├── .github └── FUNDING.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.rst ├── crowdin.yml ├── data └── config.example.env ├── gojira ├── __init__.py ├── __main__.py ├── config.py ├── database │ ├── __init__.py │ ├── base.py │ ├── chats.py │ └── users.py ├── filters │ ├── __init__.py │ ├── chats.py │ └── users.py ├── handlers │ ├── __init__.py │ ├── anime │ │ ├── __init__.py │ │ ├── categories.py │ │ ├── inline.py │ │ ├── popular.py │ │ ├── scan.py │ │ ├── schedule.py │ │ ├── start.py │ │ ├── upcoming.py │ │ └── view.py │ ├── character │ │ ├── __init__.py │ │ ├── inline.py │ │ ├── popular.py │ │ ├── start.py │ │ └── view.py │ ├── doas.py │ ├── error.py │ ├── inline.py │ ├── language.py │ ├── manga │ │ ├── __init__.py │ │ ├── categories.py │ │ ├── inline.py │ │ ├── popular.py │ │ ├── start.py │ │ ├── upcoming.py │ │ └── view.py │ ├── pm_menu.py │ ├── staff │ │ ├── __init__.py │ │ ├── inline.py │ │ ├── popular.py │ │ ├── start.py │ │ └── view.py │ ├── studio │ │ ├── __init__.py │ │ ├── popular.py │ │ ├── start.py │ │ └── view.py │ ├── upcoming.py │ ├── user.py │ └── view.py ├── middlewares │ ├── __init__.py │ ├── acl.py │ └── i18n.py └── utils │ ├── __init__.py │ ├── aiohttp │ ├── __init__.py │ ├── anilist.py │ ├── client.py │ ├── jikan.py │ └── tracemoe.py │ ├── callback_data.py │ ├── command_list.py │ ├── graphql.py │ ├── keyboard.py │ ├── language.py │ ├── logging.py │ └── systools.py ├── locales ├── bot.pot └── pt_BR │ └── LC_MESSAGES │ └── bot.po ├── pyproject.toml └── service ├── README.rst └── gojira.service /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: HitaloM 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # cache 2 | __pycache__ 3 | .ruff_cache/ 4 | .mypy_cache/ 5 | 6 | # ide 7 | .vscode 8 | 9 | # venv 10 | .venv/ 11 | venv/ 12 | 13 | # gojira 14 | data/config.env 15 | gojira/database/db.sqlite3* 16 | locales/*/*/*.mo 17 | 18 | # rye 19 | requirements.lock 20 | requirements-dev.lock 21 | .python-version 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - id: detect-private-key 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - id: mixed-line-ending 10 | - id: check-toml 11 | - id: check-yaml 12 | 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | rev: v0.6.6 15 | hooks: 16 | - id: ruff-format 17 | - id: ruff 18 | args: [--fix, --exit-non-zero-on-fix, --ignore=F841] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Hitalo M. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ##################### 2 | Gojira - Telegram Bot 3 | ##################### 4 | 5 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json 6 | :target: https://github.com/charliermarsh/ruff 7 | :alt: Ruff 8 | 9 | .. image:: https://badges.crowdin.net/gojira/localized.svg 10 | :target: https://crowdin.com/project/gojira/ 11 | :alt: crowdin status 12 | 13 | .. image:: https://results.pre-commit.ci/badge/github/HitaloM/Gojira/main.svg 14 | :target: https://results.pre-commit.ci/latest/github/HitaloM/Gojira/main 15 | :alt: pre-commit.ci status 16 | 17 | This bot can get information from AniList through its API with GraphQL, supporting all AniList media. 18 | 19 | How to contribute 20 | ================= 21 | Every open source project lives from the generous help by contributors that sacrifices their time and Gojira is no different. 22 | 23 | Translations 24 | ------------ 25 | Translations should be done in our `Crowdin Project `_, 26 | as Crowdin checks for grammatical issues, provides improved context about the string to be translated and so on, 27 | thus possibly providing better quality translations. But you can also submit a pull request if you prefer to translate that way. 28 | 29 | Bot setup 30 | --------- 31 | Below you can learn how to set up the Gojira project. 32 | 33 | Requirements 34 | ~~~~~~~~~~~~ 35 | - Python 3.11.X. 36 | - An Unix-like operating system (Windows isn't supported). 37 | - Redis 38 | 39 | Instructions 40 | ~~~~~~~~~~~~ 41 | 1. Create a virtualenv (This step is optional, but **highly** recommended to avoid dependency conflicts) 42 | 43 | - ``python3 -m venv .venv`` (You don't need to run it again) 44 | - ``. .venv/bin/activate`` (You must run this every time you open the project in a new shell) 45 | 46 | 2. Install dependencies from the pyproject.toml with ``python3 -m pip install . -U``. 47 | 3. Go to https://my.telegram.org/apps and create a new app. 48 | 4. Compile the desired locales (languages) as instructed `here `_. 49 | 5. Start the Redis service: 50 | 51 | 52 | - ``systemctl start redis`` 53 | 54 | 55 | 6. Create a new ``config.env`` in ``data/``, there is a ``config.example.env`` file for you to use as a template. 56 | 7. After completing the ``config.env`` file, run ``python3 -m gojira`` to start the bot. 57 | 58 | Tools 59 | ~~~~~ 60 | - Use `ruff `_ to lint and format your code. 61 | - We recommend using `pre-commit `_ to automate the above tools. 62 | - We use VSCode and recommend it with the Python, Pylance and Intellicode extensions. 63 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /locales/bot.pot 3 | translation: /locales/%locale_with_underscore%/LC_MESSAGES/bot.po 4 | -------------------------------------------------------------------------------- /data/config.example.env: -------------------------------------------------------------------------------- 1 | BOT_TOKEN = "YOUR_BOT_TOKEN" 2 | -------------------------------------------------------------------------------- /gojira/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import asyncio 5 | from contextlib import suppress 6 | from pathlib import Path 7 | 8 | import uvloop 9 | from aiogram import Bot, Dispatcher 10 | from aiogram.client.default import DefaultBotProperties 11 | from aiogram.enums import ParseMode 12 | from aiogram.utils.i18n import I18n 13 | from cashews import cache 14 | 15 | from gojira.config import config 16 | from gojira.utils.aiohttp import AniListClient, JikanClient, TraceMoeClient 17 | from gojira.utils.logging import log 18 | from gojira.utils.systools import ShellExceptionError, shell_run 19 | 20 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 21 | 22 | commit_count = "None" 23 | commit_hash = "None" 24 | with suppress(ShellExceptionError): 25 | commit_count = asyncio.run(shell_run("git rev-list --count HEAD")) 26 | commit_hash = asyncio.run(shell_run("git rev-parse --short HEAD")) 27 | 28 | __version__ = f"{commit_hash} ({commit_count})" 29 | 30 | log.info("Starting Gojira...", version=__version__) 31 | 32 | app_dir: Path = Path(__file__).parent.parent 33 | locales_dir: Path = app_dir / "locales" 34 | 35 | cache.setup(f"redis://{config.redis_host}", client_side=True) 36 | 37 | # Aiohttp Clients 38 | AniList = AniListClient() 39 | TraceMoe = TraceMoeClient() 40 | Jikan = JikanClient() 41 | 42 | bot = Bot( 43 | token=config.bot_token.get_secret_value(), 44 | default=DefaultBotProperties( 45 | parse_mode=ParseMode.HTML, 46 | ), 47 | ) 48 | dp = Dispatcher() 49 | i18n = I18n(path=locales_dir, default_locale="en", domain="bot") 50 | -------------------------------------------------------------------------------- /gojira/__main__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import asyncio 5 | import sys 6 | from contextlib import suppress 7 | 8 | import sentry_sdk 9 | from aiogram import __version__ as aiogram_version 10 | from aiogram.exceptions import TelegramForbiddenError 11 | from aiosqlite import __version__ as aiosqlite_version 12 | from cashews.exceptions import CacheBackendInteractionError 13 | from sentry_sdk.integrations.aiohttp import AioHttpIntegration 14 | from sentry_sdk.integrations.redis import RedisIntegration 15 | 16 | from gojira import AniList, Jikan, TraceMoe, bot, cache, config, dp, i18n 17 | from gojira import __version__ as gojira_version 18 | from gojira.database import create_tables 19 | from gojira.handlers import load_modules 20 | from gojira.middlewares.acl import ACLMiddleware 21 | from gojira.middlewares.i18n import MyI18nMiddleware 22 | from gojira.utils.command_list import set_ui_commands 23 | from gojira.utils.logging import log 24 | 25 | 26 | async def main(): 27 | try: 28 | await cache.ping() 29 | except (CacheBackendInteractionError, TimeoutError): 30 | sys.exit(log.critical("Can't connect to RedisDB! Exiting...")) 31 | 32 | await create_tables() 33 | 34 | if config.sentry_url: 35 | log.info("Starting sentry.io integraion.") 36 | 37 | sentry_sdk.init( 38 | str(config.sentry_url), 39 | traces_sample_rate=1.0, 40 | integrations=[RedisIntegration(), AioHttpIntegration()], 41 | ) 42 | 43 | dp.message.middleware(ACLMiddleware()) 44 | dp.message.middleware(MyI18nMiddleware(i18n=i18n)) 45 | dp.callback_query.middleware(ACLMiddleware()) 46 | dp.callback_query.middleware(MyI18nMiddleware(i18n=i18n)) 47 | dp.inline_query.middleware(ACLMiddleware()) 48 | dp.inline_query.middleware(MyI18nMiddleware(i18n=i18n)) 49 | 50 | load_modules(dp) 51 | 52 | await set_ui_commands(bot, i18n) 53 | 54 | with suppress(TelegramForbiddenError): 55 | if config.logs_channel: 56 | log.info("Sending startup notification.") 57 | await bot.send_message( 58 | config.logs_channel, 59 | text=( 60 | "Gojira is up and running!\n\n" 61 | f"Version: {gojira_version}\n" 62 | f"AIOgram version: {aiogram_version}\n" 63 | f"AIOSQLite version: {aiosqlite_version}" 64 | ), 65 | ) 66 | 67 | # resolve used update types 68 | useful_updates = dp.resolve_used_update_types() 69 | await dp.start_polling(bot, allowed_updates=useful_updates) 70 | 71 | # close aiohttp connections 72 | log.info("Closing aiohttp connections.") 73 | await AniList.close() 74 | await Jikan.close() 75 | await TraceMoe.close() 76 | 77 | # clear cashews cache 78 | log.info("Clearing cashews cache.") 79 | await cache.clear() 80 | 81 | 82 | if __name__ == "__main__": 83 | try: 84 | asyncio.run(main()) 85 | except (KeyboardInterrupt, SystemExit): 86 | log.info("Gojira stopped!") 87 | -------------------------------------------------------------------------------- /gojira/config.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from typing import ClassVar 5 | 6 | from pydantic import AnyHttpUrl, SecretStr 7 | from pydantic_settings import BaseSettings 8 | 9 | 10 | class Settings(BaseSettings): 11 | bot_token: SecretStr 12 | redis_host: str = "localhost" 13 | sentry_url: AnyHttpUrl | None = None 14 | sudoers: ClassVar[list[int]] = [918317361] 15 | logs_channel: int | None = None 16 | 17 | class Config: 18 | env_file = "data/config.env" 19 | env_file_encoding = "utf-8" 20 | 21 | 22 | config = Settings() # type: ignore[arg-type] 23 | -------------------------------------------------------------------------------- /gojira/database/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from .base import DB_PATH, SqliteConnection, SqliteDBConn, create_tables 5 | from .chats import Chats 6 | from .users import Users 7 | 8 | __all__ = ("DB_PATH", "Chats", "SqliteConnection", "SqliteDBConn", "Users", "create_tables") 9 | -------------------------------------------------------------------------------- /gojira/database/base.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from pathlib import Path 5 | from types import TracebackType 6 | from typing import Any, TypeVar 7 | 8 | import aiosqlite 9 | 10 | from gojira import app_dir 11 | from gojira.utils.logging import log 12 | 13 | T = TypeVar("T") 14 | DB_PATH: Path = app_dir / "gojira/database/db.sqlite3" 15 | 16 | 17 | class SqliteDBConn: 18 | def __init__(self, db_name: Path = DB_PATH) -> None: 19 | self.db_name = db_name 20 | 21 | async def __aenter__(self) -> aiosqlite.Connection: 22 | self.conn = await aiosqlite.connect(self.db_name) 23 | self.conn.row_factory = aiosqlite.Row 24 | return self.conn 25 | 26 | async def __aexit__( 27 | self, 28 | exc_type: type | None, 29 | exc_val: BaseException | None, 30 | exc_tb: TracebackType | None, 31 | ) -> None: 32 | await self.conn.close() 33 | if exc_val: 34 | raise exc_val 35 | 36 | 37 | class SqliteConnection: 38 | @staticmethod 39 | async def __make_request( 40 | sql: str, 41 | params: list[tuple] | tuple = (), 42 | fetch: bool = False, 43 | mult: bool = False, 44 | ) -> Any: 45 | async with SqliteDBConn(DB_PATH) as conn: 46 | try: 47 | cursor = ( 48 | await conn.executemany(sql, params) 49 | if isinstance(params, list) 50 | else await conn.execute(sql, params) 51 | ) 52 | except BaseException: 53 | log.error( 54 | "Error executing SQL query!", 55 | sql_query=sql, 56 | sql_params=params, 57 | exc_info=True, 58 | ) 59 | else: 60 | if fetch: 61 | return await cursor.fetchall() if mult else await cursor.fetchone() 62 | await conn.commit() 63 | 64 | @staticmethod 65 | def _convert_to_model(data: dict, model: type[T]) -> T: 66 | return model(**data) 67 | 68 | @staticmethod 69 | async def _make_request( 70 | sql: str, 71 | params: tuple = (), 72 | fetch: bool = False, 73 | mult: bool = False, 74 | model_type: type[T] | None = None, 75 | ) -> T | list[T] | str | None: 76 | raw = await SqliteConnection.__make_request(sql, params, fetch, mult) 77 | if raw is None: 78 | return [] if mult else None 79 | if mult: 80 | return ( 81 | [SqliteConnection._convert_to_model(i, model_type) for i in raw] 82 | if model_type is not None 83 | else list(raw) 84 | ) 85 | return ( 86 | SqliteConnection._convert_to_model(raw, model_type) if model_type is not None else raw 87 | ) 88 | 89 | 90 | async def create_tables() -> None: 91 | await SqliteConnection._make_request( 92 | sql=""" 93 | CREATE TABLE IF NOT EXISTS users ( 94 | id INTEGER PRIMARY KEY, 95 | language_code TEXT 96 | ); 97 | """, 98 | ) 99 | await SqliteConnection._make_request( 100 | sql=""" 101 | CREATE TABLE IF NOT EXISTS chats ( 102 | id INTEGER PRIMARY KEY, 103 | language_code TEXT 104 | ); 105 | """, 106 | ) 107 | await SqliteConnection._make_request(sql="PRAGMA journal_mode=WAL") 108 | await SqliteConnection._make_request(sql="VACUUM") 109 | -------------------------------------------------------------------------------- /gojira/database/chats.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from typing import Literal 5 | 6 | from aiogram.types import Chat 7 | 8 | from .base import SqliteConnection 9 | 10 | 11 | class Chats(SqliteConnection): 12 | @staticmethod 13 | async def get_chat(chat: Chat) -> list | str | None: 14 | sql = "SELECT * FROM chats WHERE id = ?" 15 | params = (chat.id,) 16 | return await Chats._make_request(sql, params, fetch=True, mult=True) or None 17 | 18 | @staticmethod 19 | async def register_chat(chat: Chat) -> None: 20 | sql = "INSERT INTO chats (id) VALUES (?)" 21 | params = (chat.id,) 22 | await Chats._make_request(sql, params) 23 | 24 | @staticmethod 25 | async def get_language(chat: Chat) -> str | None: 26 | sql = "SELECT language_code FROM chats WHERE id = ?" 27 | params = (chat.id,) 28 | r = await Chats._make_request(sql, params, fetch=True) 29 | return r[0] if r and r[0] else None 30 | 31 | @staticmethod 32 | async def set_language(chat: Chat, language_code: str) -> None: 33 | sql = "UPDATE chats SET language_code = ? WHERE id = ?" 34 | params = (language_code, chat.id) 35 | if not await Chats.get_chat(chat): 36 | sql = "INSERT INTO chats (language_code, id) VALUES (?, ?)" 37 | await Chats._make_request(sql, params) 38 | 39 | @staticmethod 40 | async def get_chats_count(language_code: str | None = None) -> str | Literal[0]: 41 | sql = "SELECT COUNT(*) FROM chats" 42 | params = () 43 | if language_code: 44 | sql += " WHERE language_code = ?" 45 | params = (language_code,) 46 | r = await Chats._make_request(sql, params, fetch=True) 47 | return r[0] if r and r[0] else 0 48 | -------------------------------------------------------------------------------- /gojira/database/users.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from typing import Literal 5 | 6 | from aiogram.types import User 7 | 8 | from .base import SqliteConnection 9 | 10 | 11 | class Users(SqliteConnection): 12 | @staticmethod 13 | async def get_user(user: User) -> list | str | None: 14 | sql = "SELECT * FROM users WHERE id = ?" 15 | params = (user.id,) 16 | return await Users._make_request(sql, params, fetch=True, mult=True) or None 17 | 18 | @staticmethod 19 | async def register_user(user: User) -> None: 20 | sql = "INSERT INTO users (id) VALUES (?)" 21 | params = (user.id,) 22 | await Users._make_request(sql, params) 23 | 24 | @staticmethod 25 | async def get_language(user: User) -> str | None: 26 | sql = "SELECT language_code FROM users WHERE id = ?" 27 | params = (user.id,) 28 | return (await Users._make_request(sql, params, fetch=True) or [None])[0] 29 | 30 | @staticmethod 31 | async def set_language(user: User, language_code: str) -> None: 32 | sql = "UPDATE users SET language_code = ? WHERE id = ?" 33 | params = (language_code, user.id) 34 | if not await Users.get_user(user=user): 35 | sql = "INSERT INTO users (language_code, id) VALUES (?, ?)" 36 | await Users._make_request(sql, params) 37 | 38 | @staticmethod 39 | async def get_users_count(language_code: str | None = None) -> str | Literal[0]: 40 | sql = "SELECT COUNT(*) FROM users" 41 | params = () 42 | if language_code: 43 | sql += " WHERE language_code = ?" 44 | params = (language_code,) 45 | r = await Users._make_request(sql, params, fetch=True) 46 | return r[0] if r and r[0] else 0 47 | -------------------------------------------------------------------------------- /gojira/filters/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | -------------------------------------------------------------------------------- /gojira/filters/chats.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram.enums import ChatType 5 | from aiogram.filters import BaseFilter 6 | from aiogram.types import Chat, TelegramObject 7 | 8 | 9 | class ChatTypeFilter(BaseFilter): 10 | """Filter for chat type.""" 11 | 12 | def __init__(self, chat_type: ChatType | tuple[ChatType, ...]): 13 | self.chat_type = chat_type 14 | 15 | async def __call__(self, event: TelegramObject, event_chat: Chat) -> bool: 16 | return event_chat.type in self.chat_type 17 | -------------------------------------------------------------------------------- /gojira/filters/users.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram.enums import ChatMemberStatus, ChatType 5 | from aiogram.filters import BaseFilter 6 | from aiogram.types import CallbackQuery, Message 7 | 8 | from gojira.config import config 9 | 10 | 11 | class IsAdmin(BaseFilter): 12 | """Check if user is admin.""" 13 | 14 | @staticmethod 15 | async def __call__(union: Message | CallbackQuery) -> bool: 16 | is_callback = isinstance(union, CallbackQuery) 17 | message = union.message if is_callback else union 18 | if message is None: 19 | return False 20 | 21 | if message.chat.type == ChatType.PRIVATE: 22 | return True 23 | if union.from_user is None: 24 | return False 25 | 26 | member = await message.chat.get_member(union.from_user.id) 27 | return member.status in { 28 | ChatMemberStatus.CREATOR, 29 | ChatMemberStatus.ADMINISTRATOR, 30 | } 31 | 32 | 33 | class IsSudo(BaseFilter): 34 | """Check if user is sudo.""" 35 | 36 | @staticmethod 37 | async def __call__(union: Message | CallbackQuery) -> bool: 38 | is_callback = isinstance(union, CallbackQuery) 39 | message = union.message if is_callback else union 40 | if message is None: 41 | return False 42 | 43 | if union.from_user is None: 44 | return False 45 | 46 | return union.from_user.id in config.sudoers 47 | -------------------------------------------------------------------------------- /gojira/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import os 5 | from importlib import import_module 6 | from pathlib import Path 7 | from types import ModuleType 8 | 9 | from aiogram import Dispatcher 10 | 11 | from gojira.utils.logging import log 12 | 13 | LOADED_MODULES: dict[str, ModuleType] = {} 14 | MODULES: list[str] = [] 15 | 16 | for root, _dirs, files in os.walk(Path(__file__).parent): 17 | for file in files: 18 | if file.endswith(".py") and not file.startswith("_"): 19 | module_path = Path(root) / file 20 | module_name = ( 21 | module_path.relative_to(Path(__file__).parent) 22 | .as_posix()[:-3] 23 | .replace(os.path.sep, ".") 24 | ) 25 | MODULES.append(module_name) 26 | 27 | 28 | def load_modules( 29 | dp: Dispatcher, to_load: list[str] | None = None, to_not_load: list[str] | None = None 30 | ) -> None: 31 | if to_not_load is None: 32 | to_not_load = [] 33 | if to_load is None: 34 | to_load = ["*"] 35 | log.debug("Importing modules...") 36 | if "*" in to_load: 37 | log.debug("Loading all modules...") 38 | to_load = MODULES 39 | else: 40 | log.debug("Loading modules...", loading=" ,".join(to_load)) 41 | 42 | for module_name in (x for x in MODULES if x in to_load and x not in to_not_load): 43 | # The inline help module must be loaded last so that 44 | # there is no conflict with the inline commands 45 | if module_name == "inline": 46 | continue 47 | 48 | module = import_module(f"gojira.handlers.{module_name}") 49 | dp.include_router(module.router) 50 | LOADED_MODULES[module.__name__.split(".", 3)[2]] = module 51 | 52 | dp.include_router(import_module("gojira.handlers.inline").router) 53 | log.info("Loaded modules!", modules=", ".join(LOADED_MODULES.keys())) 54 | -------------------------------------------------------------------------------- /gojira/handlers/anime/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | -------------------------------------------------------------------------------- /gojira/handlers/anime/categories.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Router 7 | from aiogram.exceptions import TelegramAPIError 8 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton 9 | from aiogram.utils.i18n import gettext as _ 10 | 11 | from gojira import AniList 12 | from gojira.utils.callback_data import ( 13 | AnimeCallback, 14 | AnimeCategCallback, 15 | AnimeGCategCallback, 16 | StartCallback, 17 | ) 18 | from gojira.utils.keyboard import Pagination 19 | 20 | router = Router(name="anime_categories") 21 | 22 | 23 | @router.callback_query(AnimeCategCallback.filter()) 24 | async def anime_categories(callback: CallbackQuery, callback_data: AnimeCategCallback): 25 | message = callback.message 26 | if not message: 27 | return 28 | 29 | if isinstance(message, InaccessibleMessage): 30 | return 31 | 32 | page = callback_data.page 33 | 34 | categories: dict = { 35 | "Action": _("Action"), 36 | "Adventure": _("Adventure"), 37 | "Comedy": _("Comedy"), 38 | "Drama": _("Drama"), 39 | "Ecchi": _("Ecchi"), 40 | "Fantasy": _("Fantasy"), 41 | "Horror": _("Horror"), 42 | "Mahou Shoujo": _("Mahou Shoujo"), 43 | "Mecha": _("Mecha"), 44 | "Music": _("Music"), 45 | "Mystery": _("Mystery"), 46 | "Psychological": _("Psychological"), 47 | "Romance": _("Romance"), 48 | "Sci-Fi": _("Sci-Fi"), 49 | "Slice of Life": _("Slice of Life"), 50 | "Sports": _("Sports"), 51 | "Supernatural": _("Supernatural"), 52 | "Thriller": _("Thriller"), 53 | } 54 | categories_list = sorted(categories.keys()) 55 | 56 | layout = Pagination( 57 | categories_list, 58 | item_data=lambda i, pg: AnimeGCategCallback(page=pg, categorie=i).pack(), 59 | item_title=lambda i, _: categories.get(i, ""), 60 | page_data=lambda pg: AnimeCategCallback(page=pg).pack(), 61 | ) 62 | 63 | keyboard = layout.create(page, lines=5, columns=2) 64 | 65 | keyboard.inline_keyboard.append([ 66 | InlineKeyboardButton( 67 | text=_("🔙 Back"), 68 | callback_data=StartCallback(menu="anime").pack(), 69 | ) 70 | ]) 71 | 72 | with suppress(TelegramAPIError): 73 | await message.edit_text( 74 | _("Below are the categories of anime, choose one to see the results:"), 75 | reply_markup=keyboard, 76 | ) 77 | 78 | 79 | @router.callback_query(AnimeGCategCallback.filter()) 80 | async def anime_categorie(callback: CallbackQuery, callback_data: AnimeGCategCallback): 81 | message = callback.message 82 | if not message: 83 | return 84 | 85 | if isinstance(message, InaccessibleMessage): 86 | return 87 | 88 | categorie = callback_data.categorie 89 | page = callback_data.page 90 | 91 | _status, data = await AniList.categories("anime", page, categorie) 92 | 93 | if data["data"]: 94 | items = data["data"]["Page"]["media"] 95 | results = [item.copy() for item in items] 96 | 97 | layout = Pagination( 98 | results, 99 | item_data=lambda i, _: AnimeCallback(query=i["id"]).pack(), 100 | item_title=lambda i, _: i["title"]["romaji"], 101 | page_data=lambda pg: AnimeGCategCallback(page=pg, categorie=categorie).pack(), 102 | ) 103 | 104 | keyboard = layout.create(page, lines=8) 105 | 106 | keyboard.inline_keyboard.append([ 107 | InlineKeyboardButton( 108 | text=_("🔙 Back"), 109 | callback_data=AnimeCategCallback(page=1).pack(), 110 | ) 111 | ]) 112 | 113 | text = _("Below are up to 50 results from the {genre} category.").format( 114 | genre=categorie 115 | ) 116 | with suppress(TelegramAPIError): 117 | await message.edit_text(text, reply_markup=keyboard) 118 | -------------------------------------------------------------------------------- /gojira/handlers/anime/inline.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import random 5 | import re 6 | from contextlib import suppress 7 | 8 | from aiogram import F, Router 9 | from aiogram.enums import InlineQueryResultType 10 | from aiogram.exceptions import TelegramBadRequest 11 | from aiogram.types import ( 12 | InlineQuery, 13 | InlineQueryResultArticle, 14 | InputTextMessageContent, 15 | ) 16 | from aiogram.utils.i18n import gettext as _ 17 | from aiogram.utils.keyboard import InlineKeyboardBuilder 18 | from aiogram.utils.markdown import hide_link 19 | 20 | from gojira import AniList, bot 21 | from gojira.utils.language import ( 22 | i18n_anilist_format, 23 | i18n_anilist_season, 24 | i18n_anilist_source, 25 | i18n_anilist_status, 26 | ) 27 | 28 | router = Router(name="anime_inline") 29 | 30 | 31 | @router.inline_query(F.query.regexp(r"^!a (?P.+)").as_("match")) 32 | async def anime_inline(inline: InlineQuery, match: re.Match[str]): 33 | query = match.group("query") 34 | 35 | results = [] 36 | 37 | search_results = [] 38 | _status, data = await AniList.search("anime", query) 39 | if not data: 40 | return 41 | 42 | search_results = data["data"]["Page"]["media"] 43 | 44 | if not search_results: 45 | return 46 | 47 | for result in search_results: 48 | _status, data = await AniList.get("anime", result["id"]) 49 | if not data: 50 | return 51 | 52 | anime = data["data"]["Page"]["media"][0] 53 | 54 | if not anime: 55 | continue 56 | 57 | photo: str = "" 58 | if cover := anime["bannerImage"]: 59 | photo = cover 60 | elif cover := anime["coverImage"]: 61 | if xl_image := cover["extraLarge"]: 62 | photo = xl_image 63 | elif large_image := cover["large"]: 64 | photo = large_image 65 | elif medium_image := cover["medium"]: 66 | photo = medium_image 67 | 68 | description: str = "" 69 | if anime["description"]: 70 | description = anime["description"] 71 | description = re.sub(re.compile(r"<.*?>"), "", description) 72 | description = description[0:260] + "..." 73 | 74 | studios = [] 75 | producers = [] 76 | for studio in anime["studios"]["nodes"]: 77 | if studio["isAnimationStudio"]: 78 | studios.append(studio["name"]) 79 | else: 80 | producers.append(studio["name"]) 81 | 82 | end_date_components = [ 83 | component 84 | for component in ( 85 | anime["endDate"].get("day"), 86 | anime["endDate"].get("month"), 87 | anime["endDate"].get("year"), 88 | ) 89 | if component is not None 90 | ] 91 | 92 | start_date_components = [ 93 | component 94 | for component in ( 95 | anime["startDate"].get("day"), 96 | anime["startDate"].get("month"), 97 | anime["startDate"].get("year"), 98 | ) 99 | if component is not None 100 | ] 101 | 102 | end_date = "/".join(str(component) for component in end_date_components) 103 | start_date = "/".join(str(component) for component in start_date_components) 104 | 105 | text = f"{anime["title"]["romaji"]}" 106 | if anime["title"]["native"]: 107 | text += f" ({anime["title"]["native"]})" 108 | text += _("\n\nID: {id}").format(id=anime["id"]) + " (ANIME)" 109 | if anime["format"]: 110 | text += _("\nFormat: {format}").format( 111 | format=i18n_anilist_format(anime["format"]) 112 | ) 113 | if anime["format"] != "MOVIE" and anime["episodes"]: 114 | text += _("\nEpisodes: {episodes}").format( 115 | episodes=anime["episodes"] 116 | ) 117 | if anime["duration"]: 118 | text += _("\nEpisode Duration: {duration} mins").format( 119 | duration=anime["duration"] 120 | ) 121 | text += _("\nStatus: {status}").format( 122 | status=i18n_anilist_status(anime["status"]) 123 | ) 124 | if anime["status"] != "NOT_YET_RELEASED": 125 | text += _("\nStart Date: {date}").format(date=start_date) 126 | if anime["status"] not in {"NOT_YET_RELEASED", "RELEASING"}: 127 | text += _("\nEnd Date: {date}").format(date=end_date) 128 | if anime["season"]: 129 | season = f"{i18n_anilist_season(anime["season"])} {anime["seasonYear"]}" 130 | text += _("\nSeason: {season}").format(season=season) 131 | if anime["averageScore"]: 132 | text += _("\nAverage Score: {score}").format( 133 | score=anime["averageScore"] 134 | ) 135 | if anime["studios"] and len(anime["studios"]["nodes"]) > 0: 136 | text += _("\nStudios: {studios}").format( 137 | studios=", ".join(studios) 138 | ) 139 | if len(producers) > 0: 140 | text += _("\nProducers: {producers}").format( 141 | producers=", ".join(producers) 142 | ) 143 | if anime["source"]: 144 | text += _("\nSource: {source}").format( 145 | source=i18n_anilist_source(anime["source"]) 146 | ) 147 | if anime["genres"]: 148 | text += _("\nGenres: {genres}").format( 149 | genres=", ".join(anime["genres"]) 150 | ) 151 | 152 | text += _("\n\nShort Description: {description}").format( 153 | description=description 154 | ) 155 | 156 | text += f"\n{hide_link(photo)}" 157 | 158 | keyboard = InlineKeyboardBuilder() 159 | 160 | me = await bot.get_me() 161 | bot_username = me.username 162 | keyboard.button( 163 | text=_("👓 View More"), 164 | url=f"https://t.me/{bot_username}/?start=anime_{anime["id"]}", 165 | ) 166 | 167 | anime_format = f"| {anime["format"]}" if i18n_anilist_format(anime["format"]) else None 168 | 169 | results.append( 170 | InlineQueryResultArticle( 171 | type=InlineQueryResultType.ARTICLE, 172 | id=str(random.getrandbits(64)), 173 | title=f"{anime["title"]["romaji"]} {anime_format}", 174 | input_message_content=InputTextMessageContent(message_text=text), 175 | reply_markup=keyboard.as_markup(), 176 | description=description, 177 | thumbnail_url=photo, 178 | ) 179 | ) 180 | 181 | with suppress(TelegramBadRequest): 182 | if len(results) > 0: 183 | await inline.answer(results=results, is_personal=True) 184 | -------------------------------------------------------------------------------- /gojira/handlers/anime/popular.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Router 7 | from aiogram.exceptions import TelegramAPIError 8 | from aiogram.types import CallbackQuery, InlineKeyboardButton 9 | from aiogram.utils.i18n import gettext as _ 10 | 11 | from gojira import AniList 12 | from gojira.utils.callback_data import AnimeCallback, AnimePopuCallback, StartCallback 13 | from gojira.utils.keyboard import Pagination 14 | 15 | router = Router(name="anime_popular") 16 | 17 | 18 | @router.callback_query(AnimePopuCallback.filter()) 19 | async def anime_popular(callback: CallbackQuery, callback_data: AnimePopuCallback): 20 | message = callback.message 21 | if not message: 22 | return 23 | 24 | page = callback_data.page 25 | 26 | _status, data = await AniList.popular("anime") 27 | if data["data"]: 28 | items = data["data"]["Page"]["media"] 29 | results = [item.copy() for item in items] 30 | 31 | layout = Pagination( 32 | results, 33 | item_data=lambda i, _: AnimeCallback(query=i["id"]).pack(), 34 | item_title=lambda i, _: i["title"]["romaji"], 35 | page_data=lambda pg: AnimePopuCallback(page=pg).pack(), 36 | ) 37 | 38 | keyboard = layout.create(page, lines=8) 39 | 40 | keyboard.row( 41 | InlineKeyboardButton( 42 | text=_("🔙 Back"), 43 | callback_data=StartCallback(menu="anime").pack(), 44 | ) 45 | ) 46 | 47 | text = _("Below are the 50 most popular animes in descending order.") 48 | with suppress(TelegramAPIError): 49 | await message.edit_text( 50 | text=text, 51 | reply_markup=keyboard.as_markup(), 52 | ) 53 | -------------------------------------------------------------------------------- /gojira/handlers/anime/scan.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | from datetime import timedelta 6 | 7 | from aiogram import Router 8 | from aiogram.enums import ChatType, InputMediaType 9 | from aiogram.exceptions import TelegramBadRequest 10 | from aiogram.filters import Command 11 | from aiogram.types import Document, InputMediaPhoto, Message, Video 12 | from aiogram.utils.i18n import gettext as _ 13 | from aiogram.utils.keyboard import InlineKeyboardBuilder 14 | 15 | from gojira import TraceMoe, bot, cache 16 | from gojira.utils.callback_data import AnimeCallback 17 | 18 | router = Router(name="anime_scan") 19 | 20 | 21 | @router.message(Command("scan")) 22 | async def anime_scan(message: Message): 23 | user = message.from_user 24 | if not message or not user: 25 | return 26 | 27 | reply = message.reply_to_message 28 | 29 | me = await bot.get_me() 30 | if user.id == me.id: 31 | return 32 | 33 | if not reply: 34 | await message.reply(_("Reply to a message with a media.")) 35 | return 36 | 37 | media = ( 38 | reply.photo[-1] 39 | if reply.photo 40 | else reply.sticker or (reply.animation or (reply.document or (reply.video or None))) 41 | ) 42 | 43 | if not media: 44 | await message.reply(_("No media was found in this message.")) 45 | return 46 | 47 | if isinstance(media, Document | Video): 48 | if media.thumbnail: 49 | media = media.thumbnail 50 | return 51 | 52 | sent = await message.reply_photo( 53 | "https://i.imgur.com/m0N2pFc.jpg", caption="Scanning media..." 54 | ) 55 | 56 | file_id = media.file_id 57 | 58 | file = await bot.get_file(file_id) 59 | if not file or not file.file_path: 60 | await sent.edit_caption(caption=_("File not found.")) 61 | return 62 | 63 | file = await bot.download_file(file.file_path) 64 | if not file: 65 | await sent.edit_caption(caption=_("Something went wrong while downloading the file.")) 66 | return 67 | 68 | file_cached = await cache.get(f"file_tmoe:{file_id}") 69 | 70 | file = file_cached or file 71 | 72 | if not file_cached: 73 | await cache.set(f"file_tmoe:{file_id}", file, expire="1d") 74 | 75 | status, data = await TraceMoe.search(file=file) 76 | 77 | if status == 200: 78 | pass 79 | elif status == 429: 80 | await sent.edit_caption(caption=_("Excessive use of the API, please try again later.")) 81 | return 82 | else: 83 | await sent.edit_caption(caption=_("The API is unavailable, please try again later.")) 84 | return 85 | 86 | results = data["result"] 87 | if len(results) == 0: 88 | await sent.edit_caption(caption=_("No results found.")) 89 | return 90 | 91 | result = results[0] 92 | 93 | video = result["video"] 94 | to_time = result["to"] 95 | episode = result["episode"] 96 | anilist_id = result["anilist"]["id"] 97 | file_name = result["filename"] 98 | from_time = result["from"] 99 | similarity = result["similarity"] 100 | is_adult = result["anilist"]["isAdult"] 101 | title_native = result["anilist"]["title"]["native"] 102 | title_romaji = result["anilist"]["title"]["romaji"] 103 | 104 | text = f"{title_romaji}" 105 | if title_native: 106 | text += f" ({title_native})" 107 | text += _("\n\nID: {anime_id}").format(anime_id=anilist_id) 108 | if episode: 109 | text += _("\nEpisode: {episode}").format(episode=episode) 110 | if is_adult: 111 | text += _("\nAdult: Yes") 112 | text += _("\nSimilarity: {similarity}%").format( 113 | similarity=round(similarity * 100, 2) 114 | ) 115 | 116 | keyboard = InlineKeyboardBuilder() 117 | keyboard.button(text=_("👓 View more"), callback_data=AnimeCallback(query=anilist_id)) 118 | sent = await sent.edit_media( 119 | InputMediaPhoto( 120 | type=InputMediaType.PHOTO, 121 | media=f"https://img.anili.st/media/{anilist_id}", 122 | caption=text, 123 | ), 124 | reply_markup=keyboard.as_markup(), 125 | ) 126 | 127 | from_time = str(timedelta(seconds=result["from"])).split(".", 1)[0].rjust(8, "0") 128 | to_time = str(timedelta(seconds=result["to"])).split(".", 1)[0].rjust(8, "0") 129 | 130 | if video is not None: 131 | with suppress(TelegramBadRequest): 132 | cached_video = await cache.get(f"trace_moe:{file_id}") 133 | video = cached_video or f"{video}&size=l" 134 | 135 | sent_video = await reply.reply_video( 136 | video=video, 137 | caption=( 138 | f"{file_name}\n\n{from_time} - \ 139 | {to_time}" 140 | ), 141 | ) 142 | 143 | if sent_video.chat.type != ChatType.PRIVATE: 144 | keyboard.button(text=_("📹 Preview"), url=sent_video.get_url()) 145 | await bot.edit_message_reply_markup( 146 | chat_id=sent.chat.id, # type: ignore[arg-type] 147 | message_id=sent.message_id, # type: ignore[arg-type] 148 | reply_markup=keyboard.as_markup(), 149 | ) 150 | 151 | if not cached_video and sent_video.video: 152 | await cache.set(f"trace_moe:{file_id}", sent_video.video.file_id, expire="1d") 153 | -------------------------------------------------------------------------------- /gojira/handlers/anime/schedule.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import datetime 5 | 6 | from aiogram import Router 7 | from aiogram.filters import Command 8 | from aiogram.types import CallbackQuery, Message 9 | from aiogram.utils.i18n import gettext as _ 10 | from aiogram.utils.keyboard import InlineKeyboardBuilder 11 | from aiogram.utils.markdown import hlink 12 | 13 | from gojira import Jikan, bot 14 | from gojira.utils.callback_data import ScheduleCallback 15 | 16 | router = Router(name="anime_schedule") 17 | 18 | 19 | @router.message(Command("schedule")) 20 | @router.callback_query(ScheduleCallback.filter()) 21 | async def anime_schedule( 22 | union: Message | CallbackQuery, callback_data: ScheduleCallback | None = None 23 | ): 24 | is_callback = isinstance(union, CallbackQuery) 25 | message = union.message if is_callback else union 26 | user = union.from_user 27 | if not message or not user: 28 | return 29 | 30 | if is_callback and callback_data: 31 | user_id = callback_data.user_id 32 | if user_id != user.id: 33 | await union.answer( 34 | _("This button is not for you."), 35 | show_alert=True, 36 | cache_time=60, 37 | ) 38 | return 39 | 40 | day = callback_data.day if callback_data else datetime.datetime.now(tz=datetime.UTC).weekday() 41 | day_map = { 42 | 0: ["Monday", _("Monday")], 43 | 1: ["Tuesday", _("Tuesday")], 44 | 2: ["Wednesday", _("Wednesday")], 45 | 3: ["Thursday", _("Thursday")], 46 | 4: ["Friday", _("Friday")], 47 | 5: ["Saturday", _("Saturday")], 48 | 6: ["Sunday", _("Sunday")], 49 | } 50 | day_name = day_map.get(day, "None") 51 | _status, data = await Jikan.schedules(day=day_name[0].lower()) 52 | animes = data["data"] 53 | 54 | me = await bot.get_me() 55 | text = _("Below is the schedule for {day}:\n\n").format(day=day_name[1]) 56 | for n, anime in enumerate(animes, start=1): 57 | title = anime["title"] 58 | malid = anime["mal_id"] 59 | url = f"https://t.me/{me.username}/?start=malanime_{malid}" 60 | text += f"{n}. {hlink(title=title, url=url)}\n" 61 | 62 | text += _( 63 | "\nNote: The schedule was taken from MyAnimeList, some information about anime \ 64 | may not be available on AniList, so the bot won't be able to show information about the anime." 65 | ) 66 | 67 | keyboard = InlineKeyboardBuilder() 68 | if day > 0: 69 | keyboard.button( 70 | text=f"⬅️ {day_map.get(day - 1, ["None", "None"])[1]}", 71 | callback_data=ScheduleCallback(user_id=user.id, day=day - 1), 72 | ) 73 | if day < 6: 74 | keyboard.button( 75 | text=f"➡️ {day_map.get(day + 1, ["None", "None"])[1]}", 76 | callback_data=ScheduleCallback(user_id=user.id, day=day + 1), 77 | ) 78 | 79 | await (message.edit_text if is_callback else message.reply)( 80 | text, 81 | disable_web_page_preview=True, 82 | reply_markup=keyboard.as_markup(), 83 | ) 84 | -------------------------------------------------------------------------------- /gojira/handlers/anime/start.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import F, Router 5 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 6 | from aiogram.utils.i18n import gettext as _ 7 | from aiogram.utils.keyboard import InlineKeyboardBuilder 8 | 9 | from gojira.utils.callback_data import ( 10 | AnimeCategCallback, 11 | AnimePopuCallback, 12 | AnimeUpcomingCallback, 13 | StartCallback, 14 | ) 15 | 16 | router = Router(name="anime_start") 17 | 18 | 19 | @router.callback_query(StartCallback.filter(F.menu == "anime")) 20 | async def anime_start(union: Message | CallbackQuery): 21 | is_callback = isinstance(union, CallbackQuery) 22 | message = union.message if is_callback else union 23 | user = union.from_user 24 | if not message or not user: 25 | return 26 | 27 | if isinstance(message, InaccessibleMessage): 28 | return 29 | 30 | keyboard = InlineKeyboardBuilder() 31 | keyboard.button(text=_("📈 Popular"), callback_data=AnimePopuCallback(page=1)) 32 | keyboard.button(text=_("🗂️ Categories"), callback_data=AnimeCategCallback(page=1)) 33 | keyboard.button( 34 | text=_("🆕 Upcoming"), 35 | callback_data=AnimeUpcomingCallback(page=1, user_id=user.id), 36 | ) 37 | keyboard.button(text=_("🔍 Search"), switch_inline_query_current_chat="!a ") 38 | keyboard.adjust(2) 39 | 40 | keyboard.row( 41 | InlineKeyboardButton( 42 | text=_("🔙 Back"), 43 | callback_data=StartCallback(menu="help").pack(), 44 | ) 45 | ) 46 | 47 | text = _("You are in the anime section, use the buttons below to do what you want.") 48 | await (message.edit_text if is_callback else message.reply)( 49 | text, 50 | reply_markup=keyboard.as_markup(), 51 | ) 52 | -------------------------------------------------------------------------------- /gojira/handlers/anime/upcoming.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Router 7 | from aiogram.enums import ChatType 8 | from aiogram.exceptions import TelegramAPIError 9 | from aiogram.types import CallbackQuery, InlineKeyboardButton 10 | from aiogram.utils.i18n import gettext as _ 11 | 12 | from gojira import AniList 13 | from gojira.utils.callback_data import ( 14 | AnimeCallback, 15 | AnimeUpcomingCallback, 16 | StartCallback, 17 | UpcomingCallback, 18 | ) 19 | from gojira.utils.keyboard import Pagination 20 | 21 | router = Router(name="anime_upcoming") 22 | 23 | 24 | @router.callback_query(AnimeUpcomingCallback.filter()) 25 | async def anime_upcoming(callback: CallbackQuery, callback_data: AnimeUpcomingCallback): 26 | message = callback.message 27 | if not message or not message.from_user: 28 | return 29 | 30 | page = callback_data.page 31 | user_id = callback_data.user_id 32 | 33 | if user_id != callback.from_user.id: 34 | await callback.answer( 35 | _("This button is not for you."), 36 | show_alert=True, 37 | cache_time=60, 38 | ) 39 | return 40 | 41 | is_private = message.chat.type == ChatType.PRIVATE 42 | 43 | _status, data = await AniList.upcoming("anime") 44 | if data["data"]: 45 | items = data["data"]["Page"]["media"] 46 | suggestions = [item.copy() for item in items] 47 | 48 | layout = Pagination( 49 | suggestions, 50 | item_data=lambda i, _: AnimeCallback( 51 | query=int(i["id"]), user_id=user_id, is_search=(not is_private) 52 | ).pack(), 53 | item_title=lambda i, _: i["title"]["romaji"], 54 | page_data=lambda pg: AnimeUpcomingCallback(page=pg, user_id=user_id).pack(), 55 | ) 56 | 57 | keyboard = layout.create(page, lines=8) 58 | 59 | if is_private: 60 | keyboard.row( 61 | InlineKeyboardButton( 62 | text=_("🔙 Back"), 63 | callback_data=StartCallback(menu="anime").pack(), 64 | ) 65 | ) 66 | else: 67 | keyboard.row( 68 | InlineKeyboardButton( 69 | text=_("🔙 Back"), 70 | callback_data=UpcomingCallback(user_id=user_id).pack(), 71 | ) 72 | ) 73 | 74 | with suppress(TelegramAPIError): 75 | await message.edit_text( 76 | _("Below are the 50 animes that have not yet been released."), 77 | reply_markup=keyboard.as_markup(), 78 | ) 79 | -------------------------------------------------------------------------------- /gojira/handlers/character/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | -------------------------------------------------------------------------------- /gojira/handlers/character/inline.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import random 5 | import re 6 | from contextlib import suppress 7 | 8 | from aiogram import F, Router 9 | from aiogram.enums import InlineQueryResultType, ParseMode 10 | from aiogram.exceptions import TelegramBadRequest 11 | from aiogram.types import InlineQuery, InlineQueryResultPhoto 12 | from aiogram.utils.i18n import gettext as _ 13 | from aiogram.utils.keyboard import InlineKeyboardBuilder 14 | 15 | from gojira import AniList, bot 16 | 17 | router = Router(name="character_inline") 18 | 19 | 20 | @router.inline_query(F.query.regexp(r"^!c (?P.+)").as_("match")) 21 | async def character_inline(inline: InlineQuery, match: re.Match[str]): 22 | query = match.group("query") 23 | 24 | results = [] 25 | 26 | search_results = [] 27 | _status, data = await AniList.search("character", query) 28 | if not data: 29 | return 30 | 31 | search_results = data["data"]["Page"]["characters"] 32 | 33 | if not search_results: 34 | return 35 | 36 | for result in search_results: 37 | _status, data = await AniList.get("character", result["id"]) 38 | if not data: 39 | return 40 | 41 | if not data["data"]["Page"]["characters"]: 42 | continue 43 | 44 | character = data["data"]["Page"]["characters"][0] 45 | 46 | photo: str = "" 47 | if image := character["image"]: 48 | if large_image := image["large"]: 49 | photo = large_image 50 | elif medium_image := image["medium"]: 51 | photo = medium_image 52 | 53 | description: str = "" 54 | if description := character["description"]: 55 | description = description.replace("__", "*") 56 | description = description.replace("~", "||") 57 | description = description[0:500] + "..." 58 | 59 | text = f"*{character["name"]["full"]}*" 60 | text += _("\n*ID*: `{id}`").format(id=character["id"]) + " (*CHARACTER*)" 61 | if character["favourites"]: 62 | text += _("\n*Favourites*: `{favourites}`").format(favourites=character["favourites"]) 63 | 64 | text += f"\n\n{description}" 65 | 66 | keyboard = InlineKeyboardBuilder() 67 | 68 | me = await bot.get_me() 69 | bot_username = me.username 70 | keyboard.button( 71 | text=_("👓 View More"), 72 | url=f"https://t.me/{bot_username}/?start=character_{character["id"]}", 73 | ) 74 | 75 | results.append( 76 | InlineQueryResultPhoto( 77 | type=InlineQueryResultType.PHOTO, 78 | id=str(random.getrandbits(64)), 79 | photo_url=photo, 80 | thumbnail_url=photo, 81 | title=character["name"]["full"], 82 | description=description, 83 | caption=text, 84 | parse_mode=ParseMode.MARKDOWN, 85 | reply_markup=keyboard.as_markup(), 86 | ) 87 | ) 88 | 89 | with suppress(TelegramBadRequest): 90 | if len(results) > 0: 91 | await inline.answer(results=results, is_personal=True) 92 | -------------------------------------------------------------------------------- /gojira/handlers/character/popular.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Router 7 | from aiogram.exceptions import TelegramAPIError 8 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton 9 | from aiogram.utils.i18n import gettext as _ 10 | 11 | from gojira import AniList 12 | from gojira.utils.callback_data import ( 13 | CharacterCallback, 14 | CharacterPopuCallback, 15 | StartCallback, 16 | ) 17 | from gojira.utils.keyboard import Pagination 18 | 19 | router = Router(name="character_popular") 20 | 21 | 22 | @router.callback_query(CharacterPopuCallback.filter()) 23 | async def character_popular(callback: CallbackQuery, callback_data: CharacterPopuCallback): 24 | message = callback.message 25 | if not message: 26 | return 27 | 28 | if isinstance(message, InaccessibleMessage): 29 | return 30 | 31 | page = callback_data.page 32 | 33 | _status, data = await AniList.popular("character") 34 | if data["data"]: 35 | items = data["data"]["Page"]["characters"] 36 | results = [item.copy() for item in items] 37 | 38 | layout = Pagination( 39 | results, 40 | item_data=lambda i, _: CharacterCallback(query=i["id"]).pack(), 41 | item_title=lambda i, _: i["name"]["full"], 42 | page_data=lambda pg: CharacterPopuCallback(page=pg).pack(), 43 | ) 44 | 45 | keyboard = layout.create(page, lines=8) 46 | 47 | keyboard.inline_keyboard.append([ 48 | InlineKeyboardButton( 49 | text=_("🔙 Back"), 50 | callback_data=StartCallback(menu="character").pack(), 51 | ) 52 | ]) 53 | 54 | text = _("Below are the 50 most popular characters in descending order.") 55 | with suppress(TelegramAPIError): 56 | await message.edit_text(text=text, reply_markup=keyboard) 57 | -------------------------------------------------------------------------------- /gojira/handlers/character/start.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import F, Router 5 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 6 | from aiogram.utils.i18n import gettext as _ 7 | from aiogram.utils.keyboard import InlineKeyboardBuilder 8 | 9 | from gojira.utils.callback_data import CharacterPopuCallback, StartCallback 10 | 11 | router = Router(name="character_start") 12 | 13 | 14 | @router.callback_query(StartCallback.filter(F.menu == "character")) 15 | async def character_start(union: Message | CallbackQuery): 16 | is_callback = isinstance(union, CallbackQuery) 17 | message = union.message if is_callback else union 18 | if not message: 19 | return 20 | 21 | if isinstance(message, InaccessibleMessage): 22 | return 23 | 24 | keyboard = InlineKeyboardBuilder() 25 | keyboard.button(text=_("📈 Popular"), callback_data=CharacterPopuCallback(page=1)) 26 | keyboard.button(text=_("🔍 Search"), switch_inline_query_current_chat="!c ") 27 | keyboard.adjust(2) 28 | 29 | keyboard.row( 30 | InlineKeyboardButton( 31 | text=_("🔙 Back"), 32 | callback_data=StartCallback(menu="help").pack(), 33 | ) 34 | ) 35 | 36 | text = _("You are in the character section, use the buttons below to do what you want.") 37 | await (message.edit_text if is_callback else message.reply)( 38 | text, 39 | reply_markup=keyboard.as_markup(), 40 | ) 41 | -------------------------------------------------------------------------------- /gojira/handlers/character/view.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import Router 5 | from aiogram.enums import ChatType, ParseMode 6 | from aiogram.filters import Command, CommandObject 7 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 8 | from aiogram.utils.i18n import gettext as _ 9 | from aiogram.utils.keyboard import InlineKeyboardBuilder 10 | 11 | from gojira import AniList 12 | from gojira.handlers.character.start import character_start 13 | from gojira.utils.callback_data import CharacterCallback 14 | 15 | router = Router(name="character_view") 16 | 17 | 18 | @router.message(Command("character")) 19 | @router.callback_query(CharacterCallback.filter()) 20 | async def character_view( 21 | union: Message | CallbackQuery, 22 | command: CommandObject | None = None, 23 | callback_data: CharacterCallback | None = None, 24 | character_id: int | None = None, 25 | ): 26 | is_callback = isinstance(union, CallbackQuery) 27 | message = union.message if is_callback else union 28 | user = union.from_user 29 | if not message or not user: 30 | return 31 | 32 | if isinstance(message, InaccessibleMessage): 33 | return 34 | 35 | is_private: bool = message.chat.type == ChatType.PRIVATE 36 | 37 | if command and not command.args: 38 | if is_private: 39 | await character_start(message) 40 | return 41 | await message.reply( 42 | _( 43 | "You need to specify an character. Use /character name or \ 44 | id" 45 | ) 46 | ) 47 | return 48 | 49 | query = str( 50 | callback_data.query 51 | if is_callback and callback_data is not None 52 | else command.args 53 | if command and command.args 54 | else character_id 55 | ) 56 | 57 | if is_callback and callback_data is not None: 58 | user_id = callback_data.user_id 59 | if user_id is not None: 60 | user_id = int(user_id) 61 | 62 | if user_id != user.id: 63 | await union.answer( 64 | _("This button is not for you."), 65 | show_alert=True, 66 | cache_time=60, 67 | ) 68 | return 69 | 70 | is_search = callback_data.is_search 71 | if bool(is_search) and not is_private: 72 | await message.delete() 73 | 74 | if not bool(query): 75 | return 76 | 77 | keyboard = InlineKeyboardBuilder() 78 | if not query.isdecimal(): 79 | _status, data = await AniList.search("character", query) 80 | if not data: 81 | await message.reply(_("No results found.")) 82 | return 83 | 84 | results = data["data"]["Page"]["characters"] 85 | if results is None or len(results) == 0: 86 | await message.reply(_("No results found.")) 87 | return 88 | 89 | if len(results) == 1: 90 | character_id = int(results[0]["id"]) 91 | else: 92 | for result in results: 93 | keyboard.row( 94 | InlineKeyboardButton( 95 | text=result["name"]["full"], 96 | callback_data=CharacterCallback( 97 | query=result["id"], 98 | user_id=user.id, 99 | is_search=True, 100 | ).pack(), 101 | ) 102 | ) 103 | await message.reply( 104 | _("Search results for: {query}").format(query=query), 105 | reply_markup=keyboard.as_markup(), 106 | ) 107 | return 108 | else: 109 | character_id = int(query) 110 | 111 | _status, data = await AniList.get("character", character_id) 112 | if not data: 113 | await message.reply(_("No results found.")) 114 | return 115 | 116 | if not data["data"]["Page"]["characters"]: 117 | await message.reply(_("No results found.")) 118 | return 119 | 120 | character = data["data"]["Page"]["characters"][0] 121 | 122 | if not character: 123 | await union.answer( 124 | _("No results found."), 125 | show_alert=True, 126 | cache_time=60, 127 | ) 128 | return 129 | 130 | text = f"*{character["name"]["full"]}*" 131 | text += f"\n*ID*: `{character["id"]}`" 132 | if character["favourites"]: 133 | text += _("\n*Favourites*: `{favourites}`").format(favourites=character["favourites"]) 134 | if character["description"]: 135 | text += f"\n\n{character["description"]}" 136 | 137 | photo: str = "" 138 | if image := character["image"]: 139 | if large_image := image["large"]: 140 | photo = large_image 141 | elif medium_image := image["medium"]: 142 | photo = medium_image 143 | 144 | keyboard.button(text=_("🐢 AniList"), url=character["siteUrl"]) 145 | 146 | keyboard.adjust(2) 147 | 148 | if len(text) > 1024: 149 | text = text[:1021] + "..." 150 | 151 | # Markdown 152 | text = text.replace("__", "*") 153 | text = text.replace("~", "||") 154 | 155 | if len(photo) > 0: 156 | await message.answer_photo( 157 | photo=photo, 158 | caption=text, 159 | parse_mode=ParseMode.MARKDOWN, 160 | reply_markup=keyboard.as_markup(), 161 | ) 162 | else: 163 | await message.answer( 164 | text=text, 165 | parse_mode=ParseMode.MARKDOWN, 166 | reply_markup=keyboard.as_markup(), 167 | ) 168 | -------------------------------------------------------------------------------- /gojira/handlers/doas.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import datetime 5 | import html 6 | import io 7 | import os 8 | import shutil 9 | import sys 10 | import traceback 11 | from pathlib import Path 12 | from signal import SIGINT 13 | 14 | import humanize 15 | import orjson 16 | from aiofile import async_open 17 | from aiogram import F, Router 18 | from aiogram.filters import Command, CommandObject 19 | from aiogram.types import BufferedInputFile, CallbackQuery, InaccessibleMessage, Message 20 | from aiogram.utils.keyboard import InlineKeyboardBuilder 21 | from meval import meval 22 | 23 | from gojira import cache, i18n 24 | from gojira.database import DB_PATH, Chats, Users 25 | from gojira.filters.users import IsSudo 26 | from gojira.utils.callback_data import StartCallback 27 | from gojira.utils.systools import ShellExceptionError, parse_commits, shell_run 28 | 29 | router = Router(name="doas") 30 | 31 | # Only sudo users can use these commands 32 | router.message.filter(IsSudo()) 33 | router.callback_query.filter(IsSudo()) 34 | 35 | 36 | @router.message(Command("errtest")) 37 | async def error_test(message: Message): 38 | await message.reply("Testing error handler...") 39 | test = 2 / 0 40 | print(test) 41 | 42 | 43 | @router.message(Command("purgecache")) 44 | async def purge_cache(message: Message): 45 | start = datetime.datetime.now(tz=datetime.UTC) 46 | await cache.clear() 47 | delta = (datetime.datetime.now(tz=datetime.UTC) - start).total_seconds() * 1000 48 | await message.reply(f"Cache purged in {delta:.2f}ms.") 49 | 50 | 51 | @router.message(Command("event")) 52 | async def json_dump(message: Message): 53 | event = str(orjson.dumps(str(message)).decode()) 54 | await message.reply(event) 55 | 56 | 57 | @router.message(Command(commands=["doc", "upload"])) 58 | async def upload_document(message: Message, command: CommandObject): 59 | path = Path(str(command.args)) 60 | if not Path.exists(path): 61 | await message.reply("File not found.") 62 | return 63 | 64 | await message.reply("Processing...") 65 | 66 | caption = f"File: {path.name}" 67 | async with async_open(path, "rb") as f: 68 | file_data = await f.read() 69 | file_obj = BufferedInputFile(file_data, filename=path.name) 70 | await message.reply_document(file_obj, caption=caption) 71 | 72 | 73 | @router.message(Command(commands=["reboot", "restart"])) 74 | async def reboot(message: Message): 75 | await message.reply("Rebooting...") 76 | os.execv(sys.executable, [sys.executable, "-m", "gojira"]) 77 | 78 | 79 | @router.message(Command("shutdown")) 80 | async def shutdown_message(message: Message): 81 | await message.reply("Turning off...") 82 | os.kill(os.getpid(), SIGINT) 83 | 84 | 85 | @router.message(Command(commands=["update", "upgrade"])) 86 | async def bot_update(message: Message): 87 | sent = await message.reply("Checking for updates...") 88 | 89 | try: 90 | await shell_run("git fetch origin") 91 | stdout = await shell_run("git log HEAD..origin/main") 92 | if not stdout: 93 | await sent.edit_text("There is nothing to update.") 94 | return 95 | except ShellExceptionError as error: 96 | await sent.edit_text(f"{html.escape(str(error))}") 97 | return 98 | 99 | commits = parse_commits(stdout) 100 | changelog = "Changelog:\n" 101 | for c_hash, commit in commits.items(): 102 | changelog += f" - [{c_hash[:7]}] {html.escape(commit["title"])}\n" 103 | changelog += f"\nNew commits count: {len(commits)}." 104 | 105 | keyboard = InlineKeyboardBuilder() 106 | keyboard.button(text="🆕 Update", callback_data=StartCallback(menu="update")) 107 | await sent.edit_text(changelog, reply_markup=keyboard.as_markup()) 108 | 109 | 110 | @router.callback_query(StartCallback.filter(F.menu == "update")) 111 | async def upgrade_callback(callback: CallbackQuery): 112 | message = callback.message 113 | if not message: 114 | return 115 | 116 | if isinstance(message, InaccessibleMessage): 117 | return 118 | 119 | await message.edit_reply_markup() 120 | sent = await message.reply("Upgrading...") 121 | 122 | commands = [ 123 | "git reset --hard origin/main", 124 | "pybabel compile -d locales -D bot", 125 | "rye sync --update-all --all-features", 126 | ] 127 | 128 | stdout = "" 129 | for command in commands: 130 | try: 131 | stdout += await shell_run(command) 132 | except ShellExceptionError as error: 133 | await sent.edit_text(f"{html.escape(str(error))}") 134 | return 135 | 136 | await sent.reply("Uploading logs...") 137 | document = io.BytesIO(stdout.encode()) 138 | document.name = "update_log.txt" 139 | document = BufferedInputFile(document.getvalue(), filename=document.name) 140 | await sent.reply_document(document=document) 141 | 142 | await sent.reply("Restarting...") 143 | os.execv(sys.executable, [sys.executable, "-m", "gojira"]) 144 | 145 | 146 | @router.message(Command(commands=["shell", "sh"])) 147 | async def bot_shell(message: Message, command: CommandObject): 148 | code = str(command.args) 149 | sent = await message.reply("Running...") 150 | 151 | try: 152 | stdout = await shell_run(command=code) 153 | except ShellExceptionError as error: 154 | await sent.edit_text(f"{html.escape(str(error))}") 155 | return 156 | 157 | output = f"Input\n> {code}\n\n" 158 | if stdout: 159 | if len(stdout) > (4096 - len(output)): 160 | document = io.BytesIO(stdout.encode()) 161 | document.name = "output.txt" 162 | document = BufferedInputFile(document.getvalue(), filename=document.name) 163 | await message.reply_document(document=document) 164 | else: 165 | output += f"Output\n> {html.escape(stdout)}" 166 | 167 | await sent.edit_text(output) 168 | 169 | 170 | @router.message(Command(commands=["eval", "ev"])) 171 | async def evaluate(message: Message, command: CommandObject): 172 | query = command.args 173 | sent = await message.reply("Evaluating...") 174 | 175 | try: 176 | stdout = await meval(query, globals(), **locals()) 177 | except BaseException: 178 | exc = sys.exc_info() 179 | exc = "".join( 180 | traceback.format_exception(exc[0], exc[1], exc[2].tb_next.tb_next.tb_next) # type: ignore[misc] 181 | ) 182 | error_txt = "Failed to execute the expression:\n> {eval}" 183 | error_txt += f"\n\nError:\n> {exc}" 184 | await sent.edit_text( 185 | error_txt.format(eval=query, exc=html.escape(exc)), 186 | disable_web_page_preview=True, 187 | ) 188 | return 189 | 190 | output_message = f"Expression:\n> {html.escape(str(query))}" 191 | 192 | if stdout: 193 | lines = str(stdout).splitlines() 194 | output = "".join(f"{html.escape(line)}\n" for line in lines) 195 | 196 | if len(output) > 0: 197 | if len(output) > (4096 - len(output_message)): 198 | document = io.BytesIO( 199 | (output.replace("", "").replace("", "")).encode() 200 | ) 201 | document.name = "output.txt" 202 | document = BufferedInputFile(document.getvalue(), filename=document.name) 203 | await message.reply_document(document=document) 204 | else: 205 | output_message += f"\n\nResult:\n> {output}" 206 | 207 | await sent.edit_text(output_message) 208 | 209 | 210 | @router.message(Command("ping")) 211 | async def ping(message: Message): 212 | start = datetime.datetime.now(tz=datetime.UTC) 213 | sent = await message.reply("Pong!") 214 | delta = (datetime.datetime.now(tz=datetime.UTC) - start).total_seconds() * 1000 215 | await sent.edit_text(f"Pong! {delta:.2f}ms") 216 | 217 | 218 | @router.message(Command("stats")) 219 | async def bot_stats(message: Message): 220 | db_size = humanize.naturalsize(Path.stat(DB_PATH).st_size, binary=True) 221 | text = f"\nDatabase Size: {db_size}" 222 | disk = shutil.disk_usage("/") 223 | text += f"\nFree Storage: {humanize.naturalsize(disk[2], binary=True)}" 224 | 225 | text += f"\n\nTotal Users: {await Users.get_users_count()}" 226 | for language in (*i18n.available_locales, i18n.default_locale): 227 | users = await Users.get_users_count(language_code=language) 228 | text += f"\n{language}: {users}" 229 | 230 | text += f"\n\nTotal Groups: {await Chats.get_chats_count()}" 231 | for language in (*i18n.available_locales, i18n.default_locale): 232 | groups = await Chats.get_chats_count(language_code=language) 233 | text += f"\n{language}: {groups}" 234 | 235 | await message.reply(text) 236 | -------------------------------------------------------------------------------- /gojira/handlers/error.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import html 5 | 6 | from aiogram import Router 7 | from aiogram.types import CallbackQuery, ErrorEvent 8 | 9 | from gojira import bot, cache 10 | from gojira.utils.logging import log 11 | 12 | router = Router(name="error") 13 | 14 | 15 | @router.errors() 16 | async def errors_handler(error: ErrorEvent): 17 | update = error.update 18 | message = ( 19 | getattr(update, "message", None) 20 | or getattr(update, "callback_query", None) 21 | or getattr(update, "edited_message", None) 22 | ) 23 | is_callback = isinstance(message, CallbackQuery) 24 | message = message.message if is_callback else message 25 | if not message: 26 | return 27 | 28 | exception = error.exception 29 | 30 | chat_id = message.chat.id 31 | err_tlt = type(exception).__name__ 32 | err_msg = str(exception) 33 | 34 | cached_error = await cache.get(f"error:{chat_id}") 35 | if cached_error == err_msg: 36 | return 37 | 38 | conn_errors = ( 39 | "TelegramNetworkError", 40 | "TelegramAPIError", 41 | "RestartingTelegram", 42 | ) 43 | if err_tlt in conn_errors: 44 | log.error("Connection/API error detected!", error=err_msg, exc_info=error.exception) 45 | return 46 | 47 | log.error("Error detected!", update=message, error=err_msg, exc_info=error.exception) 48 | 49 | text = "Sorry, I encountered a error!\n" 50 | text += ( 51 | f"{html.escape(err_tlt, quote=False)}: {html.escape(err_msg, quote=False)}" 52 | ) 53 | if not cached_error: 54 | await cache.set(f"error:{chat_id}", err_msg, "1h") 55 | await bot.send_message(chat_id, text) 56 | -------------------------------------------------------------------------------- /gojira/handlers/inline.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import random 5 | from contextlib import suppress 6 | 7 | from aiogram import Router 8 | from aiogram.enums import InlineQueryResultType, ParseMode 9 | from aiogram.exceptions import TelegramBadRequest 10 | from aiogram.types import ( 11 | InlineKeyboardButton, 12 | InlineKeyboardMarkup, 13 | InlineQuery, 14 | InlineQueryResultArticle, 15 | InputTextMessageContent, 16 | ) 17 | 18 | router = Router(name="inline") 19 | 20 | 21 | @router.inline_query() 22 | async def inline_help(inline: InlineQuery): 23 | results = [ 24 | InlineQueryResultArticle( 25 | type=InlineQueryResultType.ARTICLE, 26 | id=str(random.getrandbits(64)), 27 | title="!a ", 28 | input_message_content=InputTextMessageContent( 29 | message_text="*!a * is used to search for anime in inline, it can also be \ 30 | used in PM to get complete anime information just like `/anime`. command", 31 | parse_mode=ParseMode.MARKDOWN, 32 | ), 33 | reply_markup=InlineKeyboardMarkup( 34 | inline_keyboard=[ 35 | [ 36 | InlineKeyboardButton( 37 | text="🔨 Run 'anime'", 38 | switch_inline_query_current_chat="!a ", 39 | ) 40 | ] 41 | ] 42 | ), 43 | description="Search for anime.", 44 | ), 45 | InlineQueryResultArticle( 46 | type=InlineQueryResultType.ARTICLE, 47 | id=str(random.getrandbits(64)), 48 | title="!m ", 49 | input_message_content=InputTextMessageContent( 50 | message_text="*!m * is used to search for manga in inline, it can also be \ 51 | used in PM to get complete manga information just like `/manga` command.", 52 | parse_mode=ParseMode.MARKDOWN, 53 | ), 54 | reply_markup=InlineKeyboardMarkup( 55 | inline_keyboard=[ 56 | [ 57 | InlineKeyboardButton( 58 | text="🔨 Run 'manga'", 59 | switch_inline_query_current_chat="!m ", 60 | ) 61 | ] 62 | ] 63 | ), 64 | description="Search for manga.", 65 | ), 66 | InlineQueryResultArticle( 67 | type=InlineQueryResultType.ARTICLE, 68 | id=str(random.getrandbits(64)), 69 | title="!c ", 70 | input_message_content=InputTextMessageContent( 71 | message_text="*!c * is used to search for character in inline, it can \ 72 | also be used in PM to get complete character information just like `/character` command.", 73 | parse_mode=ParseMode.MARKDOWN, 74 | ), 75 | reply_markup=InlineKeyboardMarkup( 76 | inline_keyboard=[ 77 | [ 78 | InlineKeyboardButton( 79 | text="🔨 Run 'character'", 80 | switch_inline_query_current_chat="!c ", 81 | ) 82 | ] 83 | ] 84 | ), 85 | description="Search for character.", 86 | ), 87 | InlineQueryResultArticle( 88 | type=InlineQueryResultType.ARTICLE, 89 | id=str(random.getrandbits(64)), 90 | title="!s ", 91 | input_message_content=InputTextMessageContent( 92 | message_text="*!s * is used to search for staff in inline, it can also be \ 93 | used in PM to get complete staff information just like `/staff` command.", 94 | parse_mode=ParseMode.MARKDOWN, 95 | ), 96 | reply_markup=InlineKeyboardMarkup( 97 | inline_keyboard=[ 98 | [ 99 | InlineKeyboardButton( 100 | text="🔨 Run 'staff'", 101 | switch_inline_query_current_chat="!s ", 102 | ) 103 | ] 104 | ] 105 | ), 106 | description="Search for staff.", 107 | ), 108 | ] 109 | 110 | with suppress(TelegramBadRequest): 111 | await inline.answer(results=results, cache_time=60) # type: ignore 112 | -------------------------------------------------------------------------------- /gojira/handlers/language.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import F, Router 5 | from aiogram.enums import ChatType 6 | from aiogram.filters import Command 7 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 8 | from aiogram.utils.i18n import gettext as _ 9 | from aiogram.utils.keyboard import InlineKeyboardBuilder 10 | from babel import Locale 11 | 12 | from gojira import i18n 13 | from gojira.database import Chats, Users 14 | from gojira.filters.users import IsAdmin 15 | from gojira.utils.callback_data import LanguageCallback, StartCallback 16 | from gojira.utils.language import get_chat_language 17 | 18 | router = Router(name="language") 19 | 20 | 21 | @router.message(Command("language"), IsAdmin()) 22 | @router.callback_query(StartCallback.filter(F.menu == "language")) 23 | async def select_language(union: Message | CallbackQuery): 24 | is_callback = isinstance(union, CallbackQuery) 25 | message = union.message if is_callback else union 26 | if not message or not union.from_user: 27 | return 28 | 29 | if isinstance(message, InaccessibleMessage): 30 | return 31 | 32 | chat_type, lang_code = await get_chat_language(union) 33 | lang_display_name = str(Locale.parse(str(lang_code)).display_name).capitalize() 34 | 35 | text = _( 36 | "You can select your preferred language for the bot in this chat by clicking one of the \ 37 | buttons below.\n\nThese are the languages currently supported by the bot. If your language is \ 38 | not available, you can contribute by contacting @Hitalo to add it for translation.\n" 39 | ) 40 | 41 | if message.chat.type == ChatType.PRIVATE: 42 | text += _("\nYour current language: {lang}").format(lang=lang_display_name) 43 | else: 44 | text += _("\nGroup current language: {lang}").format(lang=lang_display_name) 45 | 46 | available_locales = (*i18n.available_locales, i18n.default_locale) 47 | keyboard = InlineKeyboardBuilder() 48 | for lang in available_locales: 49 | lang_display_name = str(Locale.parse(lang).display_name).capitalize() 50 | if lang == lang_code: 51 | lang_display_name = f"✅ {lang_display_name}" 52 | keyboard.button( 53 | text=lang_display_name, 54 | callback_data=LanguageCallback(lang=lang, chat=str(chat_type)), 55 | ) 56 | 57 | keyboard.adjust(2) 58 | keyboard.row( 59 | InlineKeyboardButton( 60 | text=_("🌎 Contribute in translations!"), 61 | url="https://crowdin.com/project/gojira/", 62 | ) 63 | ) 64 | 65 | if message.chat.type == ChatType.PRIVATE: 66 | keyboard.row( 67 | InlineKeyboardButton( 68 | text=_("🔙 Back"), callback_data=StartCallback(menu="start").pack() 69 | ) 70 | ) 71 | 72 | await (message.edit_text if is_callback else message.reply)( 73 | text, 74 | reply_markup=keyboard.as_markup(), 75 | ) 76 | 77 | 78 | @router.callback_query(LanguageCallback.filter(), IsAdmin()) 79 | async def language_callback(callback: CallbackQuery, callback_data: LanguageCallback): 80 | if not callback.message or not callback.from_user: 81 | return 82 | 83 | if isinstance(callback.message, InaccessibleMessage): 84 | return 85 | 86 | if callback_data.chat == ChatType.PRIVATE: 87 | await Users.set_language(user=callback.from_user, language_code=callback_data.lang) 88 | elif callback_data.chat in {ChatType.GROUP, ChatType.SUPERGROUP}: 89 | await Chats.set_language(chat=callback.message.chat, language_code=callback_data.lang) 90 | 91 | lang = Locale.parse(callback_data.lang) 92 | await callback.message.edit_text( 93 | _("Changed language to {lang}").format(lang=lang.display_name) 94 | ) 95 | -------------------------------------------------------------------------------- /gojira/handlers/manga/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | -------------------------------------------------------------------------------- /gojira/handlers/manga/categories.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Router 7 | from aiogram.exceptions import TelegramAPIError 8 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton 9 | from aiogram.utils.i18n import gettext as _ 10 | 11 | from gojira import AniList 12 | from gojira.utils.callback_data import ( 13 | MangaCallback, 14 | MangaCategCallback, 15 | MangaGCategCallback, 16 | StartCallback, 17 | ) 18 | from gojira.utils.keyboard import Pagination 19 | 20 | router = Router(name="manga_categories") 21 | 22 | 23 | @router.callback_query(MangaCategCallback.filter()) 24 | async def manga_categories(callback: CallbackQuery, callback_data: MangaCategCallback): 25 | message = callback.message 26 | if not message: 27 | return 28 | 29 | if isinstance(message, InaccessibleMessage): 30 | return 31 | 32 | page = callback_data.page 33 | 34 | categories: dict = { 35 | "Action": _("Action"), 36 | "Adventure": _("Adventure"), 37 | "Comedy": _("Comedy"), 38 | "Drama": _("Drama"), 39 | "Ecchi": _("Ecchi"), 40 | "Fantasy": _("Fantasy"), 41 | "Horror": _("Horror"), 42 | "Mahou Shoujo": _("Mahou Shoujo"), 43 | "Mecha": _("Mecha"), 44 | "Music": _("Music"), 45 | "Mystery": _("Mystery"), 46 | "Psychological": _("Psychological"), 47 | "Romance": _("Romance"), 48 | "Sci-Fi": _("Sci-Fi"), 49 | "Slice of Life": _("Slice of Life"), 50 | "Sports": _("Sports"), 51 | "Supernatural": _("Supernatural"), 52 | "Thriller": _("Thriller"), 53 | } 54 | categories_list = sorted(categories.keys()) 55 | 56 | layout = Pagination( 57 | categories_list, 58 | item_data=lambda i, pg: MangaGCategCallback(page=pg, categorie=i).pack(), 59 | item_title=lambda i, _: categories[i], 60 | page_data=lambda pg: MangaCategCallback(page=pg).pack(), 61 | ) 62 | 63 | keyboard = layout.create(page, lines=5, columns=2) 64 | 65 | keyboard.inline_keyboard.append([ 66 | InlineKeyboardButton( 67 | text=_("🔙 Back"), 68 | callback_data=StartCallback(menu="manga").pack(), 69 | ) 70 | ]) 71 | 72 | with suppress(TelegramAPIError): 73 | await message.edit_text( 74 | _("Below are the categories of manga, choose one to see the results:"), 75 | reply_markup=keyboard, 76 | ) 77 | 78 | 79 | @router.callback_query(MangaGCategCallback.filter()) 80 | async def manga_categorie(callback: CallbackQuery, callback_data: MangaGCategCallback): 81 | message = callback.message 82 | if not message: 83 | return 84 | 85 | if isinstance(message, InaccessibleMessage): 86 | return 87 | 88 | categorie = callback_data.categorie 89 | page = callback_data.page 90 | 91 | _status, data = await AniList.categories("manga", page, categorie) 92 | if data["data"]: 93 | items = data["data"]["Page"]["media"] 94 | results = [item.copy() for item in items] 95 | 96 | layout = Pagination( 97 | results, 98 | item_data=lambda i, _: MangaCallback(query=i["id"]).pack(), 99 | item_title=lambda i, _: i["title"]["romaji"], 100 | page_data=lambda pg: MangaGCategCallback(page=pg, categorie=categorie).pack(), 101 | ) 102 | 103 | keyboard = layout.create(page, lines=8) 104 | 105 | keyboard.inline_keyboard.append([ 106 | InlineKeyboardButton( 107 | text=_("🔙 Back"), 108 | callback_data=MangaCategCallback(page=1).pack(), 109 | ) 110 | ]) 111 | 112 | text = _("Below are up to 50 results from the {genre} category.").format( 113 | genre=categorie 114 | ) 115 | with suppress(TelegramAPIError): 116 | await message.edit_text(text, reply_markup=keyboard) 117 | -------------------------------------------------------------------------------- /gojira/handlers/manga/inline.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import random 5 | import re 6 | from contextlib import suppress 7 | 8 | from aiogram import F, Router 9 | from aiogram.enums import InlineQueryResultType 10 | from aiogram.exceptions import TelegramBadRequest 11 | from aiogram.types import ( 12 | InlineQuery, 13 | InlineQueryResultArticle, 14 | InputTextMessageContent, 15 | ) 16 | from aiogram.utils.i18n import gettext as _ 17 | from aiogram.utils.keyboard import InlineKeyboardBuilder 18 | from aiogram.utils.markdown import hide_link 19 | 20 | from gojira import AniList, bot 21 | from gojira.utils.language import ( 22 | i18n_anilist_format, 23 | i18n_anilist_source, 24 | i18n_anilist_status, 25 | ) 26 | 27 | router = Router(name="manga_inline") 28 | 29 | 30 | @router.inline_query(F.query.regexp(r"^!m (?P.+)").as_("match")) 31 | async def manga_inline(inline: InlineQuery, match: re.Match[str]): 32 | query = match.group("query") 33 | 34 | results = [] 35 | 36 | search_results = [] 37 | _status, data = await AniList.search("manga", query) 38 | if not data: 39 | return 40 | 41 | search_results = data["data"]["Page"]["media"] 42 | 43 | if not search_results: 44 | return 45 | 46 | for result in search_results: 47 | _status, data = await AniList.get("manga", result["id"]) 48 | if not data: 49 | return 50 | 51 | if not data["data"]["Page"]["media"]: 52 | continue 53 | 54 | manga = data["data"]["Page"]["media"][0] 55 | 56 | photo: str = "" 57 | if banner := manga["bannerImage"]: 58 | photo = banner 59 | elif cover := manga["coverImage"]: 60 | if xl_image := cover["extraLarge"]: 61 | photo = xl_image 62 | elif large_image := cover["large"]: 63 | photo = large_image 64 | elif medium_image := cover["medium"]: 65 | photo = medium_image 66 | 67 | description: str = "" 68 | if manga["description"]: 69 | description = manga["description"] 70 | description = re.sub(re.compile(r"<.*?>"), "", description) 71 | description = description[0:260] + "..." 72 | 73 | end_date_components = [ 74 | component 75 | for component in ( 76 | manga["endDate"].get("day"), 77 | manga["endDate"].get("month"), 78 | manga["endDate"].get("year"), 79 | ) 80 | if component is not None 81 | ] 82 | 83 | start_date_components = [ 84 | component 85 | for component in ( 86 | manga["startDate"].get("day"), 87 | manga["startDate"].get("month"), 88 | manga["startDate"].get("year"), 89 | ) 90 | if component is not None 91 | ] 92 | 93 | end_date = "/".join(str(component) for component in end_date_components) 94 | start_date = "/".join(str(component) for component in start_date_components) 95 | 96 | text = f"{manga["title"]["romaji"]}" 97 | if manga["title"]["native"]: 98 | text += f" ({manga["title"]["native"]})" 99 | text += _("\n\nID: {id}").format(id=manga["id"]) + " (MANGA)" 100 | if i18n_anilist_format(manga["format"]): 101 | text += _("\nFormat: {format}").format( 102 | format=i18n_anilist_format(manga["format"]) 103 | ) 104 | if manga["volumes"]: 105 | text += _("\nVolumes: {volumes}").format(volumes=manga["volumes"]) 106 | if manga["chapters"]: 107 | text += _("\nChapters: {chapters}").format( 108 | chapters=manga["chapters"] 109 | ) 110 | if manga["status"]: 111 | text += _("\nStatus: {status}").format( 112 | status=i18n_anilist_status(manga["status"]) 113 | ) 114 | if manga["status"] != "NOT_YET_RELEASED": 115 | text += _("\nStart Date: {date}").format(date=start_date) 116 | if manga["status"] not in {"NOT_YET_RELEASED", "RELEASING"}: 117 | text += _("\nEnd Date: {date}").format(date=end_date) 118 | if manga["averageScore"]: 119 | text += _("\nAverage Score: {score}").format( 120 | score=manga["averageScore"] 121 | ) 122 | if manga["source"]: 123 | text += _("\nSource: {source}").format( 124 | source=i18n_anilist_source(manga["source"]) 125 | ) 126 | if manga["genres"]: 127 | text += _("\nGenres: {genres}").format( 128 | genres=", ".join(manga["genres"]) 129 | ) 130 | 131 | text += _("\n\nShort Description: {description}").format( 132 | description=description 133 | ) 134 | 135 | text += f"\n{hide_link(photo)}" 136 | 137 | keyboard = InlineKeyboardBuilder() 138 | 139 | me = await bot.get_me() 140 | bot_username = me.username 141 | keyboard.button( 142 | text=_("👓 View More"), 143 | url=f"https://t.me/{bot_username}/?start=manga_{manga["id"]}", 144 | ) 145 | 146 | results.append( 147 | InlineQueryResultArticle( 148 | type=InlineQueryResultType.ARTICLE, 149 | id=str(random.getrandbits(64)), 150 | title=manga["title"]["romaji"], 151 | input_message_content=InputTextMessageContent(message_text=text), 152 | reply_markup=keyboard.as_markup(), 153 | description=description, 154 | thumbnail_url=photo, 155 | ) 156 | ) 157 | 158 | with suppress(TelegramBadRequest): 159 | if len(results) > 0: 160 | await inline.answer(results=results, is_personal=True) 161 | -------------------------------------------------------------------------------- /gojira/handlers/manga/popular.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Router 7 | from aiogram.exceptions import TelegramAPIError 8 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton 9 | from aiogram.utils.i18n import gettext as _ 10 | 11 | from gojira import AniList 12 | from gojira.utils.callback_data import MangaCallback, MangaPopuCallback, StartCallback 13 | from gojira.utils.keyboard import Pagination 14 | 15 | router = Router(name="manga_popular") 16 | 17 | 18 | @router.callback_query(MangaPopuCallback.filter()) 19 | async def manga_popular(callback: CallbackQuery, callback_data: MangaPopuCallback): 20 | message = callback.message 21 | if not message: 22 | return 23 | 24 | if isinstance(message, InaccessibleMessage): 25 | return 26 | 27 | page = callback_data.page 28 | 29 | _status, data = await AniList.popular("manga") 30 | if data["data"]: 31 | items = data["data"]["Page"]["media"] 32 | results = [item.copy() for item in items] 33 | 34 | layout = Pagination( 35 | results, 36 | item_data=lambda i, _: MangaCallback(query=i["id"]).pack(), 37 | item_title=lambda i, _: i["title"]["romaji"], 38 | page_data=lambda pg: MangaPopuCallback(page=pg).pack(), 39 | ) 40 | 41 | keyboard = layout.create(page, lines=8) 42 | 43 | keyboard.inline_keyboard.append([ 44 | InlineKeyboardButton( 45 | text=_("🔙 Back"), 46 | callback_data=StartCallback(menu="manga").pack(), 47 | ) 48 | ]) 49 | 50 | text = _("Below are the 50 most popular mangas in descending order.") 51 | with suppress(TelegramAPIError): 52 | await message.edit_text( 53 | text=text, 54 | reply_markup=keyboard, 55 | ) 56 | -------------------------------------------------------------------------------- /gojira/handlers/manga/start.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | 5 | from aiogram import F, Router 6 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 7 | from aiogram.utils.i18n import gettext as _ 8 | from aiogram.utils.keyboard import InlineKeyboardBuilder 9 | 10 | from gojira.utils.callback_data import ( 11 | MangaCategCallback, 12 | MangaPopuCallback, 13 | MangaUpcomingCallback, 14 | StartCallback, 15 | ) 16 | 17 | router = Router(name="manga_start") 18 | 19 | 20 | @router.callback_query(StartCallback.filter(F.menu == "manga")) 21 | async def manga_start(union: Message | CallbackQuery): 22 | is_callback = isinstance(union, CallbackQuery) 23 | message = union.message if is_callback else union 24 | user = union.from_user 25 | if not message or not user: 26 | return 27 | 28 | if isinstance(message, InaccessibleMessage): 29 | return 30 | 31 | keyboard = InlineKeyboardBuilder() 32 | keyboard.button(text=_("📈 Popular"), callback_data=MangaPopuCallback(page=1)) 33 | keyboard.button(text=_("🗂️ Categories"), callback_data=MangaCategCallback(page=1)) 34 | keyboard.button( 35 | text=_("🆕 Upcoming"), 36 | callback_data=MangaUpcomingCallback(page=1, user_id=user.id), 37 | ) 38 | keyboard.button(text=_("🔍 Search"), switch_inline_query_current_chat="!m ") 39 | keyboard.adjust(2) 40 | 41 | keyboard.row( 42 | InlineKeyboardButton( 43 | text=_("🔙 Back"), 44 | callback_data=StartCallback(menu="help").pack(), 45 | ) 46 | ) 47 | 48 | text = _("You are in the manga section, use the buttons below to do what you want.") 49 | await (message.edit_text if is_callback else message.reply)( 50 | text, 51 | reply_markup=keyboard.as_markup(), 52 | ) 53 | -------------------------------------------------------------------------------- /gojira/handlers/manga/upcoming.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Router 7 | from aiogram.enums import ChatType 8 | from aiogram.exceptions import TelegramAPIError 9 | from aiogram.types import ( 10 | CallbackQuery, 11 | InaccessibleMessage, 12 | InlineKeyboardButton, 13 | ) 14 | from aiogram.utils.i18n import gettext as _ 15 | 16 | from gojira import AniList 17 | from gojira.utils.callback_data import ( 18 | MangaCallback, 19 | MangaUpcomingCallback, 20 | StartCallback, 21 | UpcomingCallback, 22 | ) 23 | from gojira.utils.keyboard import Pagination 24 | 25 | router = Router(name="manga_upcoming") 26 | 27 | 28 | @router.callback_query(MangaUpcomingCallback.filter()) 29 | async def manga_upcoming(callback: CallbackQuery, callback_data: MangaUpcomingCallback): 30 | message = callback.message 31 | 32 | if (not message or isinstance(message, InaccessibleMessage)) or not message.from_user: 33 | return 34 | 35 | page = callback_data.page 36 | user_id = callback_data.user_id 37 | 38 | if user_id != callback.from_user.id: 39 | await callback.answer( 40 | _("This button is not for you."), 41 | show_alert=True, 42 | cache_time=60, 43 | ) 44 | return 45 | 46 | is_private = message.chat.type == ChatType.PRIVATE 47 | 48 | _status, data = await AniList.upcoming("manga") 49 | if data["data"]: 50 | items = data["data"]["Page"]["media"] 51 | suggestions = [item.copy() for item in items] 52 | 53 | layout = Pagination( 54 | suggestions, 55 | item_data=lambda i, _: MangaCallback( 56 | query=int(i["id"]), user_id=user_id, is_search=(not is_private) 57 | ).pack(), 58 | item_title=lambda i, _: i["title"]["romaji"], 59 | page_data=lambda pg: MangaUpcomingCallback(page=pg, user_id=user_id).pack(), 60 | ) 61 | 62 | keyboard = layout.create(page, lines=8) 63 | 64 | if is_private: 65 | keyboard.inline_keyboard.append([ 66 | InlineKeyboardButton( 67 | text=_("🔙 Back"), 68 | callback_data=StartCallback(menu="manga").pack(), 69 | ) 70 | ]) 71 | else: 72 | keyboard.inline_keyboard.append([ 73 | InlineKeyboardButton( 74 | text=_("🔙 Back"), 75 | callback_data=UpcomingCallback(user_id=user_id).pack(), 76 | ) 77 | ]) 78 | 79 | with suppress(TelegramAPIError): 80 | await message.edit_text( 81 | _("Below are the 50 mangas that have not yet been released."), 82 | reply_markup=keyboard, 83 | ) 84 | -------------------------------------------------------------------------------- /gojira/handlers/manga/view.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import math 5 | import operator 6 | 7 | from aiogram import Router 8 | from aiogram.enums import ChatType, InputMediaType 9 | from aiogram.filters import Command, CommandObject 10 | from aiogram.types import ( 11 | CallbackQuery, 12 | InaccessibleMessage, 13 | InlineKeyboardButton, 14 | InputMediaPhoto, 15 | Message, 16 | ) 17 | from aiogram.utils.i18n import gettext as _ 18 | from aiogram.utils.keyboard import InlineKeyboardBuilder 19 | from lxml import html 20 | 21 | from gojira import AniList, bot 22 | from gojira.handlers.manga.start import manga_start 23 | from gojira.utils.callback_data import ( 24 | MangaCallback, 25 | MangaCharCallback, 26 | MangaDescCallback, 27 | MangaMoreCallback, 28 | MangaStaffCallback, 29 | ) 30 | from gojira.utils.language import ( 31 | i18n_anilist_format, 32 | i18n_anilist_source, 33 | i18n_anilist_status, 34 | ) 35 | 36 | router = Router(name="manga_view") 37 | 38 | 39 | @router.message(Command("manga")) 40 | @router.callback_query(MangaCallback.filter()) 41 | async def manga_view( 42 | union: Message | CallbackQuery, 43 | command: CommandObject | None = None, 44 | callback_data: MangaCallback | None = None, 45 | manga_id: int | None = None, 46 | ): 47 | is_callback = isinstance(union, CallbackQuery) 48 | message = union.message if is_callback else union 49 | user = union.from_user 50 | if not message or not user: 51 | return 52 | 53 | if isinstance(message, InaccessibleMessage): 54 | return 55 | 56 | if command and not command.args: 57 | if message.chat.type == ChatType.PRIVATE: 58 | await manga_start(message) 59 | return 60 | await message.reply( 61 | _("You need to specify an manga. Use /manga name or id") 62 | ) 63 | return 64 | 65 | is_private: bool = message.chat.type == ChatType.PRIVATE 66 | 67 | query = str( 68 | callback_data.query 69 | if is_callback and callback_data is not None 70 | else command.args 71 | if command and command.args 72 | else manga_id 73 | ) 74 | 75 | if is_callback and callback_data is not None: 76 | user_id = callback_data.user_id 77 | if user_id is not None: 78 | user_id = int(user_id) 79 | 80 | if user_id != user.id: 81 | await union.answer( 82 | _("This button is not for you."), 83 | show_alert=True, 84 | cache_time=60, 85 | ) 86 | return 87 | 88 | is_search = callback_data.is_search 89 | if is_search and not is_private: 90 | await message.delete() 91 | 92 | if not bool(query): 93 | return 94 | 95 | keyboard = InlineKeyboardBuilder() 96 | if not query.isdecimal(): 97 | _status, data = await AniList.search("manga", query) 98 | if not data: 99 | await message.reply(_("No results found.")) 100 | return 101 | 102 | results = data["data"]["Page"]["media"] 103 | if not results or len(results) == 0: 104 | await message.reply(_("No results found.")) 105 | return 106 | 107 | if len(results) == 1: 108 | manga_id = int(results[0]["id"]) 109 | else: 110 | for result in results: 111 | keyboard.row( 112 | InlineKeyboardButton( 113 | text=result["title"]["romaji"], 114 | callback_data=MangaCallback( 115 | query=result["id"], 116 | user_id=user.id, 117 | is_search=True, 118 | ).pack(), 119 | ) 120 | ) 121 | await message.reply( 122 | _("Search results for: {query}").format(query=query), 123 | reply_markup=keyboard.as_markup(), 124 | ) 125 | return 126 | else: 127 | manga_id = int(query) 128 | 129 | _status, data = await AniList.get("manga", manga_id) 130 | if not data: 131 | await message.reply(_("No results found.")) 132 | return 133 | 134 | if not data["data"]["Page"]["media"]: 135 | await message.reply(_("No results found.")) 136 | return 137 | 138 | manga = data["data"]["Page"]["media"][0] 139 | 140 | if not manga: 141 | await union.answer( 142 | _("No results found."), 143 | show_alert=True, 144 | cache_time=60, 145 | ) 146 | return 147 | 148 | photo = f"https://img.anili.st/media/{manga_id}" 149 | 150 | end_date_components = [ 151 | component 152 | for component in ( 153 | manga["endDate"].get("day"), 154 | manga["endDate"].get("month"), 155 | manga["endDate"].get("year"), 156 | ) 157 | if component is not None 158 | ] 159 | 160 | start_date_components = [ 161 | component 162 | for component in ( 163 | manga["startDate"].get("day"), 164 | manga["startDate"].get("month"), 165 | manga["startDate"].get("year"), 166 | ) 167 | if component is not None 168 | ] 169 | 170 | end_date = "/".join(str(component) for component in end_date_components) 171 | start_date = "/".join(str(component) for component in start_date_components) 172 | 173 | text = f"{manga["title"]["romaji"]}" 174 | if manga["title"]["native"]: 175 | text += f" ({manga["title"]["native"]})" 176 | text += _("\n\nID: {id}").format(id=manga["id"]) 177 | if manga["format"]: 178 | text += _("\nFormat: {format}").format( 179 | format=i18n_anilist_format(manga["format"]) 180 | ) 181 | if manga["volumes"]: 182 | text += _("\nVolumes: {volumes}").format(volumes=manga["volumes"]) 183 | if manga["chapters"]: 184 | text += _("\nChapters: {chapters}").format(chapters=manga["chapters"]) 185 | if manga["status"]: 186 | text += _("\nStatus: {status}").format( 187 | status=i18n_anilist_status(manga["status"]) 188 | ) 189 | if manga["status"] != "NOT_YET_RELEASED": 190 | text += _("\nStart Date: {date}").format(date=start_date) 191 | if manga["status"] not in {"NOT_YET_RELEASED", "RELEASING"}: 192 | text += _("\nEnd Date: {date}").format(date=end_date) 193 | if manga["averageScore"]: 194 | text += _("\nAverage Score: {score}").format( 195 | score=manga["averageScore"] 196 | ) 197 | if manga["source"]: 198 | text += _("\nSource: {source}").format( 199 | source=i18n_anilist_source(manga["source"]) 200 | ) 201 | if manga["genres"]: 202 | text += _("\nGenres: {genres}").format( 203 | genres=", ".join(manga["genres"]) 204 | ) 205 | 206 | keyboard = InlineKeyboardBuilder() 207 | 208 | keyboard.row( 209 | InlineKeyboardButton( 210 | text=_("👓 View More"), 211 | callback_data=MangaMoreCallback( 212 | manga_id=manga_id, 213 | user_id=user.id, 214 | ).pack(), 215 | ) 216 | ) 217 | 218 | if "relations" in manga and len(manga["relations"]["edges"]) > 0: 219 | relations_buttons = [] 220 | for relation in manga["relations"]["edges"]: 221 | if relation["relationType"] in {"PREQUEL", "SEQUEL"}: 222 | button_text = ( 223 | _("➡️ Sequel") if relation["relationType"] == "SEQUEL" else _("⬅️ Prequel") 224 | ) 225 | relations_buttons.append( 226 | InlineKeyboardButton( 227 | text=button_text, 228 | callback_data=MangaCallback( 229 | query=relation["node"]["id"], 230 | user_id=user.id, 231 | ).pack(), 232 | ) 233 | ) 234 | if len(relations_buttons) > 0: 235 | relations_buttons.sort(key=lambda button: button.text, reverse=True) 236 | keyboard.row(*relations_buttons) 237 | 238 | if bool(message.photo) and is_callback: 239 | await message.edit_media( 240 | InputMediaPhoto(type=InputMediaType.PHOTO, media=photo, caption=text), 241 | reply_markup=keyboard.as_markup(), 242 | ) 243 | return 244 | if bool(message.photo) and not bool(message.via_bot): 245 | await message.edit_text( 246 | text, 247 | reply_markup=keyboard.as_markup(), 248 | ) 249 | return 250 | 251 | await message.answer_photo( 252 | photo, 253 | caption=text, 254 | reply_markup=keyboard.as_markup(), 255 | ) 256 | 257 | 258 | @router.callback_query(MangaMoreCallback.filter()) 259 | async def manga_more(callback: CallbackQuery, callback_data: MangaMoreCallback): 260 | message = callback.message 261 | user = callback.from_user 262 | if not message: 263 | return 264 | 265 | if isinstance(message, InaccessibleMessage): 266 | return 267 | 268 | manga_id = callback_data.manga_id 269 | user_id = callback_data.user_id 270 | manga_url = f"https://anilist.co/manga/{manga_id}" 271 | 272 | if user_id != user.id: 273 | await callback.answer( 274 | _("This button is not for you"), 275 | show_alert=True, 276 | cache_time=60, 277 | ) 278 | return 279 | 280 | keyboard = InlineKeyboardBuilder() 281 | 282 | keyboard.button( 283 | text=_("📜 Description"), 284 | callback_data=MangaDescCallback(manga_id=manga_id, user_id=user_id), 285 | ) 286 | keyboard.button( 287 | text=_("👨‍👩‍👧‍👦 Characters"), 288 | callback_data=MangaCharCallback(manga_id=manga_id, user_id=user_id), 289 | ) 290 | keyboard.button( 291 | text=_("👨‍💻 Staff"), 292 | callback_data=MangaStaffCallback(manga_id=manga_id, user_id=user_id), 293 | ) 294 | 295 | keyboard.button(text=_("🐢 AniList"), url=manga_url) 296 | keyboard.adjust(2) 297 | 298 | keyboard.row( 299 | InlineKeyboardButton( 300 | text=_("🔙 Back"), 301 | callback_data=MangaCallback( 302 | query=manga_id, 303 | user_id=user.id, 304 | ).pack(), 305 | ) 306 | ) 307 | 308 | text = _( 309 | "Here you will be able to see the description, the characters, the team, and other \ 310 | things; make good use of it!" 311 | ) 312 | 313 | await message.edit_caption( 314 | caption=text, 315 | reply_markup=keyboard.as_markup(), 316 | ) 317 | 318 | 319 | @router.callback_query(MangaDescCallback.filter()) 320 | async def manga_description(callback: CallbackQuery, callback_data: MangaDescCallback): 321 | message = callback.message 322 | user = callback.from_user 323 | if not message: 324 | return 325 | 326 | if isinstance(message, InaccessibleMessage): 327 | return 328 | 329 | manga_id = callback_data.manga_id 330 | user_id = callback_data.user_id 331 | page = callback_data.page 332 | 333 | if user_id != user.id: 334 | await callback.answer( 335 | _("This button is not for you"), 336 | show_alert=True, 337 | cache_time=60, 338 | ) 339 | return 340 | 341 | _status, data = await AniList.get_adesc("manga", manga_id) 342 | manga = data["data"]["Page"]["media"][0] 343 | 344 | if not manga["description"]: 345 | await callback.answer( 346 | _("This manga does not have a description."), 347 | show_alert=True, 348 | cache_time=60, 349 | ) 350 | return 351 | 352 | description = manga["description"] 353 | amount = 1024 354 | page = 1 if page <= 0 else page 355 | offset = (page - 1) * amount 356 | stop = offset + amount 357 | pages = math.ceil(len(description) / amount) 358 | description = description[offset - (3 if page > 1 else 0) : stop] 359 | 360 | page_buttons = [] 361 | if page > 1: 362 | page_buttons.append( 363 | InlineKeyboardButton( 364 | text="⬅️", 365 | callback_data=MangaDescCallback( 366 | manga_id=manga_id, user_id=user_id, page=page - 1 367 | ).pack(), 368 | ) 369 | ) 370 | 371 | if page != pages: 372 | description = description[: len(description) - 3] + "..." 373 | page_buttons.append( 374 | InlineKeyboardButton( 375 | text="➡️", 376 | callback_data=MangaDescCallback( 377 | manga_id=manga_id, user_id=user_id, page=page + 1 378 | ).pack(), 379 | ) 380 | ) 381 | 382 | keyboard = InlineKeyboardBuilder() 383 | if len(page_buttons) > 0: 384 | keyboard.row(*page_buttons) 385 | 386 | keyboard.row( 387 | InlineKeyboardButton( 388 | text=_("🔙 Back"), 389 | callback_data=MangaMoreCallback( 390 | manga_id=manga_id, 391 | user_id=user_id, 392 | ).pack(), 393 | ) 394 | ) 395 | 396 | parsed_html = html.fromstring(description.replace("
", "")) 397 | description = ( 398 | str(html.tostring(parsed_html, encoding="unicode")).replace("

