From 2bf05c78e995b15ae8651e0b2b8721aa04b591e7 Mon Sep 17 00:00:00 2001 From: AlberLC Date: Sat, 8 Jan 2022 01:12:31 +0100 Subject: [PATCH] Initial commit --- .github/workflows/publish.yaml | 41 ++ .gitignore | 159 ++++++++ LICENSE | 21 + README.rst | 26 ++ multibot/__init__.py | 4 + multibot/bots/__init__.py | 4 + multibot/bots/discord_bot.py | 264 +++++++++++++ multibot/bots/multi_bot.py | 509 +++++++++++++++++++++++++ multibot/bots/telegram_bot.py | 429 +++++++++++++++++++++ multibot/bots/twitch_bot.py | 235 ++++++++++++ multibot/constants.py | 58 +++ multibot/exceptions.py | 6 + multibot/models/__init__.py | 8 + multibot/models/bot_action.py | 29 ++ multibot/models/chat.py | 20 + multibot/models/database.py | 4 + multibot/models/enums.py | 18 + multibot/models/event_component.py | 19 + multibot/models/message.py | 36 ++ multibot/models/registered_callback.py | 52 +++ multibot/models/user.py | 16 + pyproject.toml | 6 + requirements.txt | Bin 0 -> 156 bytes setup.cfg | 35 ++ 24 files changed, 1999 insertions(+) create mode 100644 .github/workflows/publish.yaml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 multibot/__init__.py create mode 100644 multibot/bots/__init__.py create mode 100644 multibot/bots/discord_bot.py create mode 100644 multibot/bots/multi_bot.py create mode 100644 multibot/bots/telegram_bot.py create mode 100644 multibot/bots/twitch_bot.py create mode 100644 multibot/constants.py create mode 100644 multibot/exceptions.py create mode 100644 multibot/models/__init__.py create mode 100644 multibot/models/bot_action.py create mode 100644 multibot/models/chat.py create mode 100644 multibot/models/database.py create mode 100644 multibot/models/enums.py create mode 100644 multibot/models/event_component.py create mode 100644 multibot/models/message.py create mode 100644 multibot/models/registered_callback.py create mode 100644 multibot/models/user.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.cfg diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..206540d --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,41 @@ +name: Publish + +on: + release: + types: [published] + +jobs: + publish-pip: + runs-on: ubuntu-latest + steps: + - name: Clone and checkout branch + uses: actions/checkout@v1 + + - name: Set up Python version + uses: actions/setup-python@v2 + with: + python-version: '3.10' + + - name: Install build and twine + run: pip install -U build twine + + - name: Update setup.cfg + run: | + sed -i " + s/{project_name}/${{ github.event.repository.name }}/g; + s/{project_version}/${{ github.ref_name }}/g; + s/{author}/${{ github.repository_owner }}/g; + s/{description}/${{ github.event.repository.description }}/g + " setup.cfg + + - name: Build package + run: python -m build + + - name: Check binaries + run: twine check dist/* + + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8206b08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,159 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +-------- + + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit tests / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Visual studio code +.vscode + +#private keys and certs +cert.pem +private.key + +# PyCharm +.idea + +# Databases +*.db + +# Deprecateds +*.dep + +# Telethon sessions +*.session* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..40ecf9b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 AlberLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..5d46856 --- /dev/null +++ b/README.rst @@ -0,0 +1,26 @@ +MultiBot +======== + +|license| |project_version| |python_version| + +Platform agnostic high-level bot infrastructure. + +Installation +------------ + +Python 3.10 or higher is required. + +.. code-block:: python + + pip install multibot + + +.. |license| image:: https://img.shields.io/github/license/AlberLC/multibot?style=flat + :target: https://github.com/AlberLC/multibot/blob/main/LICENSE + :alt: License + +.. |project_version| image:: https://img.shields.io/pypi/v/multibot + :alt: PyPI + +.. |python_version| image:: https://img.shields.io/pypi/pyversions/multibot + :alt: PyPI - Python Version \ No newline at end of file diff --git a/multibot/__init__.py b/multibot/__init__.py new file mode 100644 index 0000000..0f7dbb8 --- /dev/null +++ b/multibot/__init__.py @@ -0,0 +1,4 @@ +from multibot.bots import * +from multibot.constants import * +from multibot.exceptions import * +from multibot.models import * diff --git a/multibot/bots/__init__.py b/multibot/bots/__init__.py new file mode 100644 index 0000000..bbdb9b3 --- /dev/null +++ b/multibot/bots/__init__.py @@ -0,0 +1,4 @@ +from multibot.bots.discord_bot import * +from multibot.bots.multi_bot import * +from multibot.bots.telegram_bot import * +from multibot.bots.twitch_bot import * diff --git a/multibot/bots/discord_bot.py b/multibot/bots/discord_bot.py new file mode 100644 index 0000000..f8ed741 --- /dev/null +++ b/multibot/bots/discord_bot.py @@ -0,0 +1,264 @@ +from __future__ import annotations # todo0 remove in 3.11 + +import asyncio +import datetime +import io +import random +from typing import Iterable + +import discord +import flanautils +from discord.ext.commands import Bot +from flanautils import Media, MediaType, OrderedSet, Source, return_if_first_empty + +from multibot import constants +from multibot.bots.multi_bot import MultiBot, parse_arguments +from multibot.exceptions import LimitError, SendError +from multibot.models import BotPlatform, Chat, Message, User + + +# --------------------------------------------------------------------------------------------------- # +# ------------------------------------------- DISCORD_BOT ------------------------------------------- # +# --------------------------------------------------------------------------------------------------- # +class DiscordBot(MultiBot[Bot]): + def __init__(self, bot_token: str): + super().__init__(bot_token=bot_token, + bot_client=Bot(command_prefix=constants.DISCORD_COMMAND_PREFIX, intents=discord.Intents.all())) + + # ----------------------------------------------------------- # + # -------------------- PROTECTED METHODS -------------------- # + # ----------------------------------------------------------- # + def _add_handlers(self): + super()._add_handlers() + self.bot_client.add_listener(self._on_ready, 'on_ready') + self.bot_client.add_listener(self._on_new_message_raw, 'on_message') + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def _create_chat_from_discord_chat(self, discord_chat: constants.DISCORD_CHAT) -> Chat | None: + try: + users = [self._create_user_from_discord_user(member) for member in discord_chat.guild.members] + chat_name = discord_chat.name + group_id = discord_chat.guild.id + except AttributeError: + users = [await self.get_user(self.owner_id), await self.get_user(self.bot_id)] + chat_name = discord_chat.recipient.name + group_id = None + + return Chat( + id=discord_chat.channel.id, + name=chat_name, + is_group=discord_chat.channel.type is not discord.ChannelType.private, + users=users, + group_id=group_id, + original_object=discord_chat.channel + ) + + @staticmethod + @return_if_first_empty + def _create_user_from_discord_user(discord_user: constants.DISCORD_USER) -> User | None: + try: + is_admin = discord_user.guild_permissions.administrator + except AttributeError: + is_admin = None + + return User( + id=discord_user.id, + name=discord_user.name, + is_admin=is_admin, + original_object=discord_user + ) + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def _get_author(self, original_message: constants.DISCORD_EVENT) -> User | None: + return self._create_user_from_discord_user(original_message.author) + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def _get_chat(self, original_message: constants.DISCORD_EVENT) -> Chat | None: + # noinspection PyTypeChecker + return await self._create_chat_from_discord_chat(original_message.channel) + + async def _get_me(self, group_id: int = None) -> User | None: + if group_id is None: + discord_user = self.bot_client.user + else: + discord_user = self.bot_client.get_guild(group_id).get_member(self.bot_client.user.id) + return self._create_user_from_discord_user(discord_user) + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def _get_mentions(self, original_message: constants.DISCORD_EVENT) -> list[User]: + return list(OrderedSet(self._create_user_from_discord_user(user) for user in original_message.mentions) - None) + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def _get_message_id(self, original_message: constants.DISCORD_EVENT) -> int: + return original_message.id + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def _get_original_message(self, original_message: constants.DISCORD_EVENT) -> discord.Message: + return original_message + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def _get_replied_message(self, original_message: constants.DISCORD_EVENT) -> Message | None: + try: + replied_discord_message = original_message.reference.resolved + except AttributeError: + return + + if not isinstance(replied_discord_message, discord.DeletedReferencedMessage): + return await self._get_message(replied_discord_message) + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def _get_text(self, original_message: constants.DISCORD_EVENT) -> str: + return original_message.content + + @staticmethod + def _find_role_by_id(role_id: int, roles: Iterable[discord.Role]) -> discord.Role | None: + for role in roles: + if role.id == role_id: + return role + + @staticmethod + def _find_role_by_name(role_name: str, roles: Iterable[discord.Role]) -> discord.Role | None: + for role in roles: + if role.name.lower() == role_name.lower(): + return role + + @staticmethod + @return_if_first_empty + async def _prepare_media_to_send(media: Media) -> discord.File | None: + if not media: + return + if media.url: + if media.source is Source.LOCAL: + with open(media.url, 'b') as file: + bytes_ = file.read() + else: + bytes_ = await flanautils.get_request(media.url) + elif media.bytes_: + bytes_ = media.bytes_ + else: + return + + if len(bytes_) > constants.DISCORD_MEDIA_MAX_BYTES: + if random.randint(0, 10): + error_message = 'El archivo pesa más de 8 MB.' + else: + error_message = 'El archivo pesa mas que tu madre' + raise SendError(error_message) + + if media.type_ is MediaType.GIF: + bytes_ = await flanautils.mp4_to_gif(bytes_) + + return discord.File(fp=io.BytesIO(bytes_), filename=f'bot_media.{media.type_.extension}') + + # ---------------------------------------------- # + # HANDLERS # + # ---------------------------------------------- # + async def _on_ready(self): + self.bot_id = self.bot_client.user.id + self.bot_name = self.bot_client.user.name + self.owner_id = (await self.bot_client.application_info()).owner.id + self.bot_platform = BotPlatform.DISCORD + await super()._on_ready() + + # -------------------------------------------------------- # + # -------------------- PUBLIC METHODS -------------------- # + # -------------------------------------------------------- # + async def ban(self, user: int | str | User, chat: int | str | Chat, seconds: int | datetime.timedelta = None, message: Message = None): # todo2 + pass + + async def clear(self, n_messages: int, chat: int | str | Chat = None, message: Message = None): # todo2 test + match chat, message: + case None, Message(): + chat = message.chat + case Message(), _: + chat = chat.chat + case _: + chat = await self.get_chat(chat) + + n_messages += 1 + try: + messages = await chat.original_object.history(limit=n_messages + 1).flatten() + except discord.ClientException: + await self._manage_exceptions(LimitError('El máximo es 99.'), message or Message(chat=chat)) + else: + await chat.original_object.delete_messages(messages) + + async def delete_message(self, message_to_delete: int | str | Message, chat: int | str | Chat = None, message: Message = None): # todo2 test + match message_to_delete: + case int() | str(): + message_to_delete = Message.find_one({'id': int(message_to_delete)}) + match chat, message: + case None, Message(): + chat = message.chat + case Message(), _: + chat = chat.chat + case _: + chat = await self.get_chat(chat) + + if message_to_delete.original_object: + await message_to_delete.original_object.delete() + else: + await self.delete_message(await chat.original_object.fetch_message(message_to_delete.id)) + message_to_delete.is_deleted = True + message_to_delete.save() + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def get_user(self, user_id: int, group_id: int = None) -> User | None: + if group_id is None: + discord_user = self.bot_client.get_user(user_id) + else: + discord_user = self.bot_client.get_guild(group_id).get_member(user_id) + + return self._create_user_from_discord_user(discord_user) + + @return_if_first_empty(exclude_self_types='DiscordBot', globals_=globals()) + async def get_chat(self, group_id: int | str | Chat = None) -> Chat | None: + if isinstance(group_id, Chat): + return group_id + + # noinspection PyTypeChecker + return await self._create_chat_from_discord_chat(self.bot_client.get_channel(int(group_id)) or await self.bot_client.fetch_channel(int(group_id))) + + @parse_arguments + async def send( + self, + text='', + media: Media = None, + buttons: list[str | list[str]] = None, + message: Message = None, + send_as_file: bool = None, + edit=False + ) -> Message: + if edit: + await message.original_object.edit(text, file=await self._prepare_media_to_send(media)) + return message + else: + return await self._get_message( + await message.chat.original_object.send(text, file=await self._prepare_media_to_send(media)) + ) + + def start(self): + async def start_(): + await self.bot_client.start(self.bot_token) + + try: + asyncio.get_running_loop() + return start_() + except RuntimeError: + asyncio.run(start_()) + + async def typing_delay(self, message: Message): + async with message.chat.original_object.typing(): + await asyncio.sleep(random.randint(1, 3)) + + def user_has_role(self, user: User, role: int | discord.Role): + if not (user_roles := getattr(user.original_object, 'roles', None)): + return + + if isinstance(role, int): + role = self._find_role_by_id(role, user.original_object.guild.roles) + + return role in user_roles + + async def unban(self, user: int | str | User, chat: int | str | Chat, message: Message = None): # todo2 + pass diff --git a/multibot/bots/multi_bot.py b/multibot/bots/multi_bot.py new file mode 100644 index 0000000..1ecdd53 --- /dev/null +++ b/multibot/bots/multi_bot.py @@ -0,0 +1,509 @@ +from __future__ import annotations # todo0 remove in 3.11 + +import datetime +import functools +import random +from abc import abstractmethod +from typing import Any, Callable, Generic, Iterable, Type, TypeVar, overload + +import discord +import flanautils +import telethon.events +import telethon.events.common +from flanautils import AmbiguityError, Media, NotFoundError, OrderedSet, RatioMatch, return_if_first_empty, shift_args_if_called + +from multibot import constants +from multibot.exceptions import LimitError, SendError +from multibot.models import BotPlatform, Chat, Message, RegisteredCallback, User + + +# ---------------------------------------------------------- # +# ----------------------- DECORATORS ----------------------- # +# ---------------------------------------------------------- # +@shift_args_if_called +def find_message(func_: Callable = None, /, return_if_not_found=False) -> Callable: + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(self: MultiBot, *args, **kwargs): + if not (message := flanautils.find((*args, *kwargs.values()), Message)): + if event := flanautils.find((*args, *kwargs.values()), constants.MESSAGE_EVENT): + message = await self._get_message(event) + elif return_if_not_found: + return + else: + raise NotFoundError('No message object') + + return await func(self, message) + + return wrapper + + return decorator(func_) if func_ else decorator + + +@shift_args_if_called +def admin(func_: Callable = None, /, is_=True, send_negative=False) -> Callable: + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + @find_message + async def wrapper(self: MultiBot, message: Message, *args, **kwargs): + message = message + if is_ == message.author.is_admin or not message.chat.is_group: + return await func(self, message, *args, **kwargs) + if send_negative: + await self.send_negative(message) + + return wrapper + + return decorator(func_) if func_ else decorator + + +def block(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*_args, **_kwargs): + return + + return wrapper + + +@shift_args_if_called +def bot_mentioned(func_: Callable = None, /, is_=True) -> Callable: + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + @find_message + async def wrapper(self: MultiBot, message: Message, *args, **kwargs): + if is_ == self.is_bot_mentioned(message): + return await func(self, message, *args, **kwargs) + + return wrapper + + return decorator(func_) if func_ else decorator + + +@shift_args_if_called +def group(func_: Callable = None, /, is_=True) -> Callable: + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + @find_message + async def wrapper(self: MultiBot, message: Message, *args, **kwargs): + if is_ == message.chat.is_group: + return await func(self, message, *args, **kwargs) + + return wrapper + + return decorator(func_) if func_ else decorator + + +def ignore_self_message(func: Callable) -> Callable: + @functools.wraps(func) + @find_message + async def wrapper(self: MultiBot, message: Message, *args, **kwargs): + if message.author.id != self.bot_id: + return await func(self, message, *args, **kwargs) + + return wrapper + + +@shift_args_if_called +def inline(func_: Callable = None, /, is_=True) -> Callable: + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + @find_message + async def wrapper(self: MultiBot, message: Message, *args, **kwargs): + if message.is_inline is None or is_ == message.is_inline: + return await func(self, message, *args, **kwargs) + + return wrapper + + return decorator(func_) if func_ else decorator + + +def out_of_service(func: Callable) -> Callable: + @functools.wraps(func) + @find_message + async def wrapper(self: MultiBot, message: Message, *_args, **_kwargs): + if self.is_bot_mentioned(message) or not message.chat.is_group: + await self.send(random.choice(constants.OUT_OF_SERVICES_PHRASES), message) + + return wrapper + + +def parse_arguments(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*args, **kwargs) -> Any: + bot: MultiBot | None = None + text = '' + media: Media | None = None + buttons: list[str | list[str]] = [] + message: Message | None = None + send_as_file: bool | None = None + edit = False + + for arg in args: + match arg: + case MultiBot() as bot: + pass + case str(text): + pass + case bool(send_as_file_) if send_as_file is None: + send_as_file = send_as_file_ + case bool(edit): + pass + case int() | float() as number: + text = str(number) + case Media() as media: + pass + case [str(), *_] as buttons: + buttons = [list(buttons)] + case [[str(), *_], *_] as buttons: + buttons = list(buttons) + for i, buttons_row in enumerate(buttons): + buttons[i] = list(buttons_row) + case Message() as message: + pass + + send_as_file = send_as_file if (kw_value := kwargs.get('send_as_file')) is None else kw_value + edit = edit if (kw_value := kwargs.get('edit')) is None else kw_value + + if edit is None: + args = (bot, text, media, buttons, message) + kwargs |= {'send_as_file': send_as_file} + else: + args = (bot, text, media, buttons, message) + kwargs |= {'send_as_file': send_as_file, 'edit': edit} + + return await func(*args, **kwargs) + + return wrapper + + +@shift_args_if_called +def reply(func_: Callable = None, /, is_=True) -> Callable: + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + @find_message + async def wrapper(self: MultiBot, message: Message, *args, **kwargs): + if is_ == bool(message.replied_message): + return await func(self, message, *args, **kwargs) + + return wrapper + + return decorator(func_) if func_ else decorator + + +# ----------------------------------------------------------------------------------------------------- # +# --------------------------------------------- MULTI_BOT --------------------------------------------- # +# ----------------------------------------------------------------------------------------------------- # +T = TypeVar('T') + + +class MultiBot(Generic[T]): + def __init__(self, bot_token: str, bot_client: T): + self.bot_id: int | None = None + self.bot_name: str | None = None + self.owner_id: int | None = None + self.bot_platform: BotPlatform | None = None + self.bot_token: str = bot_token + self.bot_client: T = bot_client + self._registered_callbacks: list[RegisteredCallback] = [] + self._registered_button_callbacks: list[Callable] = [] + + self._add_handlers() + + # ----------------------------------------------------------- # + # -------------------- PROTECTED METHODS -------------------- # + # ----------------------------------------------------------- # + def _add_handlers(self): + self.register(self._on_ban, constants.KEYWORDS['ban']) + + self.register(self._on_delete, constants.KEYWORDS['delete']) + self.register(self._on_delete, (constants.KEYWORDS['delete'], constants.KEYWORDS['message'])) + + self.register(self._on_unban, constants.KEYWORDS['unban']) + + @abstractmethod + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _get_author(self, original_message: constants.ORIGINAL_MESSAGE) -> User | None: + pass + + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _get_button_text(self, original_message: constants.ORIGINAL_MESSAGE) -> str | None: + pass + + @abstractmethod + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _get_chat(self, original_message: constants.ORIGINAL_MESSAGE) -> Chat | None: + pass + + async def _get_me(self, group_id: int | str = None) -> User | None: + pass + + @abstractmethod + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _get_mentions(self, original_message: constants.ORIGINAL_MESSAGE) -> list[User]: + pass + + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _get_message(self, event: constants.MESSAGE_EVENT) -> Message: + original_message = event if isinstance(event, constants.ORIGINAL_MESSAGE) else await self._get_original_message(event) + + message = Message( + id=await self._get_message_id(original_message), + author=await self._get_author(original_message), + text=await self._get_text(original_message), + button_text=await self._get_button_text(event), + mentions=await self._get_mentions(original_message), + chat=await self._get_chat(original_message), + replied_message=await self._get_replied_message(original_message), + is_inline=isinstance(event, telethon.events.InlineQuery.Event) if isinstance(event, constants.TELEGRAM_EVENT | constants.TELEGRAM_MESSAGE) else None, + original_object=original_message, + original_event=event + ) + message.resolve() + message.save(pull_exclude=('author', 'button_text', 'chat', 'mentions', 'replied_message'), pull_database_priority=True) + + return message + + @abstractmethod + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _get_message_id(self, original_message: constants.ORIGINAL_MESSAGE) -> int | str | None: + pass + + @abstractmethod + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _get_original_message(self, event: constants.MESSAGE_EVENT) -> constants.ORIGINAL_MESSAGE: + pass + + @abstractmethod + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _get_replied_message(self, original_message: constants.ORIGINAL_MESSAGE) -> Message | None: + pass + + @abstractmethod + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _get_text(self, original_message: constants.ORIGINAL_MESSAGE) -> str: + pass + + @return_if_first_empty(exclude_self_types='MultiBot', globals_=globals()) + async def _manage_exceptions(self, exceptions: BaseException | Iterable[BaseException], message: Message): + if not isinstance(exceptions, Iterable): + exceptions = (exceptions,) + + for exception in exceptions: + try: + raise exception + except ValueError: + await self.delete_message(message) + except LimitError as e: + await self.delete_message(message) + await self.send_error(str(e), message) + except (SendError, NotFoundError) as e: + await self.send_error(str(e), message) + except AmbiguityError: + pass # await self.send_error(f'Hay varias acciones relacionadas con tu mensaje. ¿Puedes especificar un poco más? {random.choice(constants.SAD_EMOJIS)}', message) + + def _parse_callbacks( + self, + text: str, + ratio_reward_exponent: float = 7, + keywords_lenght_penalty: float = 0.05, + minimum_ratio_to_match: float = 21 + ) -> OrderedSet[RegisteredCallback]: + text = text.lower() + text = flanautils.remove_accents(text) + text = flanautils.translate(text, {'?': ' ', '¿': ' ', '!': ' ', '¡': ' ', '_': ' ', 'auto': 'auto '}) + text = flanautils.translate(text, {'auto': 'automatico', 'matico': None, 'matic': None}) + original_text_words = OrderedSet(text.split()) + text_words = original_text_words - flanautils.CommonWords.words + + matched_callbacks: set[RatioMatch[RegisteredCallback]] = set() + always_callbacks: set[RegisteredCallback] = set() + default_callbacks: set[RegisteredCallback] = set() + for registered_callback in self._registered_callbacks: + if registered_callback.always: + always_callbacks.add(registered_callback) + elif registered_callback.default: + default_callbacks.add(registered_callback) + else: + mached_keywords_groups = 0 + total_ratio = 0 + for keywords_group in registered_callback.keywords: + text_words += [original_text_word for original_text_word in original_text_words if original_text_word in keywords_group] + word_matches = flanautils.cartesian_product_string_matching(text_words, keywords_group, min_ratio=registered_callback.min_ratio) + ratio = sum((max(matches.values()) + 1) ** ratio_reward_exponent for text_word, matches in word_matches.items()) + try: + ratio /= max(1., keywords_lenght_penalty * len(keywords_group)) + except ZeroDivisionError: + continue + if ratio: + total_ratio += ratio + mached_keywords_groups += 1 + + if mached_keywords_groups and mached_keywords_groups == len(registered_callback.keywords): + for matched_callback in matched_callbacks: + if matched_callback.element.callback == registered_callback.callback: + if total_ratio > matched_callback.ratio: + matched_callbacks.discard(matched_callback) + matched_callbacks.add(RatioMatch(registered_callback, total_ratio)) + break + else: + matched_callbacks.add(RatioMatch(registered_callback, total_ratio)) + + match sorted(matched_callbacks): + case [single]: + determined_callbacks = always_callbacks | {single.element} + case [first, second, *_] if first.ratio >= minimum_ratio_to_match: + if first.ratio == second.ratio: + raise AmbiguityError(f'\n{first.element.callback}\n{second.element.callback}') + determined_callbacks = always_callbacks | {first.element} + case _: + determined_callbacks = always_callbacks | default_callbacks + + return OrderedSet(registered_callback for registered_callback in self._registered_callbacks if registered_callback in determined_callbacks) + + async def _update_punishment(self, func_: callable, message: Message, **kwargs): + bot_user = await self._get_me(message.chat.group_id) + users: OrderedSet[User] = OrderedSet(message.mentions) - bot_user + if message.replied_message: + users.add(message.replied_message.author) + + match users: + case []: + await self.send_interrogation(message) + return + case [single] if single == bot_user: + await self.send_negative(message) + return + + for user in users: + await func_(user.id, message.chat.group_id, message=message, **kwargs) + + await flanautils.do_later(constants.COMMAND_MESSAGE_DURATION, message.original_object.delete) + + # ---------------------------------------------- # + # HANDLERS # + # ---------------------------------------------- # + @bot_mentioned + @group + @admin(send_negative=True) + async def _on_ban(self, message: Message): + await self._update_punishment(self.ban, message, time=flanautils.words_to_time(message.text)) + + @inline(False) + async def _on_delete(self, message: Message): + if message.replied_message: + if message.replied_message.author.id == self.bot_id: + await self.delete_message(message.replied_message) + await self.delete_message(message) + elif self.is_bot_mentioned(message): + await self.send_negative(message) + elif message.author.is_admin and self.is_bot_mentioned(message) and (n_messages := flanautils.sum_numbers_in_text(message.text)): + if n_messages <= 0: + await self._manage_exceptions(ValueError(), message) + return + + await self.clear(n_messages, message.chat) + + @ignore_self_message + async def _on_new_message_raw(self, message: Message): + try: + registered_callbacks = self._parse_callbacks(message.text, constants.RATIO_REWARD_EXPONENT, constants.KEYWORDS_LENGHT_PENALTY, constants.MINIMUM_RATIO_TO_MATCH) + except AmbiguityError as e: + await self._manage_exceptions(e, message) + else: + for registered_callback in registered_callbacks: + await registered_callback(message) + + async def _on_ready(self): + print(f'{self.bot_name} activado en {self.bot_platform.name} (id: {self.bot_id})') + + @bot_mentioned + @group + @admin(send_negative=True) + async def _on_unban(self, message: Message): + await self._update_punishment(self.unban, message) + + # -------------------------------------------------------- # + # -------------------- PUBLIC METHODS -------------------- # + # -------------------------------------------------------- # + async def ban(self, user: int | str | User, chat: int | str | Chat, seconds: int | datetime.timedelta = None, message: Message = None): + pass + + async def clear(self, n_messages: int, chat: int | str | Chat = None, message: Message = None): + pass + + async def delete_message(self, message_to_delete: int | str | Message, chat: int | str | Chat = None, message: Message = None): + pass + + @overload + async def get_user(self, user_id: int, group_id: int | str | str = None) -> User | None: + pass + + @overload + async def get_user(self, user_name: str, group_id: int | str | str = None) -> User | None: + pass + + async def get_user(self, user_id: int, group_id: int | str | str = None) -> User | None: + pass + + @overload + def register(self, func_: Callable = None, keywords=(), min_ratio=constants.PARSE_CALLBACKS_MIN_RATIO_DEFAULT, always=False, default=False): + pass + + @overload + def register(self, keywords=(), min_ratio=constants.PARSE_CALLBACKS_MIN_RATIO_DEFAULT, always=False, default=False): + pass + + @shift_args_if_called(exclude_self_types='MultiBot', globals_=globals()) + def register(self, func_: Callable = None, keywords: str | Iterable[str | Iterable[str]] = (), min_ratio=constants.PARSE_CALLBACKS_MIN_RATIO_DEFAULT, always=False, default=False): + def decorator(func): + self._registered_callbacks.append(RegisteredCallback(func, keywords, min_ratio, always, default)) + return func + + return decorator(func_) if func_ else decorator + + @shift_args_if_called(exclude_self_types='MultiBot', globals_=globals()) + def register_button(self, func_: Callable = None): + def decorator(func): + self._registered_button_callbacks.append(func) + return func + + return decorator(func_) if func_ else decorator + + @abstractmethod + @parse_arguments + async def send(self, text='', media: Media = None, buttons: list[str | list[str]] = None, message: Message = None, send_as_file: bool = None, edit=False, **_kwargs) -> Message | None: + pass + + @parse_arguments + async def edit(self, *args, **kwargs) -> Message: + kwargs |= {'edit': True} + return await self.send(*args, **kwargs) + + def is_bot_mentioned(self, message: Message) -> bool: + return self.bot_id in (mention.id for mention in message.mentions) + + @parse_arguments + async def send_error(self, *args, exceptions_to_ignore: Type[BaseException] | Iterable[Type[BaseException]] = (), **kwargs) -> constants.ORIGINAL_MESSAGE: + bot_message = await self.send(*args, **kwargs) + await flanautils.do_later(constants.ERROR_MESSAGE_DURATION, self.delete_message, bot_message, exceptions_to_ignore=exceptions_to_ignore or discord.errors.NotFound) + return bot_message + + @inline + async def send_inline_results(self, message: Message): + pass + + async def send_interrogation(self, message: Message) -> constants.ORIGINAL_MESSAGE: + return await self.send(random.choice(constants.INTERROGATION_PHRASES), message) + + async def send_negative(self, message: Message) -> constants.ORIGINAL_MESSAGE: + return await self.send(random.choice(constants.NO_PHRASES), message) + + @abstractmethod + async def start(self): + pass + + async def typing_delay(self, message: Message): + pass + + async def unban(self, user: int | str | User, chat: int | str | Chat, message: Message = None): + pass diff --git a/multibot/bots/telegram_bot.py b/multibot/bots/telegram_bot.py new file mode 100644 index 0000000..9866478 --- /dev/null +++ b/multibot/bots/telegram_bot.py @@ -0,0 +1,429 @@ +from __future__ import annotations # todo0 remove in 3.11 + +import asyncio +import datetime +import functools +import io +import pathlib +import random +import struct +from typing import Any, Callable + +import flanautils +import pymongo +import telethon.events.common +import telethon.hints +import telethon.tl.functions.channels +import telethon.tl.types +from flanautils import Media, MediaType, OrderedSet, Source, return_if_first_empty, shift_args_if_called +from telethon import TelegramClient +from telethon.sessions import StringSession + +from multibot import constants +from multibot.bots.multi_bot import MultiBot, find_message, inline, parse_arguments +from multibot.exceptions import LimitError +from multibot.models import BotPlatform, Chat, Message, User + + +# ---------------------------------------------------------- # +# ----------------------- DECORATORS ----------------------- # +# ---------------------------------------------------------- # +@shift_args_if_called +def user_client(func_: Callable = None, /, is_=True) -> Callable: + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(self: TelegramBot, *args, **kwargs): + if is_ == bool(self.user_client): + return await func(self, *args, **kwargs) + + return wrapper + + return decorator(func_) if func_ else decorator + + +# ---------------------------------------------------------------------------------------------------- # +# ------------------------------------------- TELEGRAM_BOT ------------------------------------------- # +# ---------------------------------------------------------------------------------------------------- # +class TelegramBot(MultiBot[TelegramClient]): + def __init__(self, api_id: int | str, api_hash: int | str, phone: int | str = None, bot_token: str = None, bot_session: str = None, user_session: str = None): + self.api_id = api_id + self.api_hash = api_hash + self.phone = phone + self.bot_session = bot_session + self.user_session = user_session + if bot_session: + bot_client = TelegramClient(StringSession(bot_session), self.api_id, self.api_hash) + else: + bot_client = TelegramClient('bot_session', self.api_id, self.api_hash) + if user_session: + self.user_client = TelegramClient(StringSession(user_session), self.api_id, self.api_hash) + elif input('Do you want to add an user-bot too? (you will need a phone number later) [y/n]: ').strip().lower() in ('y', 'yes', 's', 'si', 'sí', '1', 'true', 'ok', 'vale'): + self.user_client = TelegramClient('user_session', self.api_id, self.api_hash) + else: + self.user_client = None + super().__init__(bot_token=bot_token, + bot_client=bot_client) + + # ----------------------------------------------------------- # + # -------------------- PROTECTED METHODS -------------------- # + # ----------------------------------------------------------- # + # noinspection PyTypeChecker + def _add_handlers(self): + super()._add_handlers() + self.bot_client.add_event_handler(self._on_button_press_raw, telethon.events.CallbackQuery) + self.bot_client.add_event_handler(self._on_inline_query_raw, telethon.events.InlineQuery) + self.bot_client.add_event_handler(self._on_new_message_raw, telethon.events.NewMessage) + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _create_chat_from_telegram_chat(self, telegram_chat: constants.TELEGRAM_CHAT) -> Chat | None: + if is_group := not isinstance(telegram_chat, constants.TELEGRAM_USER): + users = [await self._create_user_from_telegram_user(participant, telegram_chat.id) for participant in await self.bot_client.get_participants(telegram_chat)] + else: + users = [await self.get_user(self.owner_id), await self.get_user(self.bot_id)] + + return Chat( + id=telegram_chat.id, + name=await self._get_name_from_entity(telegram_chat), + is_group=is_group, + users=users, + group_id=telegram_chat.id, + original_object=telegram_chat + ) + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _create_bot_message_from_telegram_bot_message(self, original_message: constants.TELEGRAM_MESSAGE, message: Message, content: Any = None) -> Message | None: + content = content or {} + original_message._sender = await self.bot_client.get_entity(self.bot_id) + original_message._chat = message.chat.original_object + bot_message = await self._get_message(original_message) + bot_message.contents = [content] + return bot_message + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _create_user_from_telegram_user(self, original_user: constants.TELEGRAM_USER, group_id: int = None) -> User | None: + try: + is_admin = (await self.bot_client.get_permissions(group_id, original_user)).is_admin + except (AttributeError, TypeError, ValueError): + is_admin = None + + return User( + id=original_user.id, + name=await self._get_name_from_entity(original_user), + is_admin=is_admin, + original_object=original_user + ) + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _get_author(self, original_message: constants.TELEGRAM_MESSAGE) -> User | None: + return await self._create_user_from_telegram_user(original_message.sender, original_message.chat.id) + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _get_button_text(self, event: constants.TELEGRAM_EVENT) -> str | None: + try: + return event.data.decode() + except AttributeError: + pass + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _get_chat(self, original_message: constants.TELEGRAM_MESSAGE) -> Chat | None: + return await self._create_chat_from_telegram_chat(original_message.chat) + + async def _get_me(self, group_id: int = None) -> User | None: + return await self._create_user_from_telegram_user(await self.bot_client.get_me(), group_id) + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _get_mentions(self, original_message: constants.TELEGRAM_MESSAGE) -> list[User]: + mentions = OrderedSet() + try: + entities = original_message.entities or () + except AttributeError: + return list(mentions) + + chat = await self._get_chat(original_message) + text = await self._get_text(original_message) + + if 'flanabot' in text.lower(): + mentions.add(await self._get_me(chat.group_id)) + + for entity in entities: + try: + mentions.add(await self.get_user(text[entity.offset:entity.offset + entity.length], chat.group_id)) + except ValueError: + pass + + return list(mentions - None) + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _get_message_id(self, original_message: constants.TELEGRAM_MESSAGE) -> int | None: + return original_message.id + + @return_if_first_empty('', exclude_self_types='TelegramBot', globals_=globals()) + async def _get_name_from_entity(self, entity: telethon.hints.EntityLike) -> str: + if isinstance(entity, telethon.types.User): + return f'@{entity.username}' if entity.username else entity.first_name + elif isinstance(entity, (telethon.types.Channel, telethon.types.Chat)): + return entity.title + + return '' + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _get_original_message(self, event: constants.TELEGRAM_EVENT) -> telethon.custom.Message: + if isinstance(event, telethon.events.CallbackQuery.Event): + return await event.get_message() + else: + return getattr(event, 'message', event) + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _get_replied_message(self, original_message: constants.TELEGRAM_MESSAGE) -> Message | None: + try: + return await self._get_message(await original_message.get_reply_message()) + except AttributeError: + pass + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def _get_text(self, original_message: constants.TELEGRAM_MESSAGE) -> str: + return original_message.text + + @staticmethod + @return_if_first_empty + async def _prepare_media_to_send(media: Media) -> str | io.BytesIO | None: + if media.url: + if not pathlib.Path(media.url).is_file() and media.source is Source.INSTAGRAM and (not (path_suffix := pathlib.Path(media.url).suffix) or len(path_suffix) > constants.MAX_FILE_EXTENSION_LENGHT): + file = f'{media.url}.{media.type_.extension}' + else: + file = media.url + elif media.bytes_: + file = io.BytesIO(media.bytes_) + file.name = f'bot_media.{media.type_.extension}' + else: + return + + return file + + # ---------------------------------------------- # + # HANDLERS # + # ---------------------------------------------- # + @find_message + async def _on_button_press_raw(self, message: Message): + for registered_button_callback in self._registered_button_callbacks: + await registered_button_callback(message) + + @find_message + async def _on_inline_query_raw(self, message: Message): + await super()._on_new_message_raw(message) + + async def _on_ready(self): + me = await self.bot_client.get_me() + self.bot_id = me.id + self.bot_name = me.username + if self.user_client: + async with self.user_client: + self.owner_id = (await self.user_client.get_me()).id + self.bot_platform = BotPlatform.TELEGRAM + await super()._on_ready() + + # -------------------------------------------------------- # + # -------------------- PUBLIC METHODS -------------------- # + # -------------------------------------------------------- # + async def ban(self, user: int | str | User, chat: int | str | Chat, seconds: int | datetime.timedelta = None, message: Message = None): # todo4 test en grupo de pruebas + ... + # if isinstance(user, User): + # user = user.original_object + # if isinstance(chat, Chat): + # chat = chat.original_object + # if isinstance(seconds, int): + # seconds = datetime.timedelta(seconds=seconds) + # + # rights = telethon.tl.types.ChatBannedRights( + # until_date=datetime.datetime.now() + seconds if seconds else None, + # view_messages=True, + # send_messages=True, + # send_media=True, + # send_stickers=True, + # send_gifs=True, + # send_games=True, + # send_inline=True, + # embed_links=True, + # send_polls=True, + # change_info=True, + # invite_users=True, + # pin_messages=True + # ) + # + # await self.bot_client(telethon.tl.functions.channels.EditBannedRequest(chat, user, rights)) + + @user_client + async def clear(self, n_messages: int, chat: int | str | Chat = None, message: Message = None): + if n_messages > constants.TELEGRAM_DELETE_MESSAGE_LIMIT: + await self._manage_exceptions(LimitError('El máximo es 100.'), message or Message(chat=chat)) # todo4 probar en grupo de pruebas >100 mensajes y <=100 mensajes + return + match chat, message: + case None, Message(): + chat = message.chat + case Message(), _: + chat = chat.chat + case _: + chat = await self.get_chat(chat) + + n_messages += 1 + + async with self.user_client: + owner_user = await self._create_user_from_telegram_user(await self.user_client.get_me(), chat.group_id) + if owner_user not in chat.users: + return + + async with self.user_client: + user_chat = await self.user_client.get_entity(chat.id) + messages_to_delete = await self.user_client.get_messages(user_chat, n_messages) + await self.user_client.delete_messages(user_chat, messages_to_delete) + for message_to_delete in messages_to_delete: + message_to_delete.is_deleted = True + message_to_delete.save() + + async def delete_message(self, message_to_delete: int | str | Message, chat: int | str | Chat = None, message: Message = None): + match message_to_delete: + case int() | str(): + message_to_delete = Message.find_one({'id': str(message_to_delete)}) + match chat, message: + case None, Message(): + chat = message.chat + case Message(), _: + chat = chat.chat + case _: + chat = await self.get_chat(chat) + + if message_to_delete.original_object: + await message_to_delete.original_object.delete() + else: + await self.bot_client.delete_messages(chat.original_object, message_to_delete.id) + message_to_delete.is_deleted = True + message_to_delete.save() + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def get_chat(self, group_id: int | str | Chat = None) -> Chat | None: + if isinstance(group_id, Chat): + return group_id + + return await self._create_chat_from_telegram_chat(await self.bot_client.get_entity(group_id)) + + @return_if_first_empty(exclude_self_types='TelegramBot', globals_=globals()) + async def get_user(self, user_id: int | str, group_id: int = None) -> User | None: + try: + with flanautils.suppress_stderr(): + return await self._create_user_from_telegram_user(await self.bot_client.get_entity(user_id), group_id) + except struct.error: + pass + + @parse_arguments + async def send( + self, + text='', + media: Media = None, + buttons: list[str | list[str]] = None, + message: Message = None, + send_as_file: bool = None, + edit=False, + ) -> Message | None: + file = await self._prepare_media_to_send(media) + + if send_as_file is None: + word_matches = flanautils.cartesian_product_string_matching(message.text, constants.KEYWORDS['send_as_file'], min_ratio=0.65) + send_as_file_ratio = sum(max(matches.values()) for text_word, matches in word_matches.items()) + send_as_file = bool(send_as_file_ratio) + + for i, row in enumerate(buttons): + for j, column in enumerate(row): + buttons[i][j] = telethon.Button.inline(buttons[i][j]) + + kwargs = { + 'file': file, + 'force_document': send_as_file, + 'buttons': buttons or None + } + + if message.is_inline and media: + if media.source is Source.TIKTOK and isinstance(media.content, bytes): + bot_user = User.find_one({'id': self.bot_id}) + last_bot_message_to_user = Message.find_one({'author': bot_user.object_id, 'chat': message.chat.object_id}, sort_keys=(('last_update', pymongo.DESCENDING),)) + for content in getattr(last_bot_message_to_user, 'contents', []): + if content == media.content: + break + else: + phrase = f"El contenido pesa demasiado, te lo tengo que mandar por aquí. Dame un {random.choice(('minutillo', 'minuto', 'momentito', 'momento'))}." + bot_message = await self.send(phrase, message) + bot_message.contents.clear() + bot_message.save() + original_message = await self.bot_client.send_message(message.chat.original_object, text, **kwargs) + bot_message = await self._create_bot_message_from_telegram_bot_message(original_message, message, content=getattr(media, 'content', None)) + bot_message.save() + elif media.type_ is MediaType.IMAGE: + message.contents.append(message.original_event.builder.photo(file)) + else: + message.contents.append(message.original_event.builder.document(file, title=media.type_.name.title(), type=media.type_.name.lower())) + elif edit: + try: + await message.original_object.edit(text, **kwargs) + except telethon.errors.rpcerrorlist.MessageNotModifiedError: + pass + message.contents = [getattr(media, 'content', None)] + message.save() + return message + else: + original_message = await self.bot_client.send_message(message.chat.original_object, text, **kwargs) + return await self._create_bot_message_from_telegram_bot_message(original_message, message, content=getattr(media, 'content', None)) + + @inline + async def send_inline_results(self, message: Message): + try: + await message.original_event.answer(message.contents) + except telethon.errors.rpcerrorlist.QueryIdInvalidError: + pass + + def start(self): + async def start_(): + await self.bot_client.connect() + + if not self.bot_session: + print('----- Bot client -----') + if not self.bot_token: + self.bot_token = input('Enter a bot token: ').strip() + await self.bot_client.sign_in(bot_token=self.bot_token) + print('Done.') + if not self.user_session and self.user_client: + print('----- User client -----') + async with self.user_client: + await self.user_client.sign_in(phone=self.phone) + print('Done.') + + await self._on_ready() + await self.bot_client.run_until_disconnected() + + try: + asyncio.get_running_loop() + return start_() + except RuntimeError: + asyncio.run(start_()) + + async def unban(self, user: int | str | User, chat: int | str | Chat, message: Message = None): # todo4 test en grupo de pruebas + ... + # if isinstance(user, User): + # user = user.original_object + # if isinstance(chat, Chat): + # chat = chat.original_object + # + # rights = telethon.tl.types.ChatBannedRights( + # view_messages=False, + # send_messages=False, + # send_media=False, + # send_stickers=False, + # send_gifs=False, + # send_games=False, + # send_inline=False, + # embed_links=False, + # send_polls=False, + # change_info=False, + # invite_users=False, + # pin_messages=False + # ) + # + # await self.bot_client(telethon.tl.functions.channels.EditBannedRequest(chat, user, rights)) diff --git a/multibot/bots/twitch_bot.py b/multibot/bots/twitch_bot.py new file mode 100644 index 0000000..02a8802 --- /dev/null +++ b/multibot/bots/twitch_bot.py @@ -0,0 +1,235 @@ +from __future__ import annotations # todo0 remove in 3.11 + +import asyncio +import datetime +import functools +import re +from collections import defaultdict +from typing import Iterable, Iterator + +import pymongo +import twitchio +from flanautils import Media, OrderedSet, return_if_first_empty + +from multibot.bots.multi_bot import MultiBot, parse_arguments +from multibot.models import BotPlatform, Chat, Message, User + + +# --------------------------------------------------------------------------------------------------- # +# ------------------------------------------- DISCORD_BOT ------------------------------------------- # +# --------------------------------------------------------------------------------------------------- # +class TwitchBot(MultiBot[twitchio.Client]): + def __init__(self, bot_token: str, initial_channels: Iterable[str] = None, loop: asyncio.AbstractEventLoop = None, owner_name: str = None): + super().__init__(bot_token=bot_token, + bot_client=twitchio.Client(token=bot_token, initial_channels=initial_channels, loop=loop)) + self.owner_name = owner_name + + # ----------------------------------------------------------- # + # -------------------- PROTECTED METHODS -------------------- # + # ----------------------------------------------------------- # + # noinspection PyProtectedMember + def _add_handlers(self): + super()._add_handlers() + self.bot_client._events = defaultdict(list) + self.bot_client._events['event_ready'].append(self._on_ready) + self.bot_client._events['event_message'].append(self._on_new_message_raw) + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def _create_chat_from_twitch_chat(self, twitch_chat: twitchio.Channel) -> Chat | None: + channel_name = twitch_chat.name + return Chat( + id=channel_name, + name=channel_name, + is_group=True, + users=list(OrderedSet(await self._get_me(), [await self._create_user_from_twitch_user(chatter) for chatter in twitch_chat.chatters])), + group_id=channel_name, + original_object=twitch_chat + ) + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def _create_user_from_twitch_user(self, twitch_user: twitchio.Chatter | twitchio.User, is_admin: bool = None) -> User | None: + if (id := twitch_user.id) is None: + id = next(iter(await self.bot_client.fetch_users([twitch_user.name])), None).id + + return User( + id=int(id), + name=twitch_user.name, + is_admin=getattr(twitch_user, 'is_mod', None) if is_admin is None else is_admin, + original_object=twitch_user + ) + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def _get_author(self, original_message: twitchio.Message) -> User | None: + if original_message.echo: + return await self._get_me(original_message.channel.name) + return await self._create_user_from_twitch_user(original_message.author) + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def _get_chat(self, original_message: twitchio.Message) -> Chat | None: + return await self._create_chat_from_twitch_chat(original_message.channel) + + async def _get_me(self, group_id: int | str = None) -> User | None: + return await self.get_user(self.bot_id, group_id) + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def _get_mentions(self, original_message: twitchio.Message) -> list[User]: + return [user for mention in re.findall(r'@[\d\w]+', original_message.content) if (user := await self.get_user(mention[1:], original_message.channel.name))] + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def _get_message_id(self, original_message: twitchio.Message) -> str: + return original_message.id + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def _get_original_message(self, event: twitchio.Message) -> twitchio.Message: + return event + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def _get_replied_message(self, original_message: twitchio.Message) -> Message | None: + try: + return Message.find_one({'id': original_message.tags['reply-parent-msg-id']}) + except KeyError: + pass + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def _get_text(self, original_message: twitchio.Message) -> str: + return re.sub(r'@[\d\w]+', '', original_message.content).strip() + + # ---------------------------------------------- # + # HANDLERS # + # ---------------------------------------------- # + async def _on_ready(self): + self.bot_id = (await self.get_user(self.bot_client.nick)).id + self.bot_name = self.bot_client.nick + self.owner_id = user.id if (user := await self.get_user(self.owner_name)) else None + self.bot_platform = BotPlatform.TWITCH + await super()._on_ready() + + # -------------------------------------------------------- # + # -------------------- PUBLIC METHODS -------------------- # + # -------------------------------------------------------- # + async def ban(self, user: int | str | User, chat: int | str | Chat, seconds: int | datetime.timedelta = None, message: Message = None): + match user: + case int(): + user = (await self.get_user(user)).name + case User(): + user = user.name + chat = await self.get_chat(chat) + if isinstance(seconds, datetime.timedelta): + seconds = seconds.total_seconds() + + if seconds: + await self.send(f'/timeout {user} {seconds}', Message(chat=chat)) + else: + await self.send(f'/ban {user}', Message(chat=chat)) + + clear_user_messages = functools.partialmethod(ban, seconds=1) + + async def clear(self, n_messages: int, chat: int | str | Chat | Message = None, message: Message = None): + match chat, message: + case None, Message(): + chat = message.chat + case Message(), _: + chat = chat.chat + case _: + chat = await self.get_chat(chat) + + owner_user = User.find_one({'name': self.owner_name}) + messages_to_delete: Iterator[Message] = Message.find({'author': {'$ne': owner_user.object_id}, 'chat': chat.object_id, 'is_deleted': False, 'last_update': {'$gt': datetime.datetime.now() - datetime.timedelta(days=1)}}, sort_keys=(('last_update', pymongo.DESCENDING),), lazy=True) + + deleted_message_count = 0 + + while deleted_message_count < n_messages: + try: + message_to_delete = next(messages_to_delete) + except StopIteration: + break + + if not message_to_delete.author.is_admin: + await self.delete_message(message_to_delete, chat) + deleted_message_count += 1 + + async def delete_message(self, message_to_delete: int | str | Message, chat: int | str | Chat | Message = None, message: Message = None): + match message_to_delete: + case int() | str(): + message_to_delete = Message.find_one({'id': str(message_to_delete)}) + if message_to_delete.author.is_admin: + return + + match chat, message: + case None, Message(): + chat = message.chat + case Message(), _: + chat = chat.chat + case _: + chat = await self.get_chat(chat) + + message = message or Message() + message.chat = chat + await self.send(f'/delete {message_to_delete.id}', message) + message_to_delete.is_deleted = True + message_to_delete.save() + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def get_chat(self, group_id: int | str | Chat = None) -> Chat | None: + if isinstance(group_id, Chat): + return group_id + + return await self._create_chat_from_twitch_chat(self.bot_client.get_channel(str(group_id)) or await self.bot_client.fetch_channel(str(group_id))) + + @return_if_first_empty(exclude_self_types='TwitchBot', globals_=globals()) + async def get_user(self, user_id: int | str | User, group_id: int | str = None) -> User | None: + if isinstance(user_id, User): + return user_id + + twitch_user: twitchio.User | twitchio.Chatter + if not (twitch_user := next(iter(await self.bot_client.fetch_users([user_id])), None)): + return + + if group_id: + if twitch_user.name != str(group_id): + chat = await self.get_chat(group_id) + twitch_user = chat.original_object.get_chatter(twitch_user.name) or next(iter(list((chatter for chatter in chat.original_object.chatters if chatter.id == twitch_user.id))), None) + is_admin = twitch_user.is_mod if twitch_user else None + elif twitch_user.name == str(group_id): + is_admin = True + else: + is_admin = False + else: + is_admin = None + return await self._create_user_from_twitch_user(twitch_user, is_admin=is_admin) + + async def join(self, chat_name: str | Iterable[str]): + await self.bot_client.join_channels((chat_name,) if isinstance(chat_name, str) else chat_name) + + @parse_arguments + async def send( + self, + text='', + media: Media = None, + buttons: list[str | list[str]] = None, + message: Message = None, + send_as_file: bool = None, + edit=False + ): + await message.chat.original_object.send(text) + + def start(self): + async def start_(): + await asyncio.create_task(self.bot_client.connect()) + # noinspection PyProtectedMember + await self.bot_client._connection._keep_alive() + + try: + asyncio.get_running_loop() + return start_() + except RuntimeError: + self.bot_client.run() + + async def unban(self, user: int | str | User, chat: int | str | Chat, message: Message = None): + match user: + case int(): + user = (await self.get_user(user)).name + case User(): + user = user.name + chat = await self.get_chat(chat) + await self.send(f'/unban {user}', Message(chat=chat)) diff --git a/multibot/constants.py b/multibot/constants.py new file mode 100644 index 0000000..695bb7c --- /dev/null +++ b/multibot/constants.py @@ -0,0 +1,58 @@ +import datetime + +import discord.ext.commands +import telethon.events.common +import twitchio + +DISCORD_CHAT = discord.abc.Messageable | discord.ext.commands.Context | discord.channel.DMChannel | discord.channel.GroupChannel | discord.Member | discord.channel.TextChannel | discord.abc.User +DISCORD_MESSAGE = discord.Message +DISCORD_USER = discord.User | discord.Member +DISCORD_EVENT = DISCORD_MESSAGE + +TELEGRAM_CHAT = telethon.types.Channel | telethon.types.Chat +TELEGRAM_MESSAGE = telethon.custom.Message +TELEGRAM_USER = telethon.types.User +TELEGRAM_EVENT = telethon.events.common.EventCommon | telethon.events.common.EventBuilder + +TWITCH_CHAT = twitchio.Channel +TWITCH_MESSAGE = twitchio.Message +TWITCH_USER = twitchio.Chatter | twitchio.User +TWITCH_EVENT = TWITCH_MESSAGE + +ORIGINAL_CHAT = DISCORD_CHAT | TELEGRAM_CHAT | TWITCH_CHAT +ORIGINAL_MESSAGE = DISCORD_MESSAGE | TELEGRAM_MESSAGE | TWITCH_MESSAGE +ORIGINAL_USER = DISCORD_USER | TELEGRAM_USER | TWITCH_USER +MESSAGE_EVENT = DISCORD_EVENT | TELEGRAM_EVENT | TWITCH_EVENT | TELEGRAM_MESSAGE + +COMMAND_MESSAGE_DURATION = 5 +DISCORD_COMMAND_PREFIX = '/' +DISCORD_MEDIA_MAX_BYTES = 8000000 +ERROR_MESSAGE_DURATION = 10 +KEYWORDS_LENGHT_PENALTY = 0.001 +MAX_FILE_EXTENSION_LENGHT = 5 +MESSAGE_EXPIRATION_TIME = datetime.timedelta(weeks=1) +MINIMUM_RATIO_TO_MATCH = 3 +PARSE_CALLBACKS_MIN_RATIO_DEFAULT = 0.8 +RATIO_REWARD_EXPONENT = 2 +TELEGRAM_DELETE_MESSAGE_LIMIT = 100 + +SAD_EMOJIS = '😥😪😓😔😕☹🙁😞😢😭😩😰' + +INTERROGATION_PHRASES = ('?', 'que?', 'que dise', 'no entiendo', 'no entender', 'mi no entender', 'ein?', '🤔', '🤨', + '🧐', '🙄', '🙃') + +KEYWORDS = { + 'ban': ('ban', 'banea', 'banealo'), + 'delete': ('borra', 'borrado', 'borres', 'clear', 'delete', 'elimina', 'limpia', 'remove'), + 'message': ('message', 'original'), + 'send_as_file': ('arhivo', 'calidad', 'compress', 'compression', 'comprimir', 'file', 'quality'), + 'unban': ('desbanea', 'unban'), +} + +NO_PHRASES = ('NO', 'no', 'no.', 'nope', 'hin', 'ahora mismo', 'va a ser que no', 'claro que si', 'claro que si guapi', + 'no me da la gana', 'y si no?', 'paso', 'pasando', 'ahora despues', 'ahora en un rato', 'tiene pinta') + +OUT_OF_SERVICES_PHRASES = ('Estoy fuera de servicio.', 'Estoy fuera de servicio.', 'No estoy disponible :(', + 'Que estoy fuera de servicioooo', 'ahora mismo no puedo', 'dehame', 'estoy indispuesto', + 'estoy malito, me están arreglando', 'https://www.youtube.com/watch?v=4KfpmQBqNZY', + 'no estoy bien', 'no funciono', 'no me encuentro muy bien..', *SAD_EMOJIS) diff --git a/multibot/exceptions.py b/multibot/exceptions.py new file mode 100644 index 0000000..a631d85 --- /dev/null +++ b/multibot/exceptions.py @@ -0,0 +1,6 @@ +class LimitError(Exception): + pass + + +class SendError(Exception): + pass diff --git a/multibot/models/__init__.py b/multibot/models/__init__.py new file mode 100644 index 0000000..21a663c --- /dev/null +++ b/multibot/models/__init__.py @@ -0,0 +1,8 @@ +from multibot.models.bot_action import * +from multibot.models.chat import * +from multibot.models.database import * +from multibot.models.enums import * +from multibot.models.event_component import * +from multibot.models.message import * +from multibot.models.registered_callback import * +from multibot.models.user import * diff --git a/multibot/models/bot_action.py b/multibot/models/bot_action.py new file mode 100644 index 0000000..2c75874 --- /dev/null +++ b/multibot/models/bot_action.py @@ -0,0 +1,29 @@ +import datetime +from dataclasses import dataclass, field + +from flanautils import FlanaBase, MongoBase + +from multibot.models.chat import Chat +from multibot.models.database import db +from multibot.models.enums import Action +from multibot.models.message import Message +from multibot.models.user import User + + +@dataclass(eq=False) +class BotAction(MongoBase, FlanaBase): + collection = db.bot_action + _unique_keys = 'message' + _nullable_unique_keys = 'message' + + action: Action = None + message: Message = None + author: User = None + chat: Chat = None + affected_objects: list = field(default_factory=list) + date: datetime.datetime = field(default_factory=datetime.datetime.now) + + def __post_init__(self): + super().__post_init__() + self.author = self.author or getattr(self.message, 'author', None) + self.chat = self.chat or getattr(self.message, 'chat', None) diff --git a/multibot/models/chat.py b/multibot/models/chat.py new file mode 100644 index 0000000..4328a9d --- /dev/null +++ b/multibot/models/chat.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field + +from multibot import constants +from multibot.models.database import db +from multibot.models.event_component import EventComponent +from multibot.models.user import User + + +@dataclass(eq=False) +class Chat(EventComponent): + collection = db.chat + _unique_keys = 'id' + + id: int | str = None + name: str = None + is_group: bool = None + users: list[User] = field(default_factory=list) + group_id: int | str = None + config: dict[str, bool] = field(default_factory=lambda: {'auto_clear': False}) + original_object: constants.ORIGINAL_CHAT = None diff --git a/multibot/models/database.py b/multibot/models/database.py new file mode 100644 index 0000000..ff576e6 --- /dev/null +++ b/multibot/models/database.py @@ -0,0 +1,4 @@ +import pymongo + +mongo_client = pymongo.MongoClient("localhost", 27017) +db = mongo_client.flanabot diff --git a/multibot/models/enums.py b/multibot/models/enums.py new file mode 100644 index 0000000..54c36df --- /dev/null +++ b/multibot/models/enums.py @@ -0,0 +1,18 @@ +from enum import auto + +from flanautils import FlanaEnum + + +class Action(FlanaEnum): + AUTO_WEATHER_CHART = auto() + MESSAGE_DELETED = auto() + + +class BotPlatform(FlanaEnum): + DISCORD = auto() + TELEGRAM = auto() + TWITCH = auto() + + @property + def name(self): + return super().name.title() diff --git a/multibot/models/event_component.py b/multibot/models/event_component.py new file mode 100644 index 0000000..3899397 --- /dev/null +++ b/multibot/models/event_component.py @@ -0,0 +1,19 @@ +from __future__ import annotations # todo0 remove in 3.11 + +from typing import Any, TypeVar + +from flanautils import FlanaBase, MongoBase + +T = TypeVar('T', bound='EventComponent') + + +class EventComponent(MongoBase, FlanaBase): + def _dict_repr(self) -> Any: + return {k: v for k, v in super()._dict_repr().items() if k not in ('original_object', 'original_event')} + + def _json_repr(self) -> Any: + return {k: v for k, v in super()._json_repr().items() if k not in ('original_object', 'original_event')} + + @classmethod + def from_event_component(cls, event_component: EventComponent) -> T: + return cls(**super(EventComponent, event_component)._dict_repr()) diff --git a/multibot/models/message.py b/multibot/models/message.py new file mode 100644 index 0000000..06c1b1f --- /dev/null +++ b/multibot/models/message.py @@ -0,0 +1,36 @@ +from __future__ import annotations # todo0 remove in 3.11 + +import datetime +from dataclasses import dataclass, field +from typing import Iterable + +from multibot import constants +from multibot.models.chat import Chat +from multibot.models.database import db +from multibot.models.event_component import EventComponent +from multibot.models.user import User + + +@dataclass(eq=False) +class Message(EventComponent): + collection = db.message + _unique_keys = ('id', 'author') + _nullable_unique_keys = ('id', 'author') + + id: int | str = None + author: User = None + text: str = None + button_text: str = None + mentions: Iterable[User] = field(default_factory=list) + chat: Chat = None + replied_message: Message = None + last_update: datetime.datetime = None + is_inline: bool = None + contents: list = field(default_factory=list) + is_deleted: bool = False + original_object: constants.ORIGINAL_MESSAGE = None + original_event: constants.MESSAGE_EVENT = None + + def save(self, pull_exclude: Iterable[str] = (), pull_database_priority=False, references=True): + self.last_update = datetime.datetime.now() + super().save(pull_exclude, pull_database_priority, references) diff --git a/multibot/models/registered_callback.py b/multibot/models/registered_callback.py new file mode 100644 index 0000000..2cb566b --- /dev/null +++ b/multibot/models/registered_callback.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from typing import Callable, Iterable + +from flanautils import FlanaBase + +from multibot import constants + + +@dataclass +class RegisteredCallback(FlanaBase): + callback: Callable + keywords: str | Iterable[str | Iterable[str]] + min_ratio: float + always: bool + default: bool + + def __init__( + self, + callback: Callable, + keywords: str | Iterable[str | Iterable[str]] = (), + min_ratio: float = constants.PARSE_CALLBACKS_MIN_RATIO_DEFAULT, + always=False, + default=False + ): + self.callback = callback + match keywords: + case str(phrase): + self.keywords = (tuple(phrase.split()),) + case [*_, [*_]]: + self.keywords = tuple((keywords_group,) if isinstance(keywords_group, str) else keywords_group for keywords_group in keywords) + case [*_, str()]: + self.keywords = (keywords,) + case _: + self.keywords = tuple(keywords) + self.min_ratio = min_ratio + self.always = always + self.default = default + + def __post_init__(self): + self.keywords = tuple(self.keywords) + + def __call__(self, *args, **kwargs): + return self.callback(*args, **kwargs) + + def __eq__(self, other): + if isinstance(other, RegisteredCallback): + return self.callback == other.callback + else: + return self.callback == other + + def __hash__(self): + return hash(self.callback) diff --git a/multibot/models/user.py b/multibot/models/user.py new file mode 100644 index 0000000..b11674b --- /dev/null +++ b/multibot/models/user.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from multibot import constants +from multibot.models.database import db +from multibot.models.event_component import EventComponent + + +@dataclass(eq=False) +class User(EventComponent): + collection = db.user + _unique_keys = 'id' + + id: int = None + name: str = None + is_admin: bool = None + original_object: constants.ORIGINAL_USER = None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fa708b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = [ + 'setuptools', + 'wheel' +] +build-backend = 'setuptools.build_meta' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..effa55ffd036b3299f65dfdd8608d88e5e9d2b30 GIT binary patch literal 156 zcmW-bTMmFA3ycbawa&sye7Ma}9P)=3.10 +install_requires = + aiohttp + cryptg + discord.py + flanautils + hachoir + pillow + pymongo + telethon + twitchio + +[options.packages.find] +include = {project_name}* + +[options.package_data] +* = *