", "").replace("

", "") 399 | ) 400 | 401 | await message.edit_caption( 402 | caption=description, 403 | reply_markup=keyboard.as_markup(), 404 | ) 405 | 406 | 407 | @router.callback_query(MangaCharCallback.filter()) 408 | async def manga_characters(callback: CallbackQuery, callback_data: MangaCharCallback): 409 | message = callback.message 410 | user = callback.from_user 411 | if not message: 412 | return 413 | 414 | if isinstance(message, InaccessibleMessage): 415 | return 416 | 417 | manga_id = callback_data.manga_id 418 | user_id = callback_data.user_id 419 | page = callback_data.page 420 | 421 | if user_id != user.id: 422 | await callback.answer( 423 | _("This button is not for you"), 424 | show_alert=True, 425 | cache_time=60, 426 | ) 427 | return 428 | 429 | _status, data = await AniList.get_achars("manga", manga_id) 430 | manga = data["data"]["Page"]["media"][0] 431 | 432 | if not manga["characters"]: 433 | await callback.answer( 434 | _("This manga does not have characters."), 435 | show_alert=True, 436 | cache_time=60, 437 | ) 438 | return 439 | 440 | characters_text = "" 441 | characters = sorted( 442 | [ 443 | { 444 | "id": character["node"]["id"], 445 | "name": character["node"]["name"], 446 | "role": character["role"], 447 | } 448 | for character in manga["characters"]["edges"] 449 | ], 450 | key=operator.itemgetter("id"), 451 | ) 452 | 453 | me = await bot.get_me() 454 | for character in characters: 455 | characters_text += f"\n• {character["id"]} - {character["name"]["full"]} \ 457 | ({character["role"]})" 458 | 459 | characters_text = characters_text.split("\n") 460 | characters_text = [line for line in characters_text if line] 461 | characters_text = [characters_text[i : i + 8] for i in range(0, len(characters_text), 8)] 462 | 463 | pages = len(characters_text) 464 | 465 | page_buttons = [] 466 | if page > 0: 467 | page_buttons.append( 468 | InlineKeyboardButton( 469 | text="⬅️", 470 | callback_data=MangaCharCallback( 471 | manga_id=manga_id, user_id=user_id, page=page - 1 472 | ).pack(), 473 | ) 474 | ) 475 | if page + 1 != pages: 476 | page_buttons.append( 477 | InlineKeyboardButton( 478 | text="➡️", 479 | callback_data=MangaCharCallback( 480 | manga_id=manga_id, user_id=user_id, page=page + 1 481 | ).pack(), 482 | ) 483 | ) 484 | 485 | characters_text = characters_text[page] 486 | characters_text = "\n".join(characters_text) 487 | 488 | keyboard = InlineKeyboardBuilder() 489 | if len(page_buttons) > 0: 490 | keyboard.add(*page_buttons) 491 | 492 | keyboard.row( 493 | InlineKeyboardButton( 494 | text=_("🔙 Back"), 495 | callback_data=MangaMoreCallback( 496 | manga_id=manga_id, 497 | user_id=user_id, 498 | ).pack(), 499 | ) 500 | ) 501 | 502 | text = _("Below is the list of characters in this manga.") 503 | text = f"{text}\n\n{characters_text}" 504 | await message.edit_caption( 505 | caption=text, 506 | reply_markup=keyboard.as_markup(), 507 | ) 508 | 509 | 510 | @router.callback_query(MangaStaffCallback.filter()) 511 | async def manga_staff(callback: CallbackQuery, callback_data: MangaStaffCallback): 512 | message = callback.message 513 | user = callback.from_user 514 | if not message: 515 | return 516 | 517 | if isinstance(message, InaccessibleMessage): 518 | return 519 | 520 | manga_id = callback_data.manga_id 521 | user_id = callback_data.user_id 522 | page = callback_data.page 523 | 524 | if user_id != user.id: 525 | await callback.answer( 526 | _("This button is not for you"), 527 | show_alert=True, 528 | cache_time=60, 529 | ) 530 | return 531 | 532 | _status, data = await AniList.get_astaff("manga", manga_id) 533 | anime = data["data"]["Page"]["media"][0] 534 | 535 | if not anime["staff"]: 536 | await callback.answer( 537 | _("This anime does not have staff."), 538 | show_alert=True, 539 | cache_time=60, 540 | ) 541 | return 542 | 543 | staff_text = "" 544 | staffs = sorted( 545 | [ 546 | { 547 | "id": staff["node"]["id"], 548 | "name": staff["node"]["name"], 549 | "role": staff["role"], 550 | } 551 | for staff in anime["staff"]["edges"] 552 | ], 553 | key=operator.itemgetter("id"), 554 | ) 555 | 556 | me = await bot.get_me() 557 | for person in staffs: 558 | staff_text += f"\n• {person["id"]} - {person["name"]["full"]} ({person["role"]})" 560 | 561 | staff_text = staff_text.split("\n") 562 | staff_text = [line for line in staff_text if line] 563 | staff_text = [staff_text[i : i + 8] for i in range(0, len(staff_text), 8)] 564 | 565 | pages = len(staff_text) 566 | 567 | page_buttons = [] 568 | if page > 0: 569 | page_buttons.append( 570 | InlineKeyboardButton( 571 | text="⬅️", 572 | callback_data=MangaStaffCallback( 573 | manga_id=manga_id, user_id=user_id, page=page - 1 574 | ).pack(), 575 | ) 576 | ) 577 | if page + 1 != pages: 578 | page_buttons.append( 579 | InlineKeyboardButton( 580 | text="➡️", 581 | callback_data=MangaStaffCallback( 582 | manga_id=manga_id, user_id=user_id, page=page + 1 583 | ).pack(), 584 | ) 585 | ) 586 | 587 | staff_text = staff_text[page] 588 | staff_text = "\n".join(staff_text) 589 | 590 | keyboard = InlineKeyboardBuilder() 591 | if len(page_buttons) > 0: 592 | keyboard.add(*page_buttons) 593 | 594 | keyboard.row( 595 | InlineKeyboardButton( 596 | text=_("🔙 Back"), 597 | callback_data=MangaMoreCallback( 598 | manga_id=manga_id, 599 | user_id=user_id, 600 | ).pack(), 601 | ) 602 | ) 603 | 604 | text = _("Below is a list of the team responsible for this manga.") 605 | text = f"{text}\n\n{staff_text}" 606 | await message.edit_caption( 607 | caption=text, 608 | reply_markup=keyboard.as_markup(), 609 | ) 610 | -------------------------------------------------------------------------------- /gojira/handlers/pm_menu.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import F, Router 5 | from aiogram.enums import ChatType 6 | from aiogram.filters import Command, CommandObject, CommandStart 7 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 8 | from aiogram.utils.formatting import TextMention 9 | from aiogram.utils.i18n import gettext as _ 10 | from aiogram.utils.keyboard import InlineKeyboardBuilder 11 | 12 | from gojira.filters.chats import ChatTypeFilter 13 | from gojira.handlers.anime.view import anime_view 14 | from gojira.handlers.character.view import character_view 15 | from gojira.handlers.manga.view import manga_view 16 | from gojira.handlers.staff.view import staff_view 17 | from gojira.handlers.studio.view import studio_view 18 | from gojira.utils.callback_data import StartCallback 19 | 20 | router = Router(name="pm_menu") 21 | 22 | 23 | @router.message(CommandStart(deep_link=True), ChatTypeFilter(ChatType.PRIVATE)) 24 | async def start_command_deep_link(message: Message, command: CommandObject): 25 | if command.args and len(command.args.split("_")) > 1: 26 | content_type, *content_id = command.args.split("_") 27 | 28 | if content_type == "anime": 29 | await anime_view(message, anime_id=int(content_id[0])) 30 | elif content_type == "malanime": 31 | await anime_view(message, anime_id=int(content_id[0]), mal=True) 32 | elif content_type == "manga": 33 | await manga_view(message, manga_id=int(content_id[0])) 34 | elif content_type == "character": 35 | await character_view(message, character_id=int(content_id[0])) 36 | elif content_type == "staff": 37 | await staff_view(message, staff_id=int(content_id[0])) 38 | elif content_type == "studio": 39 | await studio_view(message, studio_id=int(content_id[0])) 40 | else: 41 | await start_command(message) 42 | 43 | 44 | @router.message(CommandStart(), ChatTypeFilter(ChatType.PRIVATE)) 45 | @router.callback_query(StartCallback.filter(F.menu == "start")) 46 | async def start_command(union: Message | CallbackQuery): 47 | is_callback = isinstance(union, CallbackQuery) 48 | message = union.message if is_callback else union 49 | if not message or not union.from_user: 50 | return 51 | 52 | if isinstance(message, InaccessibleMessage): 53 | return 54 | 55 | keyboard = InlineKeyboardBuilder() 56 | keyboard.button(text=_("ℹ️ About"), callback_data=StartCallback(menu="about")) 57 | keyboard.button(text=_("🌐 Language"), callback_data=StartCallback(menu="language")) 58 | keyboard.button(text=_("👮‍♂️ Help"), callback_data=StartCallback(menu="help")) 59 | keyboard.adjust(2) 60 | 61 | text = _( 62 | "Hello, {user_name}. I am Gojira, a Telegram bot that can help \ 63 | you with informations about anime and manga, such as genres, characters, studios, \ 64 | staff. And much more!" 65 | ).format(user_name=TextMention(union.from_user.full_name, user=union.from_user).as_html()) 66 | 67 | await (message.edit_text if is_callback else message.reply)( 68 | text, 69 | reply_markup=keyboard.as_markup(), 70 | ) 71 | 72 | 73 | @router.message(Command("help"), ChatTypeFilter(ChatType.PRIVATE)) 74 | @router.callback_query(StartCallback.filter(F.menu == "help")) 75 | async def help_menu(union: Message | CallbackQuery): 76 | is_callback = isinstance(union, CallbackQuery) 77 | message = union.message if is_callback else union 78 | if not message or not union.from_user: 79 | return 80 | 81 | if isinstance(message, InaccessibleMessage): 82 | return 83 | 84 | keyboard = InlineKeyboardBuilder() 85 | keyboard.button(text=_("👸 Anime"), callback_data=StartCallback(menu="anime")) 86 | keyboard.button(text=_("📖 Manga"), callback_data=StartCallback(menu="manga")) 87 | keyboard.button(text=_("👤 Character"), callback_data=StartCallback(menu="character")) 88 | keyboard.button(text=_("👨‍💻 Staff"), callback_data=StartCallback(menu="staff")) 89 | keyboard.button(text=_("🎬 Studio"), callback_data=StartCallback(menu="studio")) 90 | keyboard.adjust(3) 91 | 92 | if is_callback or message.chat.type == ChatType.PRIVATE: 93 | keyboard.row( 94 | InlineKeyboardButton( 95 | text=_("🔙 Back"), callback_data=StartCallback(menu="start").pack() 96 | ) 97 | ) 98 | 99 | text = _("Click on one of the buttons below to start exploring all my features.") 100 | 101 | await (message.edit_text if is_callback else message.reply)( 102 | text, 103 | reply_markup=keyboard.as_markup(), 104 | ) 105 | 106 | 107 | @router.message(Command("about")) 108 | @router.callback_query(StartCallback.filter(F.menu == "about")) 109 | async def about(union: Message | CallbackQuery): 110 | is_callback = isinstance(union, CallbackQuery) 111 | message = union.message if is_callback else union 112 | if not message: 113 | return 114 | 115 | if isinstance(message, InaccessibleMessage): 116 | return 117 | 118 | text = _( 119 | "Gojira is a bot developed using Python that utilizes AIOgram and \ 120 | AniList GraphQL API to provide fast, stable and comprehensive information about animes \ 121 | and mangas. The name Gojira (ゴジラ) is a reference to the Japanese name of the famous monster \ 122 | Godzilla." 123 | ) 124 | 125 | keyboard = InlineKeyboardBuilder() 126 | keyboard.button(text=_("📦 GitHub"), url="https://github.com/HitaloM/Gojira") 127 | keyboard.button(text=_("📚 Channel"), url="https://t.me/HitaloProjects") 128 | keyboard.adjust(2) 129 | 130 | if is_callback or message.chat.type == ChatType.PRIVATE: 131 | keyboard.row( 132 | InlineKeyboardButton( 133 | text=_("🔙 Back"), callback_data=StartCallback(menu="start").pack() 134 | ) 135 | ) 136 | 137 | await (message.edit_text if is_callback else message.reply)( 138 | text, 139 | reply_markup=keyboard.as_markup(), 140 | ) 141 | -------------------------------------------------------------------------------- /gojira/handlers/staff/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | -------------------------------------------------------------------------------- /gojira/handlers/staff/inline.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import random 5 | import re 6 | from contextlib import suppress 7 | 8 | from aiogram import F, Router 9 | from aiogram.enums import InlineQueryResultType, ParseMode 10 | from aiogram.exceptions import TelegramBadRequest 11 | from aiogram.types import InlineQuery, InlineQueryResultPhoto 12 | from aiogram.utils.i18n import gettext as _ 13 | from aiogram.utils.keyboard import InlineKeyboardBuilder 14 | 15 | from gojira import AniList, bot 16 | 17 | router = Router(name="staff_inline") 18 | 19 | 20 | @router.inline_query(F.query.regexp(r"^!s (?P.+)").as_("match")) 21 | async def staff_inline(inline: InlineQuery, match: re.Match[str]): 22 | query = match.group("query") 23 | 24 | results = [] 25 | 26 | _status, data = await AniList.search("staff", query) 27 | if not data: 28 | return 29 | 30 | search_results = data["data"]["Page"]["staff"] 31 | 32 | if not search_results: 33 | return 34 | 35 | for result in search_results: 36 | _status, data = await AniList.get("staff", result["id"]) 37 | if not data: 38 | return 39 | 40 | if not data["data"]["Page"]["staff"]: 41 | continue 42 | 43 | staff = data["data"]["Page"]["staff"][0] 44 | 45 | photo: str = "" 46 | if image := staff["image"]: 47 | if large_image := image["large"]: 48 | photo = large_image 49 | elif medium_image := image["medium"]: 50 | photo = medium_image 51 | 52 | description: str = "" 53 | if description := staff["description"]: 54 | description = description.replace("__", "") 55 | description = description.replace("**", "") 56 | description = description.replace("~", "") 57 | description = re.sub(re.compile(r"<.*?>"), "", description) 58 | description = description[0:260] + "..." 59 | 60 | text = f"**{staff["name"]["full"]}**" 61 | text += _("\n**ID**: `{id}`").format(id=staff["id"]) + " (**STAFF**)" 62 | if staff["language"]: 63 | text += _("\n**Language**: `{language}`").format(language=staff["language"]) 64 | if staff["favourites"]: 65 | text += _("\n**Favourites**: `{favourites}`").format(favourites=staff["favourites"]) 66 | 67 | text += f"\n\n{description}" 68 | 69 | keyboard = InlineKeyboardBuilder() 70 | 71 | me = await bot.get_me() 72 | bot_username = me.username 73 | keyboard.button( 74 | text=_("👓 View More"), 75 | url=f"https://t.me/{bot_username}/?start=staff_{staff["id"]}", 76 | ) 77 | 78 | results.append( 79 | InlineQueryResultPhoto( 80 | type=InlineQueryResultType.PHOTO, 81 | id=str(random.getrandbits(64)), 82 | photo_url=photo, 83 | thumbnail_url=photo, 84 | title=staff["name"]["full"], 85 | description=description, 86 | caption=text, 87 | parse_mode=ParseMode.MARKDOWN, 88 | reply_markup=keyboard.as_markup(), 89 | ) 90 | ) 91 | 92 | with suppress(TelegramBadRequest): 93 | if len(results) > 0: 94 | await inline.answer(results=results, is_personal=True) 95 | -------------------------------------------------------------------------------- /gojira/handlers/staff/popular.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Router 7 | from aiogram.exceptions import TelegramAPIError 8 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton 9 | from aiogram.utils.i18n import gettext as _ 10 | 11 | from gojira import AniList 12 | from gojira.utils.callback_data import StaffCallback, StaffPopuCallback, StartCallback 13 | from gojira.utils.keyboard import Pagination 14 | 15 | router = Router(name="staff_popular") 16 | 17 | 18 | @router.callback_query(StaffPopuCallback.filter()) 19 | async def staff_popular(callback: CallbackQuery, callback_data: StaffPopuCallback): 20 | message = callback.message 21 | if not message: 22 | return 23 | 24 | if isinstance(message, InaccessibleMessage): 25 | return 26 | 27 | page = callback_data.page 28 | 29 | _status, data = await AniList.popular("staff") 30 | if data["data"]: 31 | items = data["data"]["Page"]["staff"] 32 | results = [item.copy() for item in items] 33 | 34 | layout = Pagination( 35 | results, 36 | item_data=lambda i, _: StaffCallback(query=i["id"]).pack(), 37 | item_title=lambda i, _: i["name"]["full"], 38 | page_data=lambda pg: StaffPopuCallback(page=pg).pack(), 39 | ) 40 | 41 | keyboard = layout.create(page, lines=8) 42 | 43 | keyboard.inline_keyboard.append([ 44 | InlineKeyboardButton( 45 | text=_("🔙 Back"), 46 | callback_data=StartCallback(menu="staff").pack(), 47 | ) 48 | ]) 49 | 50 | text = _("Below are the 50 most popular staffs in descending order.") 51 | with suppress(TelegramAPIError): 52 | await message.edit_text( 53 | text=text, 54 | reply_markup=keyboard, 55 | ) 56 | -------------------------------------------------------------------------------- /gojira/handlers/staff/start.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import F, Router 5 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 6 | from aiogram.utils.i18n import gettext as _ 7 | from aiogram.utils.keyboard import InlineKeyboardBuilder 8 | 9 | from gojira.utils.callback_data import StaffPopuCallback, StartCallback 10 | 11 | router = Router(name="staff_start") 12 | 13 | 14 | @router.callback_query(StartCallback.filter(F.menu == "staff")) 15 | async def staff_start(union: Message | CallbackQuery): 16 | is_callback = isinstance(union, CallbackQuery) 17 | message = union.message if is_callback else union 18 | if not message: 19 | return 20 | 21 | if isinstance(message, InaccessibleMessage): 22 | return 23 | 24 | keyboard = InlineKeyboardBuilder() 25 | keyboard.button(text=_("📈 Popular"), callback_data=StaffPopuCallback(page=1)) 26 | keyboard.button(text=_("🔍 Search"), switch_inline_query_current_chat="!s ") 27 | keyboard.adjust(2) 28 | 29 | keyboard.row( 30 | InlineKeyboardButton( 31 | text=_("🔙 Back"), 32 | callback_data=StartCallback(menu="help").pack(), 33 | ) 34 | ) 35 | 36 | text = _("You are in the staff section, use the buttons below to do what you want.") 37 | await (message.edit_text if is_callback else message.reply)( 38 | text, 39 | reply_markup=keyboard.as_markup(), 40 | ) 41 | -------------------------------------------------------------------------------- /gojira/handlers/staff/view.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import Router 5 | from aiogram.enums import ChatType, ParseMode 6 | from aiogram.filters import Command, CommandObject 7 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 8 | from aiogram.utils.i18n import gettext as _ 9 | from aiogram.utils.keyboard import InlineKeyboardBuilder 10 | 11 | from gojira import AniList 12 | from gojira.handlers.staff.start import staff_start 13 | from gojira.utils.callback_data import StaffCallback 14 | 15 | router = Router(name="staff_view") 16 | 17 | 18 | @router.message(Command("staff")) 19 | @router.callback_query(StaffCallback.filter()) 20 | async def staff_view( 21 | union: Message | CallbackQuery, 22 | command: CommandObject | None = None, 23 | callback_data: StaffCallback | None = None, 24 | staff_id: int | None = None, 25 | ): 26 | is_callback = isinstance(union, CallbackQuery) 27 | message = union.message if is_callback else union 28 | user = union.from_user 29 | if not message or not user: 30 | return 31 | 32 | if isinstance(message, InaccessibleMessage): 33 | return 34 | 35 | is_private: bool = message.chat.type == ChatType.PRIVATE 36 | 37 | if command and not command.args: 38 | if is_private: 39 | await staff_start(message) 40 | return 41 | await message.reply( 42 | _("You need to specify an staff. Use /staff name or id") 43 | ) 44 | return 45 | 46 | query = str( 47 | callback_data.query 48 | if is_callback and callback_data is not None 49 | else command.args 50 | if command and command.args 51 | else staff_id 52 | ) 53 | 54 | if is_callback and callback_data is not None: 55 | user_id = callback_data.user_id 56 | if user_id is not None: 57 | user_id = int(user_id) 58 | 59 | if user_id != user.id: 60 | return 61 | 62 | is_search = callback_data.is_search 63 | if bool(is_search) and not is_private: 64 | await message.delete() 65 | 66 | if not bool(query): 67 | return 68 | 69 | keyboard = InlineKeyboardBuilder() 70 | if not query.isdecimal(): 71 | _status, data = await AniList.search("staff", query) 72 | if not data: 73 | await message.reply(_("No results found.")) 74 | return 75 | 76 | results = data["data"]["Page"]["staff"] 77 | if results is None or len(results) == 0: 78 | await message.reply(_("No results found.")) 79 | return 80 | 81 | if len(results) == 1: 82 | staff_id = int(results[0]["id"]) 83 | else: 84 | for result in results: 85 | keyboard.row( 86 | InlineKeyboardButton( 87 | text=result["name"]["full"], 88 | callback_data=StaffCallback( 89 | query=result["id"], 90 | user_id=user.id, 91 | is_search=True, 92 | ).pack(), 93 | ) 94 | ) 95 | await message.reply( 96 | _("Search results for: {query}").format(query=query), 97 | reply_markup=keyboard.as_markup(), 98 | ) 99 | return 100 | else: 101 | staff_id = int(query) 102 | 103 | _status, data = await AniList.get("staff", staff_id) 104 | if not data: 105 | await message.reply(_("No results found.")) 106 | return 107 | 108 | staff = data["data"]["Page"]["staff"][0] 109 | if staff is None: 110 | await union.answer( 111 | _("No results found."), 112 | show_alert=True, 113 | cache_time=60, 114 | ) 115 | return 116 | 117 | text = f"**{staff["name"]["full"]}**" 118 | text += _("\n**ID**: `{id}`").format(id=staff["id"]) 119 | if staff["language"]: 120 | text += _("\n**Language**: `{language}`").format(language=staff["language"]) 121 | if staff["favourites"]: 122 | text += _("\n**Favourites**: `{favourites}`").format(favourites=staff["favourites"]) 123 | if staff["description"]: 124 | text += f"\n\n{staff["description"]}" 125 | 126 | photo: str = "" 127 | if image := staff["image"]: 128 | if large_image := image["large"]: 129 | photo = large_image 130 | elif medium_image := image["medium"]: 131 | photo = medium_image 132 | 133 | keyboard.button(text=_("🐢 AniList"), url=staff["siteUrl"]) 134 | 135 | keyboard.adjust(2) 136 | 137 | if len(text) > 1024: 138 | text = text[:1021] + "..." 139 | 140 | # Markdown 141 | text = text.replace("__", "**") 142 | text = text.replace("~", "||") 143 | 144 | if len(photo) > 0: 145 | await message.answer_photo( 146 | photo=photo, 147 | caption=text, 148 | parse_mode=ParseMode.MARKDOWN, 149 | reply_markup=keyboard.as_markup(), 150 | ) 151 | else: 152 | await message.answer( 153 | text=text, 154 | parse_mode=ParseMode.MARKDOWN, 155 | reply_markup=keyboard.as_markup(), 156 | ) 157 | -------------------------------------------------------------------------------- /gojira/handlers/studio/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | -------------------------------------------------------------------------------- /gojira/handlers/studio/popular.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Router 7 | from aiogram.exceptions import TelegramAPIError 8 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton 9 | from aiogram.utils.i18n import gettext as _ 10 | 11 | from gojira import AniList 12 | from gojira.utils.callback_data import StartCallback, StudioCallback, StudioPopuCallback 13 | from gojira.utils.keyboard import Pagination 14 | 15 | router = Router(name="studio_popular") 16 | 17 | 18 | @router.callback_query(StudioPopuCallback.filter()) 19 | async def studio_popular(callback: CallbackQuery, callback_data: StudioPopuCallback): 20 | message = callback.message 21 | if not message: 22 | return 23 | 24 | if isinstance(message, InaccessibleMessage): 25 | return 26 | 27 | page = callback_data.page 28 | 29 | _status, data = await AniList.popular("studio") 30 | if data["data"]: 31 | items = data["data"]["Page"]["studios"] 32 | results = [item.copy() for item in items] 33 | 34 | layout = Pagination( 35 | results, 36 | item_data=lambda i, _: StudioCallback(query=i["id"]).pack(), 37 | item_title=lambda i, _: i["name"], 38 | page_data=lambda pg: StudioPopuCallback(page=pg).pack(), 39 | ) 40 | 41 | keyboard = layout.create(page, lines=8) 42 | 43 | keyboard.inline_keyboard.append([ 44 | InlineKeyboardButton( 45 | text=_("🔙 Back"), 46 | callback_data=StartCallback(menu="studio").pack(), 47 | ) 48 | ]) 49 | 50 | text = _("Below are the 50 most popular studios in descending order.") 51 | with suppress(TelegramAPIError): 52 | await message.edit_text(text=text, reply_markup=keyboard) 53 | -------------------------------------------------------------------------------- /gojira/handlers/studio/start.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import F, Router 5 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 6 | from aiogram.utils.i18n import gettext as _ 7 | from aiogram.utils.keyboard import InlineKeyboardBuilder 8 | 9 | from gojira.utils.callback_data import StartCallback, StudioPopuCallback 10 | 11 | router = Router(name="studio_start") 12 | 13 | 14 | @router.callback_query(StartCallback.filter(F.menu == "studio")) 15 | async def studio_start(union: Message | CallbackQuery): 16 | is_callback = isinstance(union, CallbackQuery) 17 | message = union.message if is_callback else union 18 | if not message: 19 | return 20 | 21 | if isinstance(message, InaccessibleMessage): 22 | return 23 | 24 | keyboard = InlineKeyboardBuilder() 25 | keyboard.button(text=_("📈 Popular"), callback_data=StudioPopuCallback(page=1)) 26 | keyboard.adjust(2) 27 | 28 | keyboard.row( 29 | InlineKeyboardButton( 30 | text=_("🔙 Back"), 31 | callback_data=StartCallback(menu="help").pack(), 32 | ) 33 | ) 34 | 35 | text = _("You are in the studio section, use the buttons below to do what you want.") 36 | await (message.edit_text if is_callback else message.reply)( 37 | text, 38 | reply_markup=keyboard.as_markup(), 39 | ) 40 | -------------------------------------------------------------------------------- /gojira/handlers/studio/view.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import Router 5 | from aiogram.enums import ChatType 6 | from aiogram.filters import Command, CommandObject 7 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 8 | from aiogram.utils.i18n import gettext as _ 9 | from aiogram.utils.keyboard import InlineKeyboardBuilder 10 | 11 | from gojira import AniList, bot 12 | from gojira.handlers.studio.start import studio_start 13 | from gojira.utils.callback_data import StudioCallback, StudioMediaCallback 14 | 15 | router = Router(name="studio_view") 16 | 17 | 18 | @router.message(Command("studio")) 19 | @router.callback_query(StudioCallback.filter()) 20 | async def studio_view( 21 | union: Message | CallbackQuery, 22 | command: CommandObject | None = None, 23 | callback_data: StudioCallback | None = None, 24 | studio_id: int | None = None, 25 | ): 26 | is_callback = isinstance(union, CallbackQuery) 27 | message = union.message if is_callback else union 28 | user = union.from_user 29 | if not message or not user: 30 | return 31 | 32 | if isinstance(message, InaccessibleMessage): 33 | return 34 | 35 | is_private: bool = message.chat.type == ChatType.PRIVATE 36 | 37 | if command and not command.args: 38 | if is_private: 39 | await studio_start(message) 40 | return 41 | await message.reply( 42 | _("You need to specify an studio. Use /studio name or id") 43 | ) 44 | return 45 | 46 | query = str( 47 | callback_data.query 48 | if is_callback and callback_data is not None 49 | else command.args 50 | if command and command.args 51 | else studio_id 52 | ) 53 | is_search = callback_data.is_search if is_callback and callback_data else None 54 | 55 | if is_callback and callback_data is not None: 56 | user_id = callback_data.user_id 57 | if user_id is not None: 58 | user_id = int(user_id) 59 | 60 | if user_id != user.id: 61 | return 62 | 63 | if bool(is_search) and not is_private: 64 | await message.delete() 65 | 66 | if not bool(query): 67 | return 68 | 69 | keyboard = InlineKeyboardBuilder() 70 | if not query.isdecimal(): 71 | _status, data = await AniList.search("studio", query) 72 | if not data: 73 | await message.reply(_("No results found.")) 74 | return 75 | 76 | results = data["data"]["Page"]["studios"] 77 | if results is None or len(results) == 0: 78 | await message.reply(_("No results found.")) 79 | return 80 | 81 | if len(results) == 1: 82 | studio_id = int(results[0]["id"]) 83 | else: 84 | for result in results: 85 | keyboard.row( 86 | InlineKeyboardButton( 87 | text=result["name"], 88 | callback_data=StudioCallback( 89 | query=result["id"], 90 | user_id=user.id, 91 | is_search=True, 92 | ).pack(), 93 | ) 94 | ) 95 | await message.reply( 96 | _("Search results for: {query}").format(query=query), 97 | reply_markup=keyboard.as_markup(), 98 | ) 99 | return 100 | else: 101 | studio_id = int(query) 102 | 103 | _status, data = await AniList.get("studio", studio_id) 104 | if not data: 105 | await message.reply(_("No results found.")) 106 | return 107 | 108 | studio = data["data"]["Studio"] 109 | if studio is None: 110 | await union.answer( 111 | _("No results found."), 112 | show_alert=True, 113 | cache_time=60, 114 | ) 115 | return 116 | 117 | text = f"{studio["name"]}" 118 | text += _("\nID: {id}").format(id=studio["id"]) 119 | text += _("\nFavourites: {favourites}").format( 120 | favourites=studio["favourites"] 121 | ) 122 | is_anim = _("Yes") if studio["isAnimationStudio"] else _("No") 123 | text += _("\nAnimation Studio: {is_anim}").format(is_anim=is_anim) 124 | 125 | keyboard.button( 126 | text=_("🎬 Medias"), 127 | callback_data=StudioMediaCallback( 128 | studio_name=studio["name"], studio_id=studio["id"], user_id=user.id, page=0 129 | ), 130 | ) 131 | keyboard.button(text=_("🐢 AniList"), url=studio["siteUrl"]) 132 | keyboard.adjust(2) 133 | 134 | await (message.edit_text if is_callback and not is_search else message.answer)( 135 | text, 136 | reply_markup=keyboard.as_markup(), 137 | ) 138 | 139 | 140 | @router.callback_query(StudioMediaCallback.filter()) 141 | async def studio_media_view(callback: CallbackQuery, callback_data: StudioMediaCallback): 142 | message = callback.message 143 | user = callback.from_user 144 | if not message or not user: 145 | return 146 | 147 | if isinstance(message, InaccessibleMessage): 148 | return 149 | 150 | studio_id = callback_data.studio_id 151 | user_id = callback_data.user_id 152 | page = callback_data.page 153 | studio_name = callback_data.studio_name 154 | 155 | if user_id != user.id: 156 | await callback.answer( 157 | _("This button is not for you"), 158 | show_alert=True, 159 | cache_time=60, 160 | ) 161 | return 162 | 163 | _status, data = await AniList.get_studio_media(studio_id) 164 | 165 | me = await bot.get_me() 166 | medias = data["data"]["Studio"]["media"]["nodes"] 167 | media_list = "" 168 | for media in medias: 169 | title = media["title"]["romaji"] 170 | mid = media["id"] 171 | media_list += f"\n• {mid} - {title}" 172 | 173 | media_list = media_list.split("\n") 174 | media_list = [line for line in media_list if line] 175 | media_list = [media_list[i : i + 8] for i in range(0, len(media_list), 8)] 176 | 177 | keyboard = InlineKeyboardBuilder() 178 | 179 | pages = len(media_list) 180 | if page > 0: 181 | keyboard.button( 182 | text="◀️", 183 | callback_data=StudioMediaCallback( 184 | studio_name=studio_name, 185 | studio_id=studio_id, 186 | user_id=user_id, 187 | page=page - 1, 188 | ), 189 | ) 190 | if page + 1 != pages: 191 | keyboard.button( 192 | text="▶️", 193 | callback_data=StudioMediaCallback( 194 | studio_name=studio_name, 195 | studio_id=studio_id, 196 | user_id=user_id, 197 | page=page + 1, 198 | ), 199 | ) 200 | 201 | keyboard.adjust(2) 202 | 203 | keyboard.row( 204 | InlineKeyboardButton( 205 | text=_("🔙 Back"), 206 | callback_data=StudioCallback(query=studio_id, user_id=user_id).pack(), 207 | ) 208 | ) 209 | 210 | media_list = media_list[page] 211 | media_list = "\n".join(media_list) 212 | 213 | text = _("Media that {name} has worked on:\n{list}").format( 214 | name=studio_name, list=media_list 215 | ) 216 | 217 | await message.edit_text( 218 | text, 219 | disable_web_page_preview=True, 220 | reply_markup=keyboard.as_markup(), 221 | ) 222 | -------------------------------------------------------------------------------- /gojira/handlers/upcoming.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram import Router 5 | from aiogram.enums import ChatType 6 | from aiogram.filters import Command 7 | from aiogram.types import CallbackQuery, InaccessibleMessage, Message 8 | from aiogram.utils.i18n import gettext as _ 9 | from aiogram.utils.keyboard import InlineKeyboardBuilder 10 | 11 | from gojira.filters.chats import ChatTypeFilter 12 | from gojira.utils.callback_data import ( 13 | AnimeUpcomingCallback, 14 | MangaUpcomingCallback, 15 | UpcomingCallback, 16 | ) 17 | 18 | router = Router(name="upcoming") 19 | 20 | 21 | @router.message(Command("upcoming"), ChatTypeFilter((ChatType.SUPERGROUP, ChatType.GROUP))) 22 | @router.callback_query(UpcomingCallback.filter()) 23 | async def upcoming(union: Message | CallbackQuery, callback_data: UpcomingCallback | None = None): 24 | is_callback = isinstance(union, CallbackQuery) 25 | message = union.message if is_callback else union 26 | user = union.from_user 27 | if not message or not user: 28 | return 29 | 30 | if isinstance(message, InaccessibleMessage): 31 | return 32 | 33 | user_id = callback_data.user_id if callback_data else user.id 34 | 35 | if callback_data and user_id != user.id: 36 | await union.answer( 37 | _("This button is not for you."), 38 | show_alert=True, 39 | cache_time=60, 40 | ) 41 | return 42 | 43 | keyboard = InlineKeyboardBuilder() 44 | keyboard.button( 45 | text=_("👸 Anime"), callback_data=AnimeUpcomingCallback(page=1, user_id=user_id) 46 | ) 47 | keyboard.button( 48 | text=_("📖 Manga"), callback_data=MangaUpcomingCallback(page=1, user_id=user_id) 49 | ) 50 | await (message.edit_text if is_callback else message.reply)( 51 | _("Select the type of upcoming media you want to see."), 52 | reply_markup=keyboard.as_markup(), 53 | ) 54 | -------------------------------------------------------------------------------- /gojira/handlers/user.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import datetime 5 | import time 6 | 7 | import humanize 8 | from aiogram import Router 9 | from aiogram.enums import ChatType 10 | from aiogram.filters import Command, CommandObject 11 | from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardButton, Message 12 | from aiogram.utils.i18n import gettext as _ 13 | from aiogram.utils.keyboard import InlineKeyboardBuilder 14 | 15 | from gojira import AniList, cache 16 | from gojira.utils.callback_data import UserCallback, UserStatsCallback 17 | 18 | router = Router(name="users") 19 | 20 | 21 | @router.message(Command("user")) 22 | @router.callback_query(UserCallback.filter()) 23 | async def user_view( 24 | union: Message | CallbackQuery, 25 | command: CommandObject | None = None, 26 | callback_data: UserCallback | None = None, 27 | ): 28 | is_callback = isinstance(union, CallbackQuery) 29 | message = union.message if is_callback else union 30 | user = union.from_user 31 | if not message or not user: 32 | return 33 | 34 | if isinstance(message, InaccessibleMessage): 35 | return 36 | 37 | is_private: bool = message.chat.type == ChatType.PRIVATE 38 | 39 | if command and not command.args: 40 | await message.reply( 41 | _("You need to specify a user. Use /user username or id") 42 | ) 43 | return 44 | 45 | query = str( 46 | callback_data.query 47 | if is_callback and callback_data is not None 48 | else command.args 49 | if command and command.args 50 | else None 51 | ) 52 | 53 | if is_callback and callback_data is not None: 54 | user_id = callback_data.user_id 55 | if user_id is not None: 56 | user_id = int(user_id) 57 | 58 | if user_id != user.id: 59 | return 60 | 61 | is_search = callback_data.is_search 62 | if bool(is_search) and not is_private: 63 | await message.delete() 64 | 65 | if not bool(query): 66 | return 67 | 68 | keyboard = InlineKeyboardBuilder() 69 | if not query.isdecimal(): 70 | _status, data = await AniList.search("user", query) 71 | if not data: 72 | await message.reply(_("No results found.")) 73 | return 74 | 75 | results = data["data"]["Page"]["users"] 76 | if results is None or len(results) == 0: 77 | await message.reply(_("No results found.")) 78 | return 79 | 80 | if len(results) == 1: 81 | user_id = int(results[0]["id"]) 82 | else: 83 | for result in results: 84 | keyboard.row( 85 | InlineKeyboardButton( 86 | text=result["name"], 87 | callback_data=UserCallback( 88 | query=result["id"], 89 | user_id=user.id, 90 | is_search=True, 91 | ).pack(), 92 | ) 93 | ) 94 | await message.reply( 95 | _("Search results for: {query}").format(query=query), 96 | reply_markup=keyboard.as_markup(), 97 | ) 98 | return 99 | else: 100 | user_id = int(query) 101 | 102 | _status, data = await AniList.get("user", user_id) 103 | if not data: 104 | await message.reply(_("No results found.")) 105 | return 106 | 107 | auser = data["data"]["User"] 108 | if auser is None: 109 | await union.answer( 110 | _("No results found."), 111 | show_alert=True, 112 | cache_time=60, 113 | ) 114 | return 115 | 116 | text = _("Username: {name}\n").format(name=auser["name"]) 117 | text += _("ID: {id}\n").format(id=auser["id"]) 118 | donator = _("Yes") if auser["donatorTier"] else _("No") 119 | text += _("Donator: {donator}\n").format(donator=donator) 120 | 121 | text += _("\nCreated At: {date}\n").format( 122 | date=datetime.datetime.fromtimestamp(auser["createdAt"], tz=datetime.UTC).strftime( 123 | "%d/%m/%Y" 124 | ) 125 | ) 126 | text += _("Updated At: {date}").format( 127 | date=datetime.datetime.fromtimestamp(auser["updatedAt"], tz=datetime.UTC).strftime( 128 | "%d/%m/%Y" 129 | ) 130 | ) 131 | 132 | cached_photo = await cache.get(f"anilist_user_{auser["id"]}") 133 | photo = cached_photo or f"https://img.anili.st/user/{auser["id"]}?a={time.time()}" 134 | 135 | keyboard.button( 136 | text=_("Anime Stats"), 137 | callback_data=UserStatsCallback(user_id=user_id, stat_type="anime").pack(), 138 | ) 139 | keyboard.button( 140 | text=_("Manga Stats"), 141 | callback_data=UserStatsCallback(user_id=user_id, stat_type="manga").pack(), 142 | ) 143 | keyboard.button(text=_("🐢 AniList"), url=auser["siteUrl"]) 144 | keyboard.adjust(2) 145 | 146 | sent = await message.answer_photo( 147 | photo=photo, 148 | caption=text, 149 | reply_markup=keyboard.as_markup(), 150 | ) 151 | 152 | if sent.photo and not cached_photo: 153 | await cache.set(f"anilist_user_{auser["id"]}", sent.photo[-1].file_id, expire="1h") 154 | 155 | 156 | @router.callback_query(UserStatsCallback.filter()) 157 | async def user_stats(callback_query: CallbackQuery, callback_data: UserStatsCallback): 158 | user_id = callback_data.user_id 159 | stat_type = callback_data.stat_type 160 | 161 | _statu, data = await AniList.get_user_stat(user_id, stat_type) 162 | if not data: 163 | await callback_query.answer( 164 | _("No results found."), 165 | show_alert=True, 166 | cache_time=60, 167 | ) 168 | return 169 | 170 | text = "" 171 | 172 | if stat_type.lower() == "anime": 173 | stat = data["data"]["User"]["statistics"]["anime"] 174 | 175 | text = _("Total Anime Watched: {count}\n").format(count=stat["count"]) 176 | text += _("Total Episode Watched: {episodes}\n").format(episodes=stat["episodesWatched"]) 177 | days_watched = humanize.naturaldelta(stat["minutesWatched"] * 60, months=False) 178 | text += _("Total Time Spent: {time}\n").format(time=days_watched) 179 | text += _("Average Score: {mean_score}").format(mean_score=stat["meanScore"]) 180 | 181 | if stat_type.lower() == "manga": 182 | stat = data["data"]["User"]["statistics"]["manga"] 183 | 184 | text = _("Total Manga Read: {count}\n").format(count=stat["count"]) 185 | text += _("Total Chapter Read: {chapters}\n").format(chapters=stat["chaptersRead"]) 186 | text += _("Total Volume Read: {volumes}\n").format(volumes=stat["volumesRead"]) 187 | text += _("Average Score: {mean_score}").format(mean_score=stat["meanScore"]) 188 | 189 | await callback_query.answer(text, show_alert=True) 190 | -------------------------------------------------------------------------------- /gojira/handlers/view.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import re 5 | 6 | from aiogram import F, Router 7 | from aiogram.enums import ChatType 8 | from aiogram.types import Message 9 | 10 | from gojira import bot 11 | from gojira.filters.chats import ChatTypeFilter 12 | from gojira.handlers.anime.view import anime_view 13 | from gojira.handlers.character.view import character_view 14 | from gojira.handlers.manga.view import manga_view 15 | from gojira.handlers.staff.view import staff_view 16 | 17 | router = Router(name="view") 18 | 19 | 20 | @router.message(ChatTypeFilter(ChatType.PRIVATE), F.via_bot) 21 | async def view(message: Message): 22 | if not message.via_bot: 23 | return 24 | 25 | me = await bot.get_me() 26 | if (message.via_bot.id == me.id and message.photo) or message.text: 27 | for line in ( 28 | message.caption.splitlines() 29 | if message.caption 30 | else message.text.splitlines() 31 | if message.text 32 | else [] 33 | ): 34 | if "ID:" in line: 35 | matches = re.match(r"ID: (\d+) \((\w+)\)", line) 36 | if matches: 37 | content_type, content_id = ( 38 | matches.group(2).lower(), 39 | int(matches.group(1)), 40 | ) 41 | 42 | if content_type == "anime": 43 | await anime_view(message, anime_id=content_id) 44 | elif content_type == "manga": 45 | await manga_view(message, manga_id=content_id) 46 | elif content_type == "character": 47 | await character_view(message, character_id=content_id) 48 | elif content_type == "staff": 49 | await staff_view(message, staff_id=content_id) 50 | -------------------------------------------------------------------------------- /gojira/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | -------------------------------------------------------------------------------- /gojira/middlewares/acl.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from collections.abc import Awaitable, Callable 5 | from typing import Any 6 | 7 | from aiogram.dispatcher.middlewares.base import BaseMiddleware 8 | from aiogram.enums import ChatType 9 | from aiogram.types import Chat, TelegramObject, User 10 | from babel import Locale, UnknownLocaleError 11 | 12 | from gojira import i18n 13 | from gojira.database import Chats, Users 14 | 15 | 16 | class ACLMiddleware(BaseMiddleware): 17 | async def __call__( 18 | self, 19 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 20 | event: TelegramObject, 21 | data: dict[str, Any], 22 | ) -> Any: 23 | user: User | None = data.get("event_from_user") 24 | chat: Chat | None = data.get("event_chat") 25 | 26 | if user and not user.is_bot: 27 | userdb = await Users.get_user(user=user) 28 | if not userdb: 29 | if user.language_code: 30 | try: 31 | locale = Locale.parse(user.language_code, sep="-") 32 | if str(locale) not in i18n.available_locales: 33 | locale = i18n.default_locale 34 | except UnknownLocaleError: 35 | locale = i18n.default_locale 36 | else: 37 | locale = i18n.default_locale 38 | 39 | if chat and chat.type == ChatType.PRIVATE: 40 | userdb = await Users.set_language(user=user, language_code=str(locale)) 41 | 42 | data["user"] = userdb 43 | 44 | if chat: 45 | chatdb = await Chats.get_chat(chat=chat) 46 | if not chatdb and chat.type in { 47 | ChatType.GROUP, 48 | ChatType.SUPERGROUP, 49 | }: 50 | chatdb = await Chats.set_language(chat=chat, language_code=i18n.default_locale) 51 | 52 | data["chat"] = chatdb 53 | 54 | return await handler(event, data) 55 | -------------------------------------------------------------------------------- /gojira/middlewares/i18n.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from typing import Any, cast 5 | 6 | from aiogram.enums import ChatType 7 | from aiogram.types import Chat, TelegramObject, User 8 | from aiogram.utils.i18n import I18nMiddleware 9 | 10 | from gojira.database import Chats, Users 11 | 12 | 13 | class MyI18nMiddleware(I18nMiddleware): 14 | async def get_locale(self, event: TelegramObject, data: dict[str, Any]) -> str: 15 | user: User | None = data.get("event_from_user") 16 | chat: Chat | None = data.get("event_chat") 17 | 18 | if not user or not chat: 19 | return self.i18n.default_locale 20 | 21 | if chat is not None and chat.type == ChatType.PRIVATE: 22 | obj = await Users.get_user(user=user) 23 | language_code = await Users.get_language(user=user) 24 | else: 25 | obj = await Chats.get_chat(chat=chat) 26 | language_code = await Chats.get_language(chat=chat) 27 | 28 | if not obj: 29 | return self.i18n.default_locale 30 | 31 | if language_code not in self.i18n.available_locales: 32 | return self.i18n.default_locale 33 | 34 | return cast(str, language_code) 35 | -------------------------------------------------------------------------------- /gojira/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | -------------------------------------------------------------------------------- /gojira/utils/aiohttp/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from .anilist import AniListClient 5 | from .jikan import JikanClient 6 | from .tracemoe import TraceMoeClient 7 | 8 | __all__ = ( 9 | "AniListClient", 10 | "JikanClient", 11 | "TraceMoeClient", 12 | ) 13 | -------------------------------------------------------------------------------- /gojira/utils/aiohttp/anilist.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from typing import Any 5 | 6 | from gojira import cache 7 | from gojira.utils.graphql import ( 8 | AIRING_QUERY, 9 | ANIME_GET, 10 | ANIME_SEARCH, 11 | CATEGORIE_QUERY, 12 | CHARACTER_GET, 13 | CHARACTER_POPULAR_QUERY, 14 | CHARACTER_QUERY, 15 | CHARACTER_SEARCH, 16 | DESCRIPTION_QUERY, 17 | MANGA_GET, 18 | MANGA_SEARCH, 19 | POPULAR_QUERY, 20 | STAFF_GET, 21 | STAFF_POPULAR_QUERY, 22 | STAFF_QUERY, 23 | STAFF_SEARCH, 24 | STUDIO_GET, 25 | STUDIO_MEDIA_QUERY, 26 | STUDIO_POPULAR_QUERY, 27 | STUDIO_SEARCH, 28 | STUDIOS_QUERY, 29 | TRAILER_QUERY, 30 | UPCOMING_QUERY, 31 | USER_ANIME_QUERY, 32 | USER_GET, 33 | USER_MANGA_QUERY, 34 | USER_SEARCH, 35 | ) 36 | 37 | from .client import AiohttpBaseClient 38 | 39 | 40 | class AniListClient(AiohttpBaseClient): 41 | def __init__(self) -> None: 42 | self.base_url: str = "https://graphql.anilist.co" 43 | super().__init__(base_url=self.base_url) 44 | 45 | @cache(ttl="1h") 46 | async def search( 47 | self, media: str, query: str 48 | ) -> tuple[int, dict[str, Any]] | tuple[None, None]: 49 | if media.lower() == "character": 50 | return await self._make_request( 51 | "POST", 52 | url="/", 53 | json={ 54 | "query": CHARACTER_SEARCH, 55 | "variables": { 56 | "search": query, 57 | }, 58 | }, 59 | ) 60 | if media.lower() == "anime": 61 | return await self._make_request( 62 | "POST", 63 | url="/", 64 | json={ 65 | "query": ANIME_SEARCH, 66 | "variables": { 67 | "search": query, 68 | }, 69 | }, 70 | ) 71 | if media.lower() == "manga": 72 | return await self._make_request( 73 | "POST", 74 | url="/", 75 | json={ 76 | "query": MANGA_SEARCH, 77 | "variables": { 78 | "search": query, 79 | }, 80 | }, 81 | ) 82 | if media.lower() == "staff": 83 | return await self._make_request( 84 | "POST", 85 | url="/", 86 | json={ 87 | "query": STAFF_SEARCH, 88 | "variables": { 89 | "search": query, 90 | }, 91 | }, 92 | ) 93 | if media.lower() == "studio": 94 | return await self._make_request( 95 | "POST", 96 | url="/", 97 | json={ 98 | "query": STUDIO_SEARCH, 99 | "variables": { 100 | "search": query, 101 | }, 102 | }, 103 | ) 104 | if media.lower() == "user": 105 | return await self._make_request( 106 | "POST", 107 | url="/", 108 | json={ 109 | "query": USER_SEARCH, 110 | "variables": { 111 | "search": query, 112 | }, 113 | }, 114 | ) 115 | return None, None 116 | 117 | @cache(ttl="1h") 118 | async def get( 119 | self, media: str, media_id: int, mal: bool = False 120 | ) -> tuple[int, dict[str, Any]] | tuple[None, None]: 121 | if media.lower() == "character": 122 | return await self._make_request( 123 | "POST", 124 | url="/", 125 | json={ 126 | "query": CHARACTER_GET, 127 | "variables": { 128 | "id": media_id, 129 | }, 130 | }, 131 | ) 132 | if media.lower() == "anime": 133 | var = "id" if not mal else "idMal" 134 | return await self._make_request( 135 | "POST", 136 | url="/", 137 | json={ 138 | "query": ANIME_GET, 139 | "variables": { 140 | var: media_id, 141 | }, 142 | }, 143 | ) 144 | if media.lower() == "manga": 145 | return await self._make_request( 146 | "POST", 147 | url="/", 148 | json={ 149 | "query": MANGA_GET, 150 | "variables": { 151 | "id": media_id, 152 | }, 153 | }, 154 | ) 155 | if media.lower() == "staff": 156 | return await self._make_request( 157 | "POST", 158 | url="/", 159 | json={ 160 | "query": STAFF_GET, 161 | "variables": { 162 | "id": media_id, 163 | }, 164 | }, 165 | ) 166 | if media.lower() == "studio": 167 | return await self._make_request( 168 | "POST", 169 | url="/", 170 | json={ 171 | "query": STUDIO_GET, 172 | "variables": { 173 | "id": media_id, 174 | }, 175 | }, 176 | ) 177 | if media.lower() == "user": 178 | return await self._make_request( 179 | "POST", 180 | url="/", 181 | json={ 182 | "query": USER_GET, 183 | "variables": { 184 | "id": media_id, 185 | }, 186 | }, 187 | ) 188 | return None, None 189 | 190 | @cache(ttl="1h") 191 | async def get_adesc(self, media: str, media_id: int) -> tuple[int, dict[str, Any]]: 192 | return await self._make_request( 193 | "POST", 194 | url="/", 195 | json={ 196 | "query": DESCRIPTION_QUERY, 197 | "variables": { 198 | "id": media_id, 199 | "media": media.upper(), 200 | }, 201 | }, 202 | ) 203 | 204 | @cache(ttl="1h") 205 | async def get_achars(self, media: str, media_id: int) -> tuple[int, dict[str, Any]]: 206 | return await self._make_request( 207 | "POST", 208 | url="/", 209 | json={ 210 | "query": CHARACTER_QUERY, 211 | "variables": { 212 | "id": media_id, 213 | "media": media.upper(), 214 | }, 215 | }, 216 | ) 217 | 218 | @cache(ttl="1h") 219 | async def get_astaff(self, media: str, media_id: int) -> tuple[int, dict[str, Any]]: 220 | return await self._make_request( 221 | "POST", 222 | url="/", 223 | json={ 224 | "query": STAFF_QUERY, 225 | "variables": { 226 | "id": media_id, 227 | "media": media.upper(), 228 | }, 229 | }, 230 | ) 231 | 232 | @cache(ttl="1h") 233 | async def get_airing(self, anime_id: int) -> tuple[int, dict[str, Any]]: 234 | return await self._make_request( 235 | "POST", 236 | url="/", 237 | json={ 238 | "query": AIRING_QUERY, 239 | "variables": { 240 | "id": anime_id, 241 | }, 242 | }, 243 | ) 244 | 245 | @cache(ttl="1h") 246 | async def get_astudios(self, media: str, media_id: int) -> tuple[int, dict[str, Any]]: 247 | return await self._make_request( 248 | "POST", 249 | url="/", 250 | json={ 251 | "query": STUDIOS_QUERY, 252 | "variables": { 253 | "id": media_id, 254 | "media": media.upper(), 255 | }, 256 | }, 257 | ) 258 | 259 | @cache(ttl="1h") 260 | async def get_atrailer(self, media: str, media_id: int) -> tuple[int, dict[str, Any]]: 261 | return await self._make_request( 262 | "POST", 263 | url="/", 264 | json={ 265 | "query": TRAILER_QUERY, 266 | "variables": { 267 | "id": media_id, 268 | "media": media.upper(), 269 | }, 270 | }, 271 | ) 272 | 273 | @cache(ttl="1h") 274 | async def upcoming(self, media: str) -> tuple[int, dict[str, Any]]: 275 | return await self._make_request( 276 | "POST", 277 | url="/", 278 | json={ 279 | "query": UPCOMING_QUERY, 280 | "variables": { 281 | "per_page": 50, 282 | "media": media.upper(), 283 | }, 284 | }, 285 | ) 286 | 287 | @cache(ttl="1h") 288 | async def popular(self, media: str) -> tuple[int, dict[str, Any]]: 289 | if media.lower() == "character": 290 | return await self._make_request( 291 | "POST", 292 | url="/", 293 | json={ 294 | "query": CHARACTER_POPULAR_QUERY, 295 | }, 296 | ) 297 | 298 | if media.lower() == "staff": 299 | return await self._make_request( 300 | "POST", 301 | url="/", 302 | json={ 303 | "query": STAFF_POPULAR_QUERY, 304 | }, 305 | ) 306 | 307 | if media.lower() == "studio": 308 | return await self._make_request( 309 | "POST", 310 | url="/", 311 | json={ 312 | "query": STUDIO_POPULAR_QUERY, 313 | }, 314 | ) 315 | 316 | return await self._make_request( 317 | "POST", 318 | url="/", 319 | json={ 320 | "query": POPULAR_QUERY, 321 | "variables": { 322 | "media": media.upper(), 323 | }, 324 | }, 325 | ) 326 | 327 | @cache(ttl="1h") 328 | async def categories( 329 | self, media: str, page: int, categorie: str 330 | ) -> tuple[int, dict[str, Any]]: 331 | return await self._make_request( 332 | "POST", 333 | url="/", 334 | json={ 335 | "query": CATEGORIE_QUERY, 336 | "variables": { 337 | "page": page, 338 | "genre": categorie, 339 | "media": media.upper(), 340 | }, 341 | }, 342 | ) 343 | 344 | @cache(ttl="1h") 345 | async def get_studio_media(self, studio_id: int) -> tuple[int, dict[str, Any]]: 346 | return await self._make_request( 347 | "POST", 348 | url="/", 349 | json={ 350 | "query": STUDIO_MEDIA_QUERY, 351 | "variables": { 352 | "id": studio_id, 353 | }, 354 | }, 355 | ) 356 | 357 | @cache(ttl="1h") 358 | async def get_user_stat(self, user_id: int, stat_type: str) -> tuple[int, dict[str, Any]]: 359 | return await self._make_request( 360 | "POST", 361 | url="/", 362 | json={ 363 | "query": USER_ANIME_QUERY if stat_type.lower() == "anime" else USER_MANGA_QUERY, 364 | "variables": { 365 | "id": user_id, 366 | }, 367 | }, 368 | ) 369 | -------------------------------------------------------------------------------- /gojira/utils/aiohttp/client.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import asyncio 5 | import ssl 6 | from collections.abc import Callable 7 | from typing import Any 8 | 9 | import backoff 10 | import orjson 11 | from aiohttp import ClientError, ClientSession, TCPConnector 12 | from yarl import URL 13 | 14 | from gojira.utils.logging import log 15 | 16 | _JsonLoads = Callable[..., Any] 17 | _JsonDumps = Callable[..., str] 18 | 19 | 20 | class AiohttpBaseClient: 21 | def __init__(self, base_url: str | URL) -> None: 22 | self._base_url = base_url 23 | self._session: ClientSession | None = None 24 | self.json_loads: _JsonLoads = orjson.loads 25 | self.json_dumps: _JsonDumps = lambda obj: orjson.dumps(obj).decode() 26 | 27 | async def _get_session(self) -> ClientSession: 28 | if self._session is None: 29 | ssl_context = ssl.SSLContext() 30 | connector = TCPConnector(ssl_context=ssl_context) 31 | self._session = ClientSession( 32 | base_url=self._base_url, 33 | connector=connector, 34 | json_serialize=self.json_dumps, 35 | ) 36 | 37 | return self._session 38 | 39 | @backoff.on_exception(backoff.expo, ClientError, max_tries=2) 40 | async def _make_request( 41 | self, 42 | method: str, 43 | url: str | URL, 44 | params: dict | None = None, 45 | json: dict | None = None, 46 | data: dict | None = None, 47 | ) -> tuple[int, dict[str, Any]]: 48 | session = await self._get_session() 49 | 50 | log.debug( 51 | "AIOHTTP: Making request...", 52 | request=method, 53 | url=url, 54 | json=json, 55 | params=params, 56 | ) 57 | async with session.request(method, url, params=params, json=json, data=data) as response: 58 | status = response.status 59 | result = await response.json(loads=self.json_loads) 60 | 61 | log.debug( 62 | "AIOHTTP: Got response.", 63 | response=method, 64 | url=url, 65 | status=status, 66 | result=result, 67 | ) 68 | return status, result 69 | 70 | async def close(self) -> None: 71 | if not self._session: 72 | log.debug("There's not session to close.") 73 | return 74 | 75 | if self._session.closed: 76 | log.debug("Session already closed.") 77 | return 78 | 79 | await self._session.close() 80 | log.debug("Session successfully closed.") 81 | 82 | # Wait 250 ms for the underlying SSL connections to close 83 | # https://docs.aiohttp.org/en/stable/client_advanced.html#graceful-shutdown 84 | await asyncio.sleep(0.250) 85 | -------------------------------------------------------------------------------- /gojira/utils/aiohttp/jikan.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from typing import Any 5 | 6 | from gojira import cache 7 | 8 | from .client import AiohttpBaseClient 9 | 10 | 11 | class JikanClient(AiohttpBaseClient): 12 | def __init__(self) -> None: 13 | self.base_url: str = "https://api.jikan.moe/" 14 | super().__init__(base_url=self.base_url) 15 | 16 | @cache(ttl="1d") 17 | async def schedules(self, day: str | None = None) -> tuple[int, dict[str, Any]]: 18 | return await self._make_request("GET", url=f"/v4/schedules/{day or ""}") 19 | -------------------------------------------------------------------------------- /gojira/utils/aiohttp/tracemoe.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from typing import Any, BinaryIO 5 | 6 | from gojira import cache 7 | 8 | from .client import AiohttpBaseClient 9 | 10 | 11 | class TraceMoeClient(AiohttpBaseClient): 12 | def __init__(self) -> None: 13 | self.base_url: str = "https://api.trace.moe" 14 | super().__init__(base_url=self.base_url) 15 | 16 | @cache(ttl="1h") 17 | async def search(self, file: bytes | BinaryIO) -> tuple[int, dict[str, Any]]: 18 | return await self._make_request( 19 | method="POST", 20 | url="/search?anilistInfo&cutBorders", 21 | data={"image": file}, 22 | ) 23 | -------------------------------------------------------------------------------- /gojira/utils/callback_data.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram.filters.callback_data import CallbackData 5 | 6 | 7 | class LanguageCallback(CallbackData, prefix="setlang"): 8 | lang: str 9 | chat: str 10 | 11 | 12 | class StartCallback(CallbackData, prefix="start"): 13 | menu: str 14 | 15 | 16 | class AnimeCallback(CallbackData, prefix="anime"): 17 | query: int | str 18 | user_id: int | None = None 19 | is_search: bool = False 20 | 21 | 22 | class AnimeDescCallback(CallbackData, prefix="anime_description"): 23 | anime_id: int 24 | user_id: int 25 | page: int = 0 26 | 27 | 28 | class AnimeMoreCallback(CallbackData, prefix="anime_more"): 29 | anime_id: int 30 | user_id: int 31 | 32 | 33 | class AnimeCharCallback(CallbackData, prefix="anime_character"): 34 | anime_id: int 35 | user_id: int 36 | page: int = 0 37 | 38 | 39 | class AnimeStaffCallback(CallbackData, prefix="anime_staff"): 40 | anime_id: int 41 | user_id: int 42 | page: int = 0 43 | 44 | 45 | class AnimeAiringCallback(CallbackData, prefix="anime_airing"): 46 | query: int | str 47 | user_id: int | None = None 48 | is_search: bool = False 49 | 50 | 51 | class AnimeStudioCallback(CallbackData, prefix="anime_studio"): 52 | anime_id: int 53 | user_id: int 54 | page: int = 0 55 | 56 | 57 | class AnimeUpcomingCallback(CallbackData, prefix="anime_upcoming"): 58 | user_id: int 59 | page: int 60 | 61 | 62 | class AnimePopuCallback(CallbackData, prefix="anime_popular"): 63 | page: int 64 | 65 | 66 | class AnimeCategCallback(CallbackData, prefix="anime_categories"): 67 | page: int 68 | 69 | 70 | class AnimeGCategCallback(CallbackData, prefix="anime_categorie"): 71 | page: int 72 | categorie: str 73 | 74 | 75 | class MangaCallback(CallbackData, prefix="manga"): 76 | query: int | str 77 | user_id: int | None = None 78 | is_search: bool = False 79 | 80 | 81 | class MangaMoreCallback(CallbackData, prefix="manga_more"): 82 | manga_id: int 83 | user_id: int 84 | 85 | 86 | class MangaDescCallback(CallbackData, prefix="manga_description"): 87 | manga_id: int 88 | user_id: int 89 | page: int = 0 90 | 91 | 92 | class MangaCharCallback(CallbackData, prefix="manga_character"): 93 | manga_id: int 94 | user_id: int 95 | page: int = 0 96 | 97 | 98 | class MangaStaffCallback(CallbackData, prefix="manga_staff"): 99 | manga_id: int 100 | user_id: int 101 | page: int = 0 102 | 103 | 104 | class MangaUpcomingCallback(CallbackData, prefix="manga_upcoming"): 105 | user_id: int 106 | page: int 107 | 108 | 109 | class MangaPopuCallback(CallbackData, prefix="manga_popular"): 110 | page: int 111 | 112 | 113 | class MangaCategCallback(CallbackData, prefix="manga_categories"): 114 | page: int 115 | 116 | 117 | class MangaGCategCallback(CallbackData, prefix="manga_categorie"): 118 | page: int 119 | categorie: str 120 | 121 | 122 | class CharacterCallback(CallbackData, prefix="character"): 123 | query: int | str 124 | user_id: int | None = None 125 | is_search: bool = False 126 | 127 | 128 | class CharacterPopuCallback(CallbackData, prefix="character_popular"): 129 | page: int 130 | 131 | 132 | class StaffCallback(CallbackData, prefix="staff"): 133 | query: int | str 134 | user_id: int | None = None 135 | is_search: bool = False 136 | 137 | 138 | class StaffPopuCallback(CallbackData, prefix="staff_popular"): 139 | page: int 140 | 141 | 142 | class StudioCallback(CallbackData, prefix="studio"): 143 | query: int | str 144 | user_id: int | None = None 145 | is_search: bool = False 146 | 147 | 148 | class StudioMediaCallback(CallbackData, prefix="studio_media"): 149 | studio_name: str 150 | studio_id: int 151 | user_id: int 152 | page: int = 0 153 | 154 | 155 | class StudioPopuCallback(CallbackData, prefix="studio_popular"): 156 | page: int 157 | 158 | 159 | class UserCallback(CallbackData, prefix="user"): 160 | query: int | str 161 | user_id: int | None = None 162 | is_search: bool = False 163 | 164 | 165 | class UserStatsCallback(CallbackData, prefix="user_stats"): 166 | user_id: int 167 | stat_type: str 168 | 169 | 170 | class UpcomingCallback(CallbackData, prefix="upcoming"): 171 | user_id: int 172 | 173 | 174 | class ScheduleCallback(CallbackData, prefix="schedule"): 175 | user_id: int 176 | day: int 177 | -------------------------------------------------------------------------------- /gojira/utils/command_list.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from contextlib import suppress 5 | 6 | from aiogram import Bot 7 | from aiogram.exceptions import TelegramRetryAfter 8 | from aiogram.types import ( 9 | BotCommand, 10 | BotCommandScopeAllGroupChats, 11 | BotCommandScopeAllPrivateChats, 12 | ) 13 | from aiogram.utils.i18n import I18n 14 | 15 | 16 | async def set_ui_commands(bot: Bot, i18n: I18n): 17 | _ = i18n.gettext 18 | 19 | with suppress(TelegramRetryAfter): 20 | await bot.delete_my_commands() 21 | for lang in (*i18n.available_locales, i18n.default_locale): 22 | all_chats_commands: list[BotCommand] = [ 23 | BotCommand( 24 | command="anime", 25 | description=_("Get anime informations.", locale=lang), 26 | ), 27 | BotCommand( 28 | command="manga", 29 | description=_("Get manga informations.", locale=lang), 30 | ), 31 | BotCommand( 32 | command="character", 33 | description=_("Get character informations.", locale=lang), 34 | ), 35 | BotCommand( 36 | command="staff", 37 | description=_("Get staff informations.", locale=lang), 38 | ), 39 | BotCommand( 40 | command="studio", 41 | description=_("Get studio informations.", locale=lang), 42 | ), 43 | BotCommand( 44 | command="scan", 45 | description=_("Try to identify the source anime of a media", locale=lang), 46 | ), 47 | BotCommand( 48 | command="user", 49 | description=_("Get AniList user informations.", locale=lang), 50 | ), 51 | BotCommand( 52 | command="airing", 53 | description=_("Get anime airing informations.", locale=lang), 54 | ), 55 | BotCommand( 56 | command="schedule", 57 | description=_("Get anime schedules.", locale=lang), 58 | ), 59 | BotCommand( 60 | command="language", 61 | description=_("Change bot language.", locale=lang), 62 | ), 63 | BotCommand( 64 | command="about", 65 | description=_("About the bot.", locale=lang), 66 | ), 67 | ] 68 | 69 | user_commands: list[BotCommand] = [ 70 | BotCommand(command="start", description=_("Start the bot.", locale=lang)), 71 | BotCommand(command="help", description=_("Get help.", locale=lang)), 72 | *all_chats_commands, 73 | ] 74 | 75 | group_commands: list[BotCommand] = [ 76 | BotCommand(command="upcoming", description=_("Get upcoming media.", locale=lang)), 77 | *all_chats_commands, 78 | ] 79 | 80 | await bot.set_my_commands( 81 | commands=user_commands, 82 | scope=BotCommandScopeAllPrivateChats(), 83 | language_code=lang.split("_")[0].lower() if "_" in lang else lang, 84 | ) 85 | 86 | await bot.set_my_commands( 87 | commands=group_commands, 88 | scope=BotCommandScopeAllGroupChats(), 89 | language_code=lang.split("_")[0].lower() if "_" in lang else lang, 90 | ) 91 | -------------------------------------------------------------------------------- /gojira/utils/graphql.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | ANIME_SEARCH: str = """ 5 | query($id: Int, $search: String, $page: Int = 1, $per_page: Int = 10) { 6 | Page(page: $page, perPage: $per_page) { 7 | pageInfo { 8 | total 9 | currentPage 10 | lastPage 11 | hasNextPage 12 | } 13 | media(id: $id, search: $search, type: ANIME, sort: POPULARITY_DESC) { 14 | id 15 | title { 16 | romaji 17 | english 18 | native 19 | } 20 | siteUrl 21 | } 22 | } 23 | } 24 | """ 25 | 26 | MANGA_SEARCH: str = """ 27 | query($id: Int, $search: String, $page: Int = 1, $per_page: Int = 10) { 28 | Page(page: $page, perPage: $per_page) { 29 | pageInfo { 30 | total 31 | currentPage 32 | lastPage 33 | hasNextPage 34 | } 35 | media(id: $id, search: $search, type: MANGA, sort: POPULARITY_DESC) { 36 | id 37 | title { 38 | romaji 39 | english 40 | native 41 | } 42 | siteUrl 43 | } 44 | } 45 | } 46 | """ 47 | 48 | CHARACTER_SEARCH: str = """ 49 | query($search: String, $page: Int = 1, $per_page: Int = 10) { 50 | Page(page: $page, perPage: $per_page) { 51 | pageInfo { 52 | total 53 | currentPage 54 | lastPage 55 | hasNextPage 56 | } 57 | characters(search: $search) { 58 | id 59 | name { 60 | full 61 | } 62 | } 63 | } 64 | } 65 | """ 66 | 67 | STAFF_SEARCH: str = """ 68 | query($search: String, $page: Int = 1, $per_page: Int = 10) { 69 | Page(page: $page, perPage: $per_page) { 70 | pageInfo { 71 | total 72 | currentPage 73 | lastPage 74 | hasNextPage 75 | } 76 | staff(search: $search) { 77 | id 78 | name { 79 | full 80 | } 81 | } 82 | } 83 | } 84 | """ 85 | 86 | 87 | STUDIO_SEARCH: str = """ 88 | query($search: String) { 89 | Page(page: 1, perPage: 10) { 90 | studios(search: $search, sort: SEARCH_MATCH) { 91 | id 92 | name 93 | } 94 | } 95 | } 96 | """ 97 | 98 | 99 | USER_SEARCH: str = """ 100 | query($search: String, $page: Int = 1, $per_page: Int = 10) { 101 | Page(page: $page, perPage: $per_page) { 102 | pageInfo { 103 | total 104 | currentPage 105 | lastPage 106 | hasNextPage 107 | } 108 | users(search: $search) { 109 | id 110 | name 111 | } 112 | } 113 | } 114 | """ 115 | 116 | 117 | ANIME_GET: str = """ 118 | query($id: Int, $idMal: Int) { 119 | Page(page: 1, perPage: 1) { 120 | media(id: $id, idMal: $idMal, type: ANIME) { 121 | id 122 | idMal 123 | title { 124 | romaji 125 | english 126 | native 127 | } 128 | episodes 129 | description 130 | format 131 | status 132 | duration 133 | genres 134 | studios { 135 | nodes { 136 | name 137 | isAnimationStudio 138 | } 139 | } 140 | startDate { 141 | year 142 | month 143 | day 144 | } 145 | endDate { 146 | year 147 | month 148 | day 149 | } 150 | season 151 | seasonYear 152 | source 153 | averageScore 154 | bannerImage 155 | coverImage { 156 | medium 157 | large 158 | extraLarge 159 | } 160 | relations { 161 | edges { 162 | node { 163 | id 164 | } 165 | relationType(version: 2) 166 | } 167 | } 168 | } 169 | } 170 | } 171 | """ 172 | 173 | 174 | MANGA_GET: str = """ 175 | query($id: Int) { 176 | Page(page: 1, perPage: 1) { 177 | media(id: $id, type: MANGA) { 178 | id 179 | idMal 180 | title { 181 | romaji 182 | english 183 | native 184 | } 185 | chapters 186 | volumes 187 | description 188 | format 189 | status 190 | genres 191 | startDate { 192 | year 193 | month 194 | day 195 | } 196 | endDate { 197 | year 198 | month 199 | day 200 | } 201 | source 202 | averageScore 203 | bannerImage 204 | coverImage { 205 | medium 206 | large 207 | extraLarge 208 | } 209 | relations { 210 | edges { 211 | node { 212 | id 213 | } 214 | relationType(version: 2) 215 | } 216 | } 217 | } 218 | } 219 | } 220 | """ 221 | 222 | CHARACTER_GET: str = """ 223 | query($id: Int) { 224 | Page(page: 1, perPage: 1) { 225 | characters(id: $id) { 226 | id 227 | name { 228 | full 229 | } 230 | image { 231 | large 232 | medium 233 | } 234 | description 235 | siteUrl 236 | favourites 237 | } 238 | } 239 | } 240 | """ 241 | 242 | STAFF_GET: str = """ 243 | query($id: Int) { 244 | Page(page: 1, perPage: 1) { 245 | staff(id: $id) { 246 | id 247 | name { 248 | full 249 | } 250 | image { 251 | large 252 | medium 253 | } 254 | description 255 | siteUrl 256 | favourites 257 | language 258 | } 259 | } 260 | } 261 | """ 262 | 263 | 264 | STUDIO_GET: str = """ 265 | query($id: Int) { 266 | Studio(id: $id) { 267 | id 268 | name 269 | siteUrl 270 | favourites 271 | isAnimationStudio 272 | } 273 | } 274 | """ 275 | 276 | 277 | USER_GET: str = """ 278 | query($id: Int) { 279 | User(id: $id) { 280 | id 281 | name 282 | about 283 | siteUrl 284 | donatorTier 285 | createdAt 286 | updatedAt 287 | } 288 | } 289 | """ 290 | 291 | 292 | TRAILER_QUERY: str = """ 293 | query($id: Int, $media: MediaType) { 294 | Page(page: 1, perPage: 1) { 295 | media(id: $id, type: $media) { 296 | trailer { 297 | id 298 | site 299 | thumbnail 300 | } 301 | siteUrl 302 | } 303 | } 304 | } 305 | """ 306 | 307 | DESCRIPTION_QUERY: str = """ 308 | query($id: Int, $media: MediaType) { 309 | Page(page: 1, perPage: 1) { 310 | media(id: $id, type: $media) { 311 | description 312 | } 313 | } 314 | } 315 | """ 316 | 317 | CHARACTER_QUERY: str = """ 318 | query($id: Int, $media: MediaType) { 319 | Page(page: 1, perPage: 1) { 320 | media(id: $id, type: $media) { 321 | characters(sort: FAVOURITES_DESC) { 322 | edges { 323 | node { 324 | name { 325 | first 326 | full 327 | native 328 | last 329 | } 330 | id 331 | } 332 | role 333 | } 334 | } 335 | } 336 | } 337 | } 338 | """ 339 | 340 | STAFF_QUERY: str = """ 341 | query($id: Int, $media: MediaType) { 342 | Page(page: 1, perPage: 1) { 343 | media(id: $id, type: $media) { 344 | staff(sort: FAVOURITES_DESC) { 345 | edges { 346 | node { 347 | name { 348 | full 349 | } 350 | id 351 | } 352 | role 353 | } 354 | } 355 | } 356 | } 357 | } 358 | """ 359 | 360 | AIRING_QUERY: str = """ 361 | query($id: Int, $media: MediaType) { 362 | Page(page: 1, perPage: 1) { 363 | media(id: $id, type: $media) { 364 | nextAiringEpisode { 365 | timeUntilAiring 366 | episode 367 | } 368 | externalLinks { 369 | id 370 | url 371 | site 372 | type 373 | } 374 | episodes 375 | status 376 | } 377 | } 378 | } 379 | """ 380 | 381 | STUDIOS_QUERY: str = """ 382 | query($id: Int, $media: MediaType) { 383 | Page(page: 1, perPage: 1) { 384 | media(id: $id, type: $media) { 385 | studios { 386 | nodes { 387 | id 388 | name 389 | isAnimationStudio 390 | } 391 | } 392 | } 393 | } 394 | } 395 | """ 396 | 397 | UPCOMING_QUERY: str = """ 398 | query($per_page: Int $media: MediaType) { 399 | Page(page: 1, perPage: $per_page) { 400 | media(type: $media, sort: POPULARITY_DESC, status: NOT_YET_RELEASED) { 401 | id 402 | title { 403 | romaji 404 | english 405 | native 406 | } 407 | siteUrl 408 | } 409 | } 410 | } 411 | """ 412 | 413 | POPULAR_QUERY: str = """ 414 | query($media: MediaType) { 415 | Page(page: 1, perPage: 50) { 416 | media(type: $media, sort: POPULARITY_DESC) { 417 | id 418 | title { 419 | romaji 420 | english 421 | native 422 | } 423 | siteUrl 424 | } 425 | } 426 | } 427 | """ 428 | 429 | CHARACTER_POPULAR_QUERY: str = """ 430 | query($per_page: Int = 50) { 431 | Page(page: 1, perPage: $per_page) { 432 | characters(sort: FAVOURITES_DESC) { 433 | id 434 | name { 435 | full 436 | } 437 | siteUrl 438 | } 439 | } 440 | } 441 | """ 442 | 443 | STAFF_POPULAR_QUERY: str = """ 444 | query($per_page: Int = 50) { 445 | Page(page: 1, perPage: $per_page) { 446 | staff(sort: FAVOURITES_DESC) { 447 | id 448 | name { 449 | full 450 | } 451 | siteUrl 452 | } 453 | } 454 | } 455 | """ 456 | 457 | STUDIO_POPULAR_QUERY: str = """ 458 | query($per_page: Int) { 459 | Page(page: 1, perPage: $per_page) { 460 | studios(sort: FAVOURITES_DESC) { 461 | id 462 | name 463 | siteUrl 464 | } 465 | } 466 | } 467 | """ 468 | 469 | 470 | CATEGORIE_QUERY: str = """ 471 | query($genre: String, $page: Int, $media: MediaType) { 472 | Page(page: $page, perPage: 50) { 473 | media(type: $media, genre: $genre, sort: POPULARITY_DESC) { 474 | id 475 | title { 476 | romaji 477 | } 478 | } 479 | } 480 | } 481 | """ 482 | 483 | 484 | STUDIO_MEDIA_QUERY: str = """ 485 | query($id: Int) { 486 | Studio(id: $id) { 487 | media(sort: POPULARITY_DESC) { 488 | nodes { 489 | id 490 | title { 491 | romaji 492 | english 493 | native 494 | } 495 | type 496 | } 497 | } 498 | } 499 | } 500 | """ 501 | 502 | 503 | USER_ANIME_QUERY: str = """ 504 | query($id: Int) { 505 | User(id: $id) { 506 | statistics { 507 | anime { 508 | count 509 | meanScore 510 | standardDeviation 511 | minutesWatched 512 | episodesWatched 513 | } 514 | } 515 | } 516 | } 517 | """ 518 | 519 | 520 | USER_MANGA_QUERY: str = """ 521 | query($id: Int) { 522 | User(id: $id) { 523 | statistics { 524 | manga { 525 | count 526 | meanScore 527 | standardDeviation 528 | chaptersRead 529 | volumesRead 530 | } 531 | } 532 | } 533 | } 534 | """ 535 | -------------------------------------------------------------------------------- /gojira/utils/keyboard.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from __future__ import annotations 5 | 6 | import math 7 | from itertools import islice 8 | from typing import TYPE_CHECKING, Any 9 | 10 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Callable, Iterator, Sequence 14 | 15 | 16 | class Pagination: 17 | __slots__ = ("item_data", "item_title", "objects", "page_data") 18 | 19 | def __init__( 20 | self, 21 | objects: list[Any], 22 | page_data: Callable[[int], str], 23 | item_data: Callable[[Any, int], str], 24 | item_title: Callable[[Any, int], str], 25 | ) -> None: 26 | self.objects = objects 27 | self.page_data = page_data 28 | self.item_data = item_data 29 | self.item_title = item_title 30 | 31 | @staticmethod 32 | def chunk_list(lst: Sequence[Any], size: int) -> Iterator[Sequence[Any]]: 33 | it = iter(lst) 34 | for first in it: 35 | yield [first, *list(islice(it, size - 1))] 36 | 37 | def create(self, page: int, lines: int = 5, columns: int = 1) -> InlineKeyboardMarkup: 38 | items_per_page = lines * columns 39 | page = max(1, page) 40 | offset = (page - 1) * items_per_page 41 | current_page_items = self.objects[offset : offset + items_per_page] 42 | 43 | total_items = len(self.objects) 44 | last_page = math.ceil(total_items / items_per_page) 45 | pages_range = range(1, last_page + 1) 46 | nav_buttons = self._generate_navigation_buttons(page, last_page, pages_range) 47 | 48 | buttons = [ 49 | (self.item_title(item, page), self.item_data(item, page)) 50 | for item in current_page_items 51 | ] 52 | kb_lines = list(self.chunk_list(buttons, columns)) 53 | 54 | if last_page > 1: 55 | kb_lines.append(nav_buttons) 56 | 57 | return InlineKeyboardMarkup( 58 | inline_keyboard=[ 59 | [InlineKeyboardButton(text=str(text), callback_data=data) for text, data in line] 60 | for line in kb_lines 61 | ] 62 | ) 63 | 64 | def _generate_navigation_buttons( 65 | self, page: int, last_page: int, pages_range: range 66 | ) -> list[tuple[str, str]]: 67 | if last_page <= 5: 68 | return [(str(n) if n != page else f"· {n} ·", self.page_data(n)) for n in pages_range] 69 | if page <= 3: 70 | return self._generate_first_section_navigation(page, last_page, pages_range) 71 | if page >= last_page - 2: 72 | return self._generate_last_section_navigation(page, last_page, pages_range) 73 | return self._generate_middle_section_navigation(page, last_page) 74 | 75 | def _generate_first_section_navigation( 76 | self, page: int, last_page: int, pages_range: range 77 | ) -> list[tuple[str, str]]: 78 | nav = [(str(n) if n != page else f"· {n} ·", self.page_data(n)) for n in pages_range[:3]] 79 | 80 | if last_page >= 4: 81 | nav.append(("4 ›" if last_page > 5 else "4", self.page_data(4))) 82 | 83 | if last_page > 4: 84 | nav.append(( 85 | f"{last_page} »" if last_page > 5 else str(last_page), 86 | self.page_data(last_page), 87 | )) 88 | 89 | return nav 90 | 91 | def _generate_last_section_navigation( 92 | self, page: int, last_page: int, pages_range: range 93 | ) -> list[tuple[str, str]]: 94 | nav = [("« 1" if last_page > 5 else "1", self.page_data(1))] 95 | if last_page > 5: 96 | nav.append((f"‹ {last_page - 3}", self.page_data(last_page - 3))) 97 | 98 | nav.extend( 99 | (str(n) if n != page else f"· {n} ·", self.page_data(n)) for n in pages_range[-3:] 100 | ) 101 | 102 | return nav 103 | 104 | def _generate_middle_section_navigation( 105 | self, page: int, last_page: int 106 | ) -> list[tuple[str, str]]: 107 | return [ 108 | ("« 1", self.page_data(1)), 109 | (f"‹ {page - 1}", self.page_data(page - 1)), 110 | (f"· {page} ·", "noop"), 111 | (f"{page + 1} ›", self.page_data(page + 1)), 112 | (f"{last_page} »", self.page_data(last_page)), 113 | ] 114 | -------------------------------------------------------------------------------- /gojira/utils/language.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | from aiogram.enums import ChatType 5 | from aiogram.types import CallbackQuery, InaccessibleMessage, Message 6 | from aiogram.utils.i18n import gettext as _ 7 | 8 | from gojira.database import Chats, Users 9 | 10 | 11 | async def get_chat_language( 12 | union: Message | CallbackQuery, 13 | ) -> tuple[str | None, list | str | None]: 14 | is_callback = isinstance(union, CallbackQuery) 15 | message = union.message if is_callback else union 16 | if not message or not union.from_user: 17 | return None, None 18 | 19 | if isinstance(message, InaccessibleMessage): 20 | return None, None 21 | 22 | if not message or not message.from_user: 23 | return message.chat.type, None 24 | 25 | language_code = None 26 | 27 | if message.chat.type == ChatType.PRIVATE: 28 | user = union.from_user 29 | language_code = await Users.get_language(user=user) 30 | 31 | if message.chat.type in {ChatType.GROUP, ChatType.SUPERGROUP}: 32 | chat = message.chat 33 | language_code = await Chats.get_language(chat=chat) 34 | 35 | return message.chat.type, language_code 36 | 37 | 38 | def i18n_anilist_status(status: str) -> str: 39 | status_dict = { 40 | "FINISHED": _("Finished"), 41 | "RELEASING": _("Releasing"), 42 | "NOT_YET_RELEASED": _("Not yet released"), 43 | "CANCELLED": _("Cancelled"), 44 | "HIATUS": _("Hiatus"), 45 | } 46 | return status_dict.get(status, "") 47 | 48 | 49 | def i18n_anilist_source(source: str) -> str: 50 | source_dict = { 51 | "ORIGINAL": _("Original"), 52 | "MANGA": _("Manga"), 53 | "LIGHT_NOVEL": _("Light Novel"), 54 | "VISUAL_NOVEL": _("Visual Novel"), 55 | "VIDEO_GAME": _("Video Game"), 56 | "OTHER": _("Other"), 57 | "NOVEL": _("Novel"), 58 | "DOUJINSHI": _("Doujinshi"), 59 | "ANIME": _("Anime"), 60 | "WEB_NOVEL": _("Web Novel"), 61 | "LIVE_ACTION": _("Live Action"), 62 | "GAME": _("Game"), 63 | "COMIC": _("Comic"), 64 | "MULTIMEDIA_PROJECT": _("Multimedia Project"), 65 | "PICTURE_BOOK": _("Picture Book"), 66 | } 67 | return source_dict.get(source, "") 68 | 69 | 70 | def i18n_anilist_format(anilist_format: str) -> str: 71 | format_dict = { 72 | "TV": _("TV"), 73 | "TV_SHORT": _("TV Short"), 74 | "MOVIE": _("Movie"), 75 | "SPECIAL": _("Special"), 76 | "OVA": _("OVA"), 77 | "ONA": _("ONA"), 78 | "MUSIC": _("Music"), 79 | "MANGA": _("Manga"), 80 | "NOVEL": _("Novel"), 81 | "ONE_SHOT": _("One Shot"), 82 | } 83 | return format_dict.get(anilist_format, "") 84 | 85 | 86 | def i18n_anilist_season(season: str) -> str: 87 | season_dict = { 88 | "WINTER": _("Winter"), 89 | "SPRING": _("Spring"), 90 | "SUMMER": _("Summer"), 91 | "FALL": _("Fall"), 92 | } 93 | return season_dict.get(season, "") 94 | -------------------------------------------------------------------------------- /gojira/utils/logging.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import sys 5 | 6 | import picologging 7 | import structlog 8 | 9 | structlog.configure( 10 | cache_logger_on_first_use=True, 11 | wrapper_class=structlog.make_filtering_bound_logger(picologging.INFO), 12 | processors=[ 13 | structlog.contextvars.merge_contextvars, 14 | structlog.processors.add_log_level, 15 | structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"), 16 | structlog.dev.ConsoleRenderer(exception_formatter=structlog.dev.better_traceback), 17 | ], 18 | ) 19 | log = structlog.wrap_logger(logger=picologging.getLogger()) 20 | 21 | picologging.basicConfig( 22 | format="%(message)s", 23 | stream=sys.stdout, 24 | level=picologging.INFO, 25 | ) 26 | -------------------------------------------------------------------------------- /gojira/utils/systools.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-3-Clause 2 | # Copyright (c) 2023 Hitalo M. 3 | 4 | import asyncio 5 | 6 | 7 | class ShellExceptionError(Exception): 8 | pass 9 | 10 | 11 | def parse_commits(log: str) -> dict: 12 | commits = {} 13 | last_commit = "" 14 | for line in log.splitlines(): 15 | if line.startswith("commit"): 16 | last_commit = line.split()[1] 17 | commits[last_commit] = {} 18 | elif line.startswith(" "): 19 | if "title" in commits[last_commit]: 20 | commits[last_commit]["message"] = line[4:] 21 | else: 22 | commits[last_commit]["title"] = line[4:] 23 | elif ":" in line: 24 | key, value = line.split(": ", 1) 25 | commits[last_commit][key] = value 26 | return commits 27 | 28 | 29 | async def shell_run(command: str) -> str: 30 | process = await asyncio.create_subprocess_shell( 31 | command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE 32 | ) 33 | stdout, stderr = await process.communicate() 34 | 35 | if process.returncode == 0: 36 | return stdout.decode("utf-8").strip() 37 | 38 | msg = ( 39 | f"Command '{command}' exited with {process.returncode}:\n{stderr.decode("utf-8").strip()}" 40 | ) 41 | raise ShellExceptionError(msg) 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "gojira" 3 | version = "1.0.0" 4 | description = "A Telegram bot that retrieves data from the Anilist API, specifically for manga and anime content." 5 | readme = "README.rst" 6 | license = { file = "LICENSE" } 7 | requires-python = ">=3.12" 8 | authors = [{ name = "Hitalo M." }] 9 | dependencies = [ 10 | "aiogram[fast,i18n]>=3.13.1", 11 | "aiohttp[speedups]>=3.10.5", 12 | "aiosqlite>=0.20.0", 13 | "lxml>=5.3.0", 14 | "humanize>=4.10.0", 15 | "meval>=2.5", 16 | "backoff>=2.2.1", 17 | "cashews[redis,speedup]>=7.3.1", 18 | "sentry-sdk>=2.14.0", 19 | "pydantic-settings>=2.5.2", 20 | "aiofile>=3.8.8", 21 | "structlog>=24.4.0", 22 | "orjson>=3.10.7", 23 | "picologging>=0.9.3", 24 | "better-exceptions>=0.3.3", 25 | "babel>=2.13.1", 26 | "uvloop>=0.20.0", 27 | ] 28 | 29 | [build-system] 30 | requires = ["hatchling"] 31 | build-backend = "hatchling.build" 32 | 33 | [tool.rye] 34 | managed = true 35 | dev-dependencies = ["pre-commit>=3.8.0", "ruff>=0.6.6"] 36 | 37 | [tool.hatch.metadata] 38 | allow-direct-references = true 39 | 40 | [project.urls] 41 | Repository = "https://github.com/HitaloM/Gojira/" 42 | 43 | [tool.ruff] 44 | line-length = 99 45 | target-version = "py312" 46 | 47 | [tool.ruff.lint] 48 | ignore = [ 49 | "RUF001", 50 | "RUF002", 51 | "RUF003", 52 | "PLR0911", 53 | "PLR0912", 54 | "PLR0913", 55 | "PLR0914", 56 | "PLR0915", 57 | "PLR0917", 58 | "PLR2004", 59 | "PLW2901", 60 | "PLW1641", 61 | "C901", 62 | ] 63 | select = [ 64 | "ASYNC", # flake8-async 65 | "B", # flake8-bugbear 66 | "C4", # flake8-comprehensions 67 | "C90", # mccabe 68 | "CPY", # flake8-copyright 69 | "DTZ", # flake8-datetimez 70 | "E", # pycodestyle 71 | "EM", # flake8-errmsg 72 | "F", # pyflakes 73 | "FURB", # refurb 74 | "G", # flake8-logging-format 75 | "I", # isort 76 | "N", # pep8-naming 77 | "PERF", # perflint 78 | "PL", # pylint 79 | "PTH", # flake8-use-pathlib 80 | "RET", # flake8-return 81 | "RUF", # ruff 82 | "SIM", # flake8-simplify 83 | "TCH", # flake8-type-checking 84 | "TID", # flake8-tidy-imports 85 | "UP", # pyupgrade 86 | "W", # pycodestyle 87 | ] 88 | preview = true 89 | 90 | [tool.ruff.format] 91 | docstring-code-format = true 92 | preview = true 93 | 94 | [tool.ruff.lint.isort] 95 | known-first-party = ["src"] 96 | 97 | [tool.ruff.lint.flake8-copyright] 98 | author = "Hitalo M" 99 | notice-rgx = "(?i)Copyright \\(C\\) \\d{4}" 100 | 101 | [tool.ruff.lint.flake8-tidy-imports] 102 | ban-relative-imports = "parents" 103 | 104 | [tool.rye.scripts] 105 | extract-locales = { cmd = "pybabel extract --keywords='__ _' --input-dirs=. -o locales/bot.pot" } 106 | compile-locales = { cmd = "pybabel compile -d locales -D bot" } 107 | clean-locales = { cmd = "find . -name '*.mo' -type f -delete" } 108 | -------------------------------------------------------------------------------- /service/README.rst: -------------------------------------------------------------------------------- 1 | ######################## 2 | Gojira - Systemd Service 3 | ######################## 4 | 5 | This is an example of a systemd service that can be used to run the bot in production. 6 | However, you may need to make changes to this service to suit your setup. 7 | -------------------------------------------------------------------------------- /service/gojira.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Gojira Telegram Bot 3 | Requires=redis.service 4 | After=redis.service 5 | 6 | [Service] 7 | User=hitalo 8 | Group=wheel 9 | type=simple 10 | WorkingDirectory=/home/hitalo/Gojira 11 | ExecStart=/usr/bin/rye run python -m gojira 12 | EnvironmentFile=/home/hitalo/Gojira/data/config.env 13 | restart=always 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | --------------------------------------------------------------------------------