├── app ├── __init__.py ├── domain │ ├── __init__.py │ ├── media │ │ ├── __init__.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── stats_type.py │ │ └── dto │ │ │ ├── __init__.py │ │ │ ├── media.py │ │ │ └── stats.py │ └── common │ │ ├── __init__.py │ │ ├── dto │ │ ├── __init__.py │ │ └── base.py │ │ └── models │ │ ├── __init__.py │ │ └── base.py ├── infrastructure │ ├── __init__.py │ ├── database │ │ ├── alembic │ │ │ ├── README │ │ │ ├── script.py.mako │ │ │ ├── versions │ │ │ │ ├── ea4780da43f5_add_unique_constraint_to_view.py │ │ │ │ ├── 53cc2c59da45_edit_column_tg_id_type.py │ │ │ │ └── c410d49a8acb_create_tables.py │ │ │ └── env.py │ │ ├── base.py │ │ ├── __init__.py │ │ ├── repositories │ │ │ ├── repo.py │ │ │ ├── __init__.py │ │ │ ├── views.py │ │ │ ├── source.py │ │ │ ├── user.py │ │ │ ├── uow.py │ │ │ └── media.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── source.py │ │ │ ├── user.py │ │ │ ├── media.py │ │ │ └── view.py │ │ └── database.py │ ├── scheduler │ │ ├── __init__.py │ │ └── media.py │ └── media │ │ ├── base │ │ ├── exceptions.py │ │ ├── __init__.py │ │ ├── typehints.py │ │ ├── schemas │ │ │ ├── __init__.py │ │ │ ├── media.py │ │ │ └── media_genre.py │ │ └── client.py │ │ ├── nekos_fun │ │ ├── __init__.py │ │ └── client.py │ │ ├── nekos_life │ │ ├── __init__.py │ │ └── client.py │ │ ├── waifu_pics │ │ ├── __init__.py │ │ └── client.py │ │ └── __init__.py ├── language_utils │ ├── __init__.py │ └── language.py ├── typehints.py ├── filters │ ├── __init__.py │ ├── nsfw_settings.py │ └── genre.py ├── media_utils │ ├── __init__.py │ ├── genres.py │ └── stats.py ├── constants.py ├── states │ ├── __init__.py │ ├── main_menu.py │ ├── stats.py │ ├── language.py │ └── settings.py ├── middlewares │ ├── __init__.py │ ├── acl.py │ ├── database.py │ └── i18n.py ├── handlers │ ├── __init__.py │ ├── errors.py │ ├── introduction.py │ └── media.py ├── dialogs │ ├── __init__.py │ ├── main_menu.py │ ├── settings.py │ ├── language.py │ └── stats.py ├── config_reader.py ├── logging_config.py └── __main__.py ├── .gitattributes ├── requirements-dev.txt ├── .gitignore ├── tox.ini ├── .env.example ├── .env.dev.example ├── mypy.ini ├── requirements.txt ├── .dockerignore ├── README.MD ├── Dockerfile ├── alembic.ini ├── LICENSE ├── docker-compose.yaml ├── docker-compose-dev.yaml ├── Makefile └── locales ├── bot.pot ├── en └── LC_MESSAGES │ └── bot.po ├── be └── LC_MESSAGES │ └── bot.po ├── ru └── LC_MESSAGES │ └── bot.po └── ua └── LC_MESSAGES └── bot.po /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/media/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/language_utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import DTO 2 | -------------------------------------------------------------------------------- /app/domain/common/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import entity 2 | -------------------------------------------------------------------------------- /app/domain/media/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .stats_type import StatsType 2 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /app/infrastructure/scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | from .media import start_parse_media 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /app/domain/media/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .media import Media 2 | from .stats import Stats 3 | -------------------------------------------------------------------------------- /app/infrastructure/media/base/exceptions.py: -------------------------------------------------------------------------------- 1 | class GenreNotFound(Exception): 2 | ... 3 | -------------------------------------------------------------------------------- /app/typehints.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | I18nGettext = Callable[..., str] 4 | -------------------------------------------------------------------------------- /app/infrastructure/media/base/__init__.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.media.base.client import MediaSource 2 | -------------------------------------------------------------------------------- /app/domain/common/models/base.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | 3 | entity = define(slots=False, kw_only=True) 4 | -------------------------------------------------------------------------------- /app/infrastructure/media/nekos_fun/__init__.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.media.nekos_fun.client import NekosFun 2 | -------------------------------------------------------------------------------- /app/infrastructure/media/base/typehints.py: -------------------------------------------------------------------------------- 1 | MediaUrlType = str 2 | MediaGenreType = str 3 | MediaRawGenreType = str 4 | -------------------------------------------------------------------------------- /app/infrastructure/media/nekos_life/__init__.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.media.nekos_life.client import NekosLife 2 | -------------------------------------------------------------------------------- /app/infrastructure/media/waifu_pics/__init__.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.media.waifu_pics.client import WaifuPics 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mypy~=0.971 2 | flake8~=5.0.4 3 | sqlalchemy[mypy]~=1.4.37 4 | sqlalchemy2-stubs~=0.0.2a27 5 | -------------------------------------------------------------------------------- /app/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from app.filters.genre import CheckGenreIn 2 | from app.filters.nsfw_settings import NSFWSettings 3 | -------------------------------------------------------------------------------- /app/media_utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .genres import get_sorted_genres, get_text_by_genres 2 | from .stats import get_stats_text 3 | -------------------------------------------------------------------------------- /app/constants.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | APP_DIR = Path(__file__).parent.parent 4 | LOCALES_DIR = APP_DIR / "locales" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | 3 | *.mo 4 | 5 | .venv/ 6 | venv/ 7 | 8 | .vscode/ 9 | 10 | .env 11 | .env.dev 12 | 13 | .mypy_cache/ 14 | -------------------------------------------------------------------------------- /app/infrastructure/database/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base 2 | 3 | Base = declarative_base() # type: ignore 4 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | per-file-ignores = 3 | # imported but unused 4 | __init__.py: F401 5 | max-line-length = 79 6 | max-doc-length = 79 7 | -------------------------------------------------------------------------------- /app/states/__init__.py: -------------------------------------------------------------------------------- 1 | from .language import Language 2 | from .main_menu import MainMenu 3 | from .settings import Settings 4 | from .stats import Stats 5 | -------------------------------------------------------------------------------- /app/states/main_menu.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.filters.state import State, StatesGroup 2 | 3 | 4 | class MainMenu(StatesGroup): 5 | main_menu = State() 6 | -------------------------------------------------------------------------------- /app/states/stats.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.filters.state import State, StatesGroup 2 | 3 | 4 | class Stats(StatesGroup): 5 | select_stats_type = State() 6 | -------------------------------------------------------------------------------- /app/states/language.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.filters.state import State, StatesGroup 2 | 3 | 4 | class Language(StatesGroup): 5 | select_language = State() 6 | -------------------------------------------------------------------------------- /app/states/settings.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.filters.state import State, StatesGroup 2 | 3 | 4 | class Settings(StatesGroup): 5 | select_settings = State() 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # telegram bot 2 | BOT_TOKEN= 3 | 4 | # database 5 | DB_USER= 6 | DB_PASSWORD= 7 | DB_HOST=172.17.0.1 8 | DB_PORT=5432 9 | DB_NAME=get_anime_bot 10 | -------------------------------------------------------------------------------- /app/domain/media/models/stats_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class StatsType(Enum): 5 | MEDIA = 1 6 | VIEWED_BY_ME = 2 7 | VIEWED_BY_ALL = 3 8 | -------------------------------------------------------------------------------- /app/domain/media/dto/media.py: -------------------------------------------------------------------------------- 1 | from app.domain.common.dto import DTO 2 | 3 | 4 | class Media(DTO): 5 | total: int 6 | genre: str 7 | media_type: str 8 | is_sfw: bool 9 | -------------------------------------------------------------------------------- /app/infrastructure/database/__init__.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.database.base import Base 2 | from app.infrastructure.database.database import make_connection_string, sa_sessionmaker 3 | -------------------------------------------------------------------------------- /.env.dev.example: -------------------------------------------------------------------------------- 1 | # telegram bot 2 | BOT_TOKEN= 3 | 4 | # database 5 | DB_USER=test 6 | DB_PASSWORD=test 7 | DB_HOST=172.17.0.1 8 | DB_PORT=5432 9 | DB_NAME=get_anime_bot_test 10 | -------------------------------------------------------------------------------- /app/infrastructure/media/base/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.media.base.schemas.media_genre import MediaGenre 2 | from app.infrastructure.media.base.schemas.media import Media 3 | -------------------------------------------------------------------------------- /app/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from app.middlewares.acl import ACLMiddleware 2 | from app.middlewares.database import DatabaseMiddleware 3 | from app.middlewares.i18n import I18nMiddleware 4 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/repo.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | 4 | class Repo: 5 | def __init__(self, session: AsyncSession): 6 | self.session = session 7 | -------------------------------------------------------------------------------- /app/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from app.handlers.errors import register_error_handlers 2 | from app.handlers.introduction import register_introduction_handlers 3 | from app.handlers.media import register_genre_handlers 4 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | check_untyped_defs = True 4 | warn_unreachable = True 5 | show_error_context = True 6 | show_error_codes = True 7 | pretty = True 8 | plugins = sqlalchemy.ext.mypy.plugin 9 | -------------------------------------------------------------------------------- /app/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | from .language import language as language_dialog 2 | from .main_menu import main_menu as main_menu_dialog 3 | from .settings import settings as settings_dialog 4 | from .stats import stats as stats_dialog 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.7.2,<4.0.0 2 | aiogram>=2.13,<3.0.0 3 | sqlalchemy~=1.4.37 4 | asyncpg>=0.24.0<1.0.0 5 | pydantic~=1.9.0 6 | structlog~=21.5.0 7 | pathlib>=1.0.1,<2.0.0 8 | aiogram-dialog~=1.4.1 9 | alembic>=1.8.0,<2.0.0 10 | -------------------------------------------------------------------------------- /app/domain/common/dto/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Extra 2 | 3 | 4 | class DTO(BaseModel): 5 | class Config: 6 | use_enum_values = True 7 | extra = Extra.forbid 8 | frozen = True 9 | orm_mode = True 10 | -------------------------------------------------------------------------------- /app/domain/media/dto/stats.py: -------------------------------------------------------------------------------- 1 | from app.domain.common.dto import DTO 2 | 3 | from .media import Media 4 | 5 | 6 | class Stats(DTO): 7 | total: int 8 | gif: int 9 | img: int 10 | all: int 11 | sfw: int 12 | nsfw: int 13 | media: list[Media] 14 | -------------------------------------------------------------------------------- /app/infrastructure/media/base/schemas/media.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(frozen=True) 5 | class Media: 6 | url: str 7 | media_type: str # gif, jpg, jpeg, png 8 | is_sfw: bool 9 | raw_genre: str # hentai, neko, etc. 10 | -------------------------------------------------------------------------------- /app/infrastructure/media/base/schemas/media_genre.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from app.infrastructure.media.base.typehints import MediaRawGenreType 4 | 5 | 6 | @dataclass(frozen=True) 7 | class MediaGenre: 8 | raw_genre: MediaRawGenreType # neko, foxgirl, etc. 9 | media_type: str # gif, img, all 10 | is_sfw: bool 11 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.database.base import Base 2 | from app.infrastructure.database.models.media import MediaModel 3 | from app.infrastructure.database.models.source import SourceModel 4 | from app.infrastructure.database.models.user import UserModel 5 | from app.infrastructure.database.models.view import ViewModel 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | LICENSE 2 | LICENCE 3 | README.md 4 | 5 | .gitignore 6 | .gitattributes 7 | 8 | *.mo 9 | 10 | .env 11 | .env.dev 12 | .env.dev.example 13 | .env.example 14 | 15 | .vscode/ 16 | 17 | __pycache__/ 18 | .mypy_cache/ 19 | 20 | .venv/ 21 | venv/ 22 | 23 | mypy.ini 24 | tox.ini 25 | 26 | requirements-dev.txt 27 | 28 | docker-compose.yml 29 | Dockerfile 30 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.database.repositories.media import MediaRepo 2 | from app.infrastructure.database.repositories.repo import Repo 3 | from app.infrastructure.database.repositories.source import SourceRepo 4 | from app.infrastructure.database.repositories.uow import UnitOfWork 5 | from app.infrastructure.database.repositories.user import UserRepo 6 | from app.infrastructure.database.repositories.views import ViewsRepo 7 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/views.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.database.models import ViewModel 2 | from app.infrastructure.database.repositories.repo import Repo 3 | from sqlalchemy import insert 4 | 5 | 6 | class ViewsRepo(Repo): 7 | async def create( 8 | self, 9 | tg_id: int, 10 | media_id: int, 11 | ): 12 | query = insert(ViewModel).values(user_tg_id=tg_id, media_id=media_id) 13 | 14 | await self.session.execute(query) 15 | -------------------------------------------------------------------------------- /app/filters/nsfw_settings.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.filters import Filter 2 | from aiogram.types.base import TelegramObject 3 | from app.infrastructure.database.models import UserModel 4 | 5 | 6 | class NSFWSettings(Filter): 7 | def __init__(self, can_show_nsfw: bool): 8 | self.can_show_nsfw = can_show_nsfw 9 | 10 | async def check(self, obj: TelegramObject) -> bool: 11 | user: UserModel = obj.bot["user"] 12 | 13 | return user.show_nsfw is self.can_show_nsfw 14 | -------------------------------------------------------------------------------- /app/filters/genre.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from aiogram.dispatcher.filters import Filter 4 | from aiogram.types import Message 5 | from app.infrastructure.media.base.typehints import MediaGenreType 6 | 7 | 8 | class CheckGenreIn(Filter): 9 | def __init__(self, genres: chain[MediaGenreType]): 10 | self.genres = set(genres) 11 | 12 | async def check(self, obj) -> bool: 13 | if not isinstance(obj, Message): 14 | return False 15 | 16 | return obj.get_command(pure=True) in self.genres 17 | -------------------------------------------------------------------------------- /app/infrastructure/media/__init__.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.media.base import MediaSource 2 | from app.infrastructure.media.base.exceptions import GenreNotFound 3 | from app.infrastructure.media.base.schemas import Media, MediaGenre 4 | from app.infrastructure.media.base.typehints import ( 5 | MediaGenreType, 6 | MediaRawGenreType, 7 | MediaUrlType, 8 | ) 9 | from app.infrastructure.media.nekos_fun import NekosFun 10 | from app.infrastructure.media.nekos_life import NekosLife 11 | from app.infrastructure.media.waifu_pics import WaifuPics 12 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/source.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from app.infrastructure.database import Base 4 | from sqlalchemy import Column, DateTime, Integer, String 5 | 6 | 7 | class SourceModel(Base): # type: ignore 8 | __tablename__ = "sources" 9 | 10 | id = Column(Integer, primary_key=True) 11 | 12 | name = Column(String, nullable=False, unique=True) 13 | url = Column(String, nullable=False, unique=True) 14 | 15 | created = Column( 16 | DateTime, 17 | default=datetime.utcnow, 18 | nullable=False, 19 | ) 20 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | [![MIT Licence](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT) 2 | 3 | **get_anime_bot** is a telegram bot for convenient getting anime GIFs or images by genres. 4 | 5 | ### Configuration 6 | Configure the configuration for your own use in file `.env` or `.env.dev` for development. 7 | 8 | ### Run 9 | ```bash 10 | # for production 11 | make release-build && make release-up # or just `make release` 12 | 13 | # for development 14 | make dev-build && make dev-up # or just `make dev` 15 | ``` 16 | 17 | Check `Makefile` for other scenarios 18 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from app.infrastructure.database import Base 4 | from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, String 5 | 6 | 7 | class UserModel(Base): # type: ignore 8 | __tablename__ = "users" 9 | 10 | id = Column(Integer, primary_key=True) 11 | tg_id = Column(BigInteger, unique=True, nullable=False) 12 | 13 | language_code = Column(String, nullable=True) 14 | show_nsfw = Column(Boolean, default=False, nullable=False) 15 | 16 | created = Column( 17 | DateTime, 18 | default=datetime.utcnow, 19 | nullable=False, 20 | ) 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.6-slim-buster as compile-image 2 | 3 | ENV PYTHONUNBUFFERED=1 \ 4 | PYTHONDONTWRITEBYTECODE=1 \ 5 | PIP_NO_CACHE_DIR=off \ 6 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 7 | PIP_DEFAULT_TIMEOUT=100 8 | 9 | RUN python -m venv /opt/venv 10 | ENV PATH="/opt/venv/bin:$PATH" 11 | COPY requirements.txt . 12 | RUN pip install --no-cache-dir --upgrade pip \ 13 | && pip install --no-cache-dir -r requirements.txt 14 | 15 | FROM python:3.10.6-slim-buster 16 | COPY --from=compile-image /opt/venv /opt/venv 17 | ENV PATH="/opt/venv/bin:$PATH" 18 | WORKDIR /app 19 | COPY . /app/ 20 | RUN pybabel compile -d locales -D bot 21 | 22 | CMD ["python", "-m", "app"] 23 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | # path to migration scripts 3 | script_location = app/infrastructure/database/alembic 4 | 5 | prepend_sys_path = . 6 | 7 | [loggers] 8 | keys = root,sqlalchemy,alembic 9 | 10 | [handlers] 11 | keys = console 12 | 13 | [formatters] 14 | keys = generic 15 | 16 | [logger_root] 17 | level = WARN 18 | handlers = console 19 | qualname = 20 | 21 | [logger_sqlalchemy] 22 | level = WARN 23 | handlers = 24 | qualname = sqlalchemy.engine 25 | 26 | [logger_alembic] 27 | level = INFO 28 | handlers = 29 | qualname = alembic 30 | 31 | [handler_console] 32 | class = StreamHandler 33 | args = (sys.stderr,) 34 | level = NOTSET 35 | formatter = generic 36 | 37 | [formatter_generic] 38 | format = %(levelname)-5.5s [%(name)s] %(message)s 39 | datefmt = %H:%M:%S 40 | -------------------------------------------------------------------------------- /app/handlers/errors.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.types import Update 3 | from aiogram_dialog.exceptions import UnknownIntent 4 | from app.typehints import I18nGettext 5 | from structlog import get_logger 6 | from structlog.stdlib import BoundLogger 7 | 8 | logger: BoundLogger = get_logger() 9 | 10 | 11 | async def dialog_exception(u: Update, exc: UnknownIntent) -> bool: 12 | _: I18nGettext = u.bot["gettext"] 13 | 14 | await u.callback_query.answer( 15 | text=_( 16 | "It looks like this message belongs to another person " 17 | "or something went wrong" 18 | ), 19 | show_alert=True, 20 | cache_time=60, 21 | ) 22 | 23 | return True 24 | 25 | 26 | def register_error_handlers(dp: Dispatcher): 27 | dp.register_errors_handler(dialog_exception, exception=UnknownIntent) 28 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/source.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.database.models import SourceModel 2 | from app.infrastructure.database.repositories.repo import Repo 3 | from sqlalchemy import func, insert, select 4 | 5 | 6 | class SourceRepo(Repo): 7 | async def get_count_sources(self) -> int: 8 | result = await self.session.execute( 9 | select(func.count("*")).select_from(SourceModel) 10 | ) 11 | 12 | return result.scalar_one() 13 | 14 | async def get_by_name(self, name: str) -> SourceModel: 15 | result = await self.session.execute( 16 | select(SourceModel).where(SourceModel.name == name) 17 | ) 18 | 19 | return result.scalar_one() 20 | 21 | async def create(self, name: str, url: str): 22 | await self.session.execute(insert(SourceModel).values(name=name, url=url)) 23 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/versions/ea4780da43f5_add_unique_constraint_to_view.py: -------------------------------------------------------------------------------- 1 | """Add unique constraint to view 2 | 3 | Revision ID: ea4780da43f5 4 | Revises: 53cc2c59da45 5 | Create Date: 2022-10-05 15:32:15.498251 6 | 7 | """ 8 | from alembic import op 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = 'ea4780da43f5' 12 | down_revision = '53cc2c59da45' 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade() -> None: 18 | # ### commands auto generated by Alembic - please adjust! ### 19 | op.create_unique_constraint(None, 'views', ['user_tg_id', 'media_id']) 20 | # ### end Alembic commands ### 21 | 22 | 23 | def downgrade() -> None: 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.drop_constraint(None, 'views', type_='unique') # type: ignore 26 | # ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/media.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from app.infrastructure.database import Base 4 | from sqlalchemy import ( 5 | Boolean, 6 | Column, 7 | DateTime, 8 | ForeignKey, 9 | Integer, 10 | String, 11 | UniqueConstraint, 12 | ) 13 | 14 | 15 | class MediaModel(Base): # type: ignore 16 | __tablename__ = "media" 17 | 18 | id = Column(Integer, primary_key=True) 19 | 20 | url = Column(String, nullable=False) 21 | genre = Column(String, nullable=True) 22 | media_type = Column(String, nullable=False) 23 | is_sfw = Column(Boolean, nullable=True) 24 | 25 | source_id = Column( 26 | Integer, 27 | ForeignKey( 28 | column="sources.id", 29 | onupdate="CASCADE", 30 | ondelete="SET NULL", 31 | ), 32 | ) 33 | 34 | created = Column( 35 | DateTime, 36 | default=datetime.utcnow, 37 | nullable=False, 38 | ) 39 | 40 | UniqueConstraint(url, genre, media_type) 41 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/view.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from app.infrastructure.database import Base 4 | from sqlalchemy import ( 5 | BigInteger, 6 | Column, 7 | DateTime, 8 | ForeignKey, 9 | Integer, 10 | UniqueConstraint, 11 | ) 12 | 13 | 14 | class ViewModel(Base): # type: ignore 15 | __tablename__ = "views" 16 | 17 | id = Column(Integer, primary_key=True) 18 | 19 | user_tg_id = Column( 20 | BigInteger, 21 | ForeignKey( 22 | column="users.tg_id", 23 | onupdate="CASCADE", 24 | ondelete="CASCADE", 25 | ), 26 | ) 27 | media_id = Column( 28 | Integer, 29 | ForeignKey( 30 | column="media.id", 31 | onupdate="CASCADE", 32 | ondelete="SET NULL", 33 | ), 34 | ) 35 | 36 | created = Column( 37 | DateTime, 38 | default=datetime.utcnow, 39 | nullable=False, 40 | ) 41 | 42 | UniqueConstraint(user_tg_id, media_id) 43 | -------------------------------------------------------------------------------- /app/config_reader.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | 4 | class Bot(BaseSettings): 5 | token: str 6 | 7 | 8 | class Database(BaseSettings): 9 | user: str 10 | password: str 11 | host: str 12 | port: int 13 | name: str 14 | 15 | 16 | class SettingsExtractor(BaseSettings): 17 | # telegram bot 18 | BOT_TOKEN: str 19 | 20 | # database 21 | DB_USER: str 22 | DB_PASSWORD: str 23 | DB_HOST: str 24 | DB_PORT: int 25 | DB_NAME: str 26 | 27 | 28 | class Settings(BaseSettings): 29 | bot: Bot 30 | database: Database 31 | 32 | class Config: 33 | env_file_encoding = "utf-8" 34 | 35 | 36 | def load_config() -> Settings: 37 | settings = SettingsExtractor() # type: ignore 38 | 39 | return Settings( 40 | bot=Bot(token=settings.BOT_TOKEN), 41 | database=Database( 42 | user=settings.DB_USER, 43 | password=settings.DB_PASSWORD, 44 | host=settings.DB_HOST, 45 | port=settings.DB_PORT, 46 | name=settings.DB_NAME, 47 | ), 48 | ) 49 | -------------------------------------------------------------------------------- /app/infrastructure/database/database.py: -------------------------------------------------------------------------------- 1 | from app.config_reader import Database 2 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine 3 | from sqlalchemy.orm import sessionmaker 4 | 5 | 6 | def make_connection_string( 7 | database: Database, 8 | async_fallback: bool = False, 9 | ) -> str: 10 | return ( 11 | "postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}" 12 | "?async_fallback={async_fallback}" 13 | ).format( 14 | user=database.user, 15 | password=database.password, 16 | host=database.host, 17 | port=database.port, 18 | name=database.name, 19 | async_fallback=async_fallback, 20 | ) 21 | 22 | 23 | def sa_sessionmaker( 24 | connection_string: str, 25 | echo: bool = False, 26 | ) -> sessionmaker: 27 | return sessionmaker( 28 | bind=create_async_engine(connection_string, echo=echo, pool_size=100), 29 | expire_on_commit=False, 30 | class_=AsyncSession, 31 | future=True, 32 | autoflush=False, 33 | autocommit=False, 34 | ) 35 | -------------------------------------------------------------------------------- /app/logging_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import structlog 5 | 6 | 7 | def logging_configure() -> None: 8 | logging.basicConfig( 9 | format="%(message)s", 10 | level=logging.INFO, 11 | stream=sys.stdout, 12 | ) 13 | structlog.configure( 14 | processors=[ 15 | structlog.processors.format_exc_info, 16 | structlog.stdlib.add_logger_name, 17 | structlog.stdlib.add_log_level, 18 | structlog.processors.TimeStamper(fmt="iso", utc=True), 19 | structlog.processors.StackInfoRenderer(), 20 | structlog.processors.UnicodeDecoder(), 21 | structlog.processors.CallsiteParameterAdder( 22 | parameters={ 23 | structlog.processors.CallsiteParameter.FUNC_NAME, 24 | structlog.processors.CallsiteParameter.LINENO, 25 | } 26 | ), 27 | structlog.dev.ConsoleRenderer(), 28 | ], 29 | logger_factory=structlog.stdlib.LoggerFactory(), 30 | cache_logger_on_first_use=True, 31 | ) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2022 Desiders 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /app/language_utils/language.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | 4 | @dataclass 5 | class LanguageData: 6 | code: str 7 | flag: str 8 | title: str 9 | label: str | None = field(init=False, default=None) 10 | 11 | def __post_init__(self): 12 | self.label = f"{self.flag} {self.title}" 13 | 14 | 15 | AVAILABLE_LANGUAGES = { 16 | "en": LanguageData( 17 | code="en", 18 | flag="🇺🇸", 19 | title="English", 20 | ), 21 | "ru": LanguageData( 22 | code="ru", 23 | flag="🇷🇺", 24 | title="Русский", 25 | ), 26 | "ua": LanguageData( 27 | code="ua", 28 | flag="🇺🇦", 29 | title="Українська", 30 | ), 31 | "be": LanguageData( 32 | code="be", 33 | flag="🇧🇾", 34 | title="Беларуская", 35 | ), 36 | } 37 | DEFAULT_LANGUAGE = AVAILABLE_LANGUAGES["en"] 38 | 39 | 40 | def get_locale_or_default(locale: str | None = None) -> str: 41 | if not locale: 42 | return DEFAULT_LANGUAGE.code 43 | 44 | if locale not in AVAILABLE_LANGUAGES: 45 | return DEFAULT_LANGUAGE.code 46 | 47 | return locale 48 | -------------------------------------------------------------------------------- /app/middlewares/acl.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.middlewares import BaseMiddleware 2 | from aiogram.types import CallbackQuery, Message 3 | from app.infrastructure.database.repositories import UnitOfWork 4 | from sqlalchemy.exc import NoResultFound 5 | 6 | 7 | class ACLMiddleware(BaseMiddleware): 8 | async def on_pre_process_message( 9 | self, 10 | message: Message, 11 | data: dict, 12 | ): 13 | await self.create_user(message, data) 14 | 15 | async def on_pre_process_callback_query( 16 | self, 17 | query: CallbackQuery, 18 | data: dict, 19 | ): 20 | await self.create_user(query, data) 21 | 22 | async def create_user( 23 | self, 24 | obj: CallbackQuery | Message, 25 | data: dict, 26 | ): 27 | uow: UnitOfWork = data["uow"] 28 | 29 | tg_id = obj.from_user.id 30 | 31 | try: 32 | user = await uow.users.get_by_tg_id(tg_id) 33 | except NoResultFound: 34 | await uow.users.create(tg_id) 35 | await uow.commit() 36 | 37 | user = await uow.users.get_by_tg_id(tg_id) 38 | 39 | data["user"] = user 40 | obj.bot["user"] = user 41 | -------------------------------------------------------------------------------- /app/middlewares/database.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.middlewares import LifetimeControllerMiddleware 2 | from aiogram.types import CallbackQuery, Message 3 | from app.infrastructure.database.repositories import UnitOfWork 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | 7 | class DatabaseMiddleware(LifetimeControllerMiddleware): 8 | skip_patterns = {"update"} 9 | 10 | def __init__(self, sa_sessionmaker: sessionmaker): 11 | self.sa_sessionmaker = sa_sessionmaker 12 | 13 | super().__init__() 14 | 15 | async def pre_process(self, message: Message, data: dict, *args): 16 | await self.setup_uow(message, data) 17 | 18 | async def setup_uow( 19 | self, 20 | obj: Message | CallbackQuery, 21 | data: dict, 22 | ): 23 | async with self.sa_sessionmaker() as session: # type: ignore 24 | uow = UnitOfWork(session=session) 25 | 26 | data["uow"] = uow 27 | obj.bot["uow"] = uow 28 | 29 | async def post_process(self, query, data: dict, *args): 30 | await self.clear_uow(data) 31 | 32 | async def clear_uow(self, data: dict): 33 | uow: UnitOfWork = data["uow"] 34 | 35 | await uow.close() 36 | 37 | del data["uow"] 38 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | bot: 5 | container_name: get_anime_bot 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | stop_signal: SIGINT 10 | restart: on-failure 11 | env_file: .env 12 | depends_on: 13 | - db 14 | - db_migration 15 | networks: 16 | - backend 17 | db: 18 | container_name: get_anime_bot_db 19 | image: postgres:14-alpine 20 | restart: on-failure 21 | environment: 22 | POSTGRES_USER: ${DB_USER} 23 | POSTGRES_PASSWORD: ${DB_PASSWORD} 24 | POSTGRES_DB: ${DB_NAME} 25 | volumes: 26 | - postgres-data:/var/lib/postgresql/data 27 | ports: 28 | - 5432:5432 29 | networks: 30 | - backend 31 | db_migration: 32 | build: . 33 | restart: on-failure 34 | depends_on: 35 | - db 36 | env_file: .env 37 | command: sh -c "python -m alembic upgrade head" 38 | networks: 39 | - backend 40 | 41 | volumes: 42 | postgres-data: 43 | 44 | networks: 45 | backend: 46 | name: get_anime_bot_backend 47 | driver: bridge 48 | -------------------------------------------------------------------------------- /docker-compose-dev.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | bot: 5 | container_name: get_anime_bot_dev 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | stop_signal: SIGINT 10 | restart: on-failure 11 | env_file: .env.dev 12 | depends_on: 13 | - db 14 | - db_migration 15 | networks: 16 | - backend_dev 17 | db: 18 | container_name: get_anime_bot_db_dev 19 | image: postgres:14-alpine 20 | restart: on-failure 21 | environment: 22 | POSTGRES_USER: ${DB_USER} 23 | POSTGRES_PASSWORD: ${DB_PASSWORD} 24 | POSTGRES_DB: ${DB_NAME} 25 | volumes: 26 | - postgres-data:/var/lib/postgresql/data 27 | ports: 28 | - 5432:5432 29 | networks: 30 | - backend_dev 31 | db_migration: 32 | build: . 33 | restart: on-failure 34 | depends_on: 35 | - db 36 | env_file: .env.dev 37 | command: sh -c "python -m alembic upgrade head" 38 | networks: 39 | - backend_dev 40 | 41 | volumes: 42 | postgres-data: 43 | 44 | networks: 45 | backend_dev: 46 | name: get_anime_bot_backend_dev 47 | driver: bridge 48 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .ONESHELL: 2 | 3 | python := $(py) python 4 | 5 | define setup_env 6 | $(eval ENV_FILE := $(1)) 7 | @echo " - setup env $(ENV_FILE)" 8 | $(eval include $(1)) 9 | $(eval export) 10 | endef 11 | 12 | .PHONY: release 13 | release: 14 | docker compose up --build 15 | 16 | .PHONY: release-up 17 | release-up: 18 | docker compose up 19 | 20 | .PHONY: release-build 21 | release-build: 22 | docker compose build 23 | 24 | .PHONY: dev 25 | dev: 26 | docker compose -f=docker-compose-dev.yaml up --build 27 | 28 | .PHONY: dev-up 29 | dev-up: 30 | docker compose -f=docker-compose-dev.yaml up 31 | 32 | .PHONY: dev-build 33 | dev-build: 34 | docker compose -f=docker-compose-dev.yaml build 35 | 36 | .PHONY: migration-head 37 | migration-head: 38 | $(call setup_env, .env) 39 | alembic upgrade head 40 | 41 | .PHONY: migration-head-dev 42 | migration-head-dev: 43 | $(call setup_env, .env.dev) 44 | alembic upgrade head 45 | 46 | .PHONY: migration-up 47 | migration-up: 48 | $(call setup_env, .env) 49 | alembic upgrade +1 50 | 51 | .PHONY: migration-up-dev 52 | migration-up-dev: 53 | $(call setup_env, .env.dev) 54 | alembic upgrade +1 55 | 56 | .PHONY: migration-down 57 | migration-down: 58 | $(call setup_env, .env) 59 | alembic downgrade -1 60 | 61 | .PHONY: migration-down-dev 62 | migration-down-dev: 63 | $(call setup_env, .env.dev) 64 | alembic downgrade -1 65 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/user.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.database.models import UserModel 2 | from app.infrastructure.database.repositories.repo import Repo 3 | from sqlalchemy import insert, select, update 4 | 5 | 6 | class UserRepo(Repo): 7 | async def get_by_tg_id(self, tg_id: int) -> UserModel: 8 | result = await self.session.execute( 9 | select(UserModel).where(UserModel.tg_id == tg_id) 10 | ) 11 | 12 | return result.scalar_one() 13 | 14 | async def create( 15 | self, 16 | tg_id: int, 17 | language_code: str | None = None, 18 | ): 19 | await self.session.execute( 20 | insert(UserModel).values(tg_id=tg_id, language_code=language_code) 21 | ) 22 | 23 | async def update_language_code( 24 | self, 25 | tg_id: int, 26 | language_code: str, 27 | ): 28 | await self.session.execute( 29 | update(UserModel) 30 | .where(UserModel.tg_id == tg_id) 31 | .values(language_code=language_code) 32 | ) 33 | 34 | async def update_show_nsfw( 35 | self, 36 | tg_id: int, 37 | show_nsfw: bool, 38 | ): 39 | await self.session.execute( 40 | update(UserModel) 41 | .where(UserModel.tg_id == tg_id) 42 | .values(show_nsfw=show_nsfw) 43 | ) 44 | -------------------------------------------------------------------------------- /app/middlewares/i18n.py: -------------------------------------------------------------------------------- 1 | from aiogram.contrib.middlewares.i18n import I18nMiddleware as BaseI18nMiddleware 2 | from aiogram.types import CallbackQuery, Message 3 | from app.infrastructure.database.models import UserModel 4 | from app.infrastructure.database.repositories.uow import UnitOfWork 5 | from app.language_utils.language import get_locale_or_default 6 | 7 | 8 | class I18nMiddleware(BaseI18nMiddleware): 9 | async def get_user_locale(self, action, args: tuple) -> str | None: 10 | obj: CallbackQuery | Message 11 | data: dict 12 | obj, *_, data = args 13 | 14 | user: UserModel | None = data.get("user") 15 | if user is None: 16 | return None 17 | 18 | if not (language_code := user.language_code): 19 | language_code = get_locale_or_default(obj.from_user.locale) 20 | 21 | uow: UnitOfWork = data["uow"] 22 | 23 | await uow.users.update_language_code( 24 | user.tg_id, 25 | language_code, # type: ignore 26 | ) 27 | await uow.commit() 28 | 29 | user.language_code = language_code # type: ignore 30 | data["user"] = user 31 | 32 | data["_"] = self.gettext 33 | obj.bot["gettext"] = self.gettext 34 | obj.bot["i18n"] = self 35 | 36 | return language_code # type: ignore 37 | 38 | def change_user_locale(self, locale: str): 39 | self.ctx_locale.set(locale) # type: ignore 40 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/versions/53cc2c59da45_edit_column_tg_id_type.py: -------------------------------------------------------------------------------- 1 | """edit column `tg_id` type 2 | 3 | Revision ID: 53cc2c59da45 4 | Revises: c410d49a8acb 5 | Create Date: 2022-06-22 16:36:20.492077 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '53cc2c59da45' 13 | down_revision = 'c410d49a8acb' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade() -> None: 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.alter_column('users', 'tg_id', 21 | existing_type=sa.INTEGER(), 22 | type_=sa.BigInteger(), 23 | existing_nullable=False) 24 | op.alter_column('views', 'user_tg_id', 25 | existing_type=sa.INTEGER(), 26 | type_=sa.BigInteger(), 27 | existing_nullable=True) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.alter_column('views', 'user_tg_id', 34 | existing_type=sa.BigInteger(), 35 | type_=sa.INTEGER(), 36 | existing_nullable=True) 37 | op.alter_column('users', 'tg_id', 38 | existing_type=sa.BigInteger(), 39 | type_=sa.INTEGER(), 40 | existing_nullable=False) 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/uow.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.database.repositories.media import MediaRepo 2 | from app.infrastructure.database.repositories.source import SourceRepo 3 | from app.infrastructure.database.repositories.user import UserRepo 4 | from app.infrastructure.database.repositories.views import ViewsRepo 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | 8 | class UnitOfWork: 9 | def __init__(self, session: AsyncSession): 10 | self.session = session 11 | 12 | self._media_repo: MediaRepo | None = None 13 | self._source_repo: SourceRepo | None = None 14 | self._user_repo: UserRepo | None = None 15 | self._views_repo: ViewsRepo | None = None 16 | 17 | @property 18 | def media(self): 19 | if self._media_repo is None: 20 | self._media_repo = MediaRepo(self.session) 21 | return self._media_repo 22 | 23 | @property 24 | def sources(self): 25 | if self._source_repo is None: 26 | self._source_repo = SourceRepo(self.session) 27 | return self._source_repo 28 | 29 | @property 30 | def users(self): 31 | if self._user_repo is None: 32 | self._user_repo = UserRepo(self.session) 33 | return self._user_repo 34 | 35 | @property 36 | def views(self): 37 | if self._views_repo is None: 38 | self._views_repo = ViewsRepo(self.session) 39 | return self._views_repo 40 | 41 | async def commit(self): 42 | await self.session.commit() 43 | 44 | async def rollback(self): 45 | await self.session.rollback() 46 | 47 | async def close(self): 48 | await self.session.close() 49 | -------------------------------------------------------------------------------- /app/dialogs/main_menu.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import User 2 | from aiogram_dialog import Dialog, Window 3 | from aiogram_dialog.dialog import DialogManager 4 | from aiogram_dialog.widgets.kbd import Start 5 | from aiogram_dialog.widgets.text import Format 6 | from app.states import Language, MainMenu, Settings, Stats 7 | from app.typehints import I18nGettext 8 | 9 | 10 | async def get_text( 11 | _: I18nGettext, 12 | dialog_manager: DialogManager, 13 | **kwargs, 14 | ) -> dict[str, str]: 15 | data = dialog_manager.current_context().start_data # type: ignore 16 | user: User = data["user"] # type: ignore 17 | 18 | return { 19 | "start_text": _( 20 | "Hi, {first_name}!\n\n" 21 | "Get an anime GIF or image by genre!\n" 22 | "/genres_gif\n" 23 | "/genres_img\n" 24 | "/genres_all" 25 | ).format( 26 | first_name=user.first_name, 27 | ), 28 | "language_text": _("Change language"), 29 | "settings_text": _("Change settings"), 30 | "stats_text": _("View statistics"), 31 | } 32 | 33 | 34 | main_menu = Dialog( 35 | Window( 36 | Format("{start_text}"), 37 | Start( 38 | Format("{language_text}"), 39 | id="change_language", 40 | state=Language.select_language, 41 | ), 42 | Start( 43 | Format("{settings_text}"), 44 | id="change_settings", 45 | state=Settings.select_settings, 46 | ), 47 | Start( 48 | Format("{stats_text}"), 49 | id="view_stats", 50 | state=Stats.select_stats_type, 51 | ), 52 | getter=get_text, 53 | state=MainMenu.main_menu, 54 | ), 55 | ) 56 | -------------------------------------------------------------------------------- /app/media_utils/genres.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from app.infrastructure.media import MediaSource 4 | from app.typehints import I18nGettext 5 | 6 | 7 | def get_sorted_genres_gif( 8 | sources: set[MediaSource], 9 | show_nsfw: bool, 10 | ) -> list[str]: 11 | if show_nsfw: 12 | genres = list(chain.from_iterable(source.genres_gif for source in sources)) 13 | else: 14 | genres = list(chain.from_iterable(source.sfw_genres_gif for source in sources)) 15 | return sorted(genres) 16 | 17 | 18 | def get_sorted_genres_img( 19 | sources: set[MediaSource], 20 | show_nsfw: bool, 21 | ) -> list[str]: 22 | if show_nsfw: 23 | genres = list(chain.from_iterable(source.genres_img for source in sources)) 24 | else: 25 | genres = list(chain.from_iterable(source.sfw_genres_img for source in sources)) 26 | return sorted(genres) 27 | 28 | 29 | def get_sorted_genres_all( 30 | sources: set[MediaSource], 31 | show_nsfw: bool, 32 | ) -> list[str]: 33 | if show_nsfw: 34 | genres = list(chain.from_iterable(source.genres_all for source in sources)) 35 | else: 36 | genres = list(chain.from_iterable(source.sfw_genres_all for source in sources)) 37 | return sorted(genres) 38 | 39 | 40 | def get_sorted_genres( 41 | sources: set[MediaSource], 42 | show_nsfw: bool, 43 | genres_type: str, 44 | ) -> list[str]: 45 | if genres_type == "gif": 46 | return get_sorted_genres_gif(sources, show_nsfw) 47 | elif genres_type == "img": 48 | return get_sorted_genres_img(sources, show_nsfw) 49 | elif genres_type == "all": 50 | return get_sorted_genres_all(sources, show_nsfw) 51 | else: 52 | raise NotImplementedError(f"Unknown genres type `{genres_type}`") 53 | 54 | 55 | def get_text_by_genres(genres: list[str], _: I18nGettext) -> str: 56 | if not genres: 57 | return _("No genres found") 58 | return _("Genres:\n\n{genres}").format( 59 | genres=" ".join(map(lambda string: "/" + string, genres)), 60 | ) 61 | -------------------------------------------------------------------------------- /app/dialogs/settings.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | from aiogram.types import CallbackQuery 4 | from aiogram_dialog import Dialog, DialogManager, Window 5 | from aiogram_dialog.widgets.kbd import Button, Cancel, Radio 6 | from aiogram_dialog.widgets.text import Format 7 | from app.infrastructure.database.repositories.uow import UnitOfWork 8 | from app.states import Settings 9 | from app.typehints import I18nGettext 10 | 11 | 12 | async def get_text(_: I18nGettext, **kwargs) -> dict[str, str]: 13 | return { 14 | "choice_settings_text": _("Choose your settings:"), 15 | "cancel_text": _("Go back"), 16 | } 17 | 18 | 19 | async def get_settings( 20 | _: I18nGettext, 21 | **kwargs, 22 | ) -> dict[str, list[tuple[str, str]]]: 23 | nsfw_settings = [ 24 | (_("Show NSFW"), "show"), 25 | (_("Hide NSFW"), "hide"), 26 | ] 27 | 28 | return { 29 | "nsfw_settings": nsfw_settings, 30 | } 31 | 32 | 33 | async def select_nsfw_setting( 34 | c: CallbackQuery, 35 | button: Radio, 36 | manager: DialogManager, 37 | nsfw_setting: str, 38 | ): 39 | if button.get_checked(manager) == nsfw_setting: 40 | await c.answer() 41 | return 42 | 43 | uow: UnitOfWork = c.bot["uow"] 44 | 45 | await uow.users.update_show_nsfw( 46 | tg_id=c.from_user.id, 47 | show_nsfw=nsfw_setting == "show", 48 | ) 49 | await uow.commit() 50 | 51 | await c.answer() 52 | 53 | 54 | async def finish_dialog( 55 | c: CallbackQuery, 56 | button: Button, 57 | manager: DialogManager, 58 | ): 59 | await c.message.delete() 60 | await manager.done() 61 | 62 | 63 | settings = Dialog( 64 | Window( 65 | Format("{choice_settings_text}"), 66 | Radio( 67 | checked_text=Format("✓ {item[0]}"), 68 | unchecked_text=Format("{item[0]}"), 69 | id="select_nsfw_setting", 70 | item_id_getter=operator.itemgetter(1), 71 | items="nsfw_settings", 72 | on_click=select_nsfw_setting, # type: ignore 73 | ), 74 | Cancel(Format("{cancel_text}")), 75 | getter=[get_text, get_settings], 76 | state=Settings.select_settings, 77 | ), 78 | ) 79 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from app.config_reader import load_config 4 | from app.infrastructure.database import make_connection_string 5 | from app.infrastructure.database.models import Base 6 | from sqlalchemy import engine_from_config, pool 7 | 8 | from alembic import context 9 | 10 | config = context.config 11 | 12 | config.set_main_option( 13 | "sqlalchemy.url", 14 | make_connection_string( 15 | load_config().database, 16 | async_fallback=True, 17 | ), 18 | ) 19 | 20 | if config.config_file_name is not None: 21 | fileConfig(config.config_file_name) 22 | 23 | target_metadata = Base.metadata 24 | 25 | 26 | def run_migrations_offline() -> None: 27 | """Run migrations in 'offline' mode. 28 | 29 | This configures the context with just a URL 30 | and not an Engine, though an Engine is acceptable 31 | here as well. By skipping the Engine creation 32 | we don't even need a DBAPI to be available. 33 | 34 | Calls to context.execute() here emit the given string to the 35 | script output. 36 | 37 | """ 38 | url = config.get_main_option("sqlalchemy.url") 39 | context.configure( 40 | url=url, 41 | target_metadata=target_metadata, 42 | literal_binds=True, 43 | dialect_opts={"paramstyle": "named"}, 44 | ) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online() -> None: 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | connectable = engine_from_config( 58 | config.get_section(config.config_ini_section), 59 | prefix="sqlalchemy.", 60 | poolclass=pool.NullPool, 61 | ) 62 | 63 | with connectable.connect() as connection: 64 | context.configure( 65 | connection=connection, 66 | target_metadata=target_metadata, 67 | compare_type=True 68 | ) 69 | 70 | with context.begin_transaction(): 71 | context.run_migrations() 72 | 73 | 74 | if context.is_offline_mode(): 75 | run_migrations_offline() 76 | else: 77 | run_migrations_online() 78 | -------------------------------------------------------------------------------- /app/dialogs/language.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | from aiogram.types import CallbackQuery 4 | from aiogram_dialog import Dialog, DialogManager, Window 5 | from aiogram_dialog.widgets.kbd import Button, Cancel, Radio, ScrollingGroup 6 | from aiogram_dialog.widgets.text import Format 7 | from app.infrastructure.database.repositories.uow import UnitOfWork 8 | from app.language_utils.language import AVAILABLE_LANGUAGES 9 | from app.middlewares import I18nMiddleware 10 | from app.states import Language 11 | from app.typehints import I18nGettext 12 | 13 | 14 | async def get_text(_: I18nGettext, **kwargs) -> dict[str, str]: 15 | return { 16 | "select_language_text": _("Select a language:"), 17 | "cancel_text": _("Go back"), 18 | } 19 | 20 | 21 | async def get_languages( 22 | _: I18nGettext, 23 | **kwargs, 24 | ) -> dict[str, list[tuple[str | None, str]]]: 25 | languages = [(lang.label, lang.code) for lang in AVAILABLE_LANGUAGES.values()] 26 | 27 | return { 28 | "languages": languages, 29 | } 30 | 31 | 32 | async def select_language( 33 | c: CallbackQuery, 34 | button: Radio, 35 | manager: DialogManager, 36 | language_code: str, 37 | ): 38 | if button.get_checked(manager) == language_code: 39 | await c.answer() 40 | return 41 | 42 | i18n: I18nMiddleware = c.bot["i18n"] 43 | i18n.change_user_locale(language_code) 44 | 45 | uow: UnitOfWork = c.bot["uow"] 46 | 47 | await uow.users.update_language_code( 48 | c.from_user.id, 49 | language_code, 50 | ) 51 | await uow.commit() 52 | 53 | await c.answer() 54 | 55 | 56 | async def finish_dialog( 57 | c: CallbackQuery, 58 | button: Button, 59 | manager: DialogManager, 60 | ): 61 | await c.message.delete() 62 | await manager.done() 63 | 64 | 65 | language = Dialog( 66 | Window( 67 | Format("{select_language_text}"), 68 | ScrollingGroup( 69 | Radio( 70 | checked_text=Format("✓ {item[0]}"), 71 | unchecked_text=Format("{item[0]}"), 72 | id="select_language", 73 | item_id_getter=operator.itemgetter(1), 74 | items="languages", 75 | on_click=select_language, # type: ignore 76 | ), 77 | id="scrolling_languages", 78 | width=2, 79 | height=6, 80 | ), 81 | Cancel(Format("{cancel_text}")), 82 | getter=[get_text, get_languages], 83 | state=Language.select_language, 84 | ), 85 | ) 86 | -------------------------------------------------------------------------------- /app/handlers/introduction.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.types import Message, ReplyKeyboardRemove 3 | from aiogram.utils.text_decorations import html_decoration as html_dec 4 | from aiogram_dialog import DialogManager, StartMode 5 | from app.states import Language, MainMenu, Settings, Stats 6 | from app.typehints import I18nGettext 7 | 8 | 9 | async def menu_cmd(m: Message, dialog_manager: DialogManager): 10 | await dialog_manager.start( 11 | MainMenu.main_menu, 12 | data={"user": m.from_user}, 13 | mode=StartMode.RESET_STACK, 14 | ) 15 | 16 | 17 | async def source_cmd(m: Message, _: I18nGettext): 18 | text = _("The bot has open source code!\n\n" "{source_link}").format( 19 | source_link=html_dec.link( 20 | "Source code", 21 | "https://github.com/Desiders/get_anime_bot", 22 | ), 23 | ) 24 | 25 | await m.answer( 26 | text=text, 27 | parse_mode="HTML", 28 | reply_markup=ReplyKeyboardRemove(), 29 | ) 30 | 31 | 32 | async def language_cmd(m: Message, dialog_manager: DialogManager): 33 | await dialog_manager.start( 34 | Language.select_language, 35 | mode=StartMode.RESET_STACK, 36 | ) 37 | 38 | 39 | async def settings_cmd(m: Message, dialog_manager: DialogManager): 40 | await dialog_manager.start( 41 | Settings.select_settings, 42 | mode=StartMode.RESET_STACK, 43 | ) 44 | 45 | 46 | async def stats_cmd(m: Message, dialog_manager: DialogManager): 47 | await dialog_manager.start( 48 | Stats.select_stats_type, 49 | mode=StartMode.RESET_STACK, 50 | ) 51 | 52 | 53 | def register_introduction_handlers(dp: Dispatcher): 54 | dp.register_message_handler( 55 | menu_cmd, 56 | commands={"start", "help", "menu"}, 57 | content_types={"text"}, 58 | state="*", 59 | ) 60 | dp.register_message_handler( 61 | source_cmd, 62 | commands={"source"}, 63 | content_types={"text"}, 64 | state="*", 65 | ) 66 | dp.register_message_handler( 67 | language_cmd, 68 | commands={"language", "lang"}, 69 | content_types={"text"}, 70 | state="*", 71 | ) 72 | dp.register_message_handler( 73 | settings_cmd, 74 | commands={"settings"}, 75 | content_types={"text"}, 76 | state="*", 77 | ) 78 | dp.register_message_handler( 79 | stats_cmd, 80 | commands={"statistics", "stats"}, 81 | content_types={"text"}, 82 | state="*", 83 | ) 84 | -------------------------------------------------------------------------------- /app/media_utils/stats.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter 2 | 3 | from app.domain.media.dto import Media, Stats 4 | from app.typehints import I18nGettext 5 | 6 | 7 | def get_sorted_media_by_total(media: list[Media]) -> list[Media]: 8 | return sorted(media, key=attrgetter("total"), reverse=True) 9 | 10 | 11 | def get_media_stats_text(media: list[Media], _: I18nGettext) -> str: 12 | if media := get_sorted_media_by_total(media): 13 | text = "\n\t- ".join( 14 | [ 15 | "{genre}: {total}".format( 16 | genre=media.genre, 17 | total=media.total, 18 | ) 19 | for media in get_sorted_media_by_total(media) 20 | ] 21 | ) 22 | else: 23 | text = _("No media found") 24 | return "\t- " + text 25 | 26 | 27 | def get_media_gif_stats_text(media: list[Media], is_sfw: bool, _: I18nGettext) -> str: 28 | return get_media_stats_text( 29 | [ 30 | media 31 | for media in media 32 | if (media.media_type == "gif" and media.is_sfw == is_sfw) 33 | ], 34 | _, 35 | ) 36 | 37 | 38 | def get_media_img_stats_text(media: list[Media], is_sfw: bool, _: I18nGettext) -> str: 39 | return get_media_stats_text( 40 | [ 41 | media 42 | for media in media 43 | if (media.media_type == "img" and media.is_sfw == is_sfw) 44 | ], 45 | _, 46 | ) 47 | 48 | 49 | def get_media_all_stats_text(media: list[Media], is_sfw: bool, _: I18nGettext) -> str: 50 | return get_media_stats_text( 51 | [media for media in media if media.is_sfw == is_sfw], 52 | _, 53 | ) 54 | 55 | 56 | def get_stats_text(stats: Stats, _: I18nGettext) -> str: 57 | media = stats.media 58 | 59 | sfw_media_gif = get_media_gif_stats_text(media, True, _) 60 | nsfw_media_gif = get_media_gif_stats_text(media, False, _) 61 | sfw_media_img = get_media_img_stats_text(media, True, _) 62 | nsfw_media_img = get_media_img_stats_text(media, False, _) 63 | sfw_media_all = get_media_all_stats_text(media, True, _) 64 | nsfw_media_all = get_media_all_stats_text(media, False, _) 65 | 66 | return _( 67 | "Total count: {total}\n" 68 | "GIF count: {gif}\n" 69 | "\tGenres SFW count:\n" 70 | "{sfw_media_gif_stats_text}\n" 71 | "\tGenres NSFW count:\n" 72 | "{nsfw_media_gif_stats_text}\n" 73 | "IMG count: {img}\n" 74 | "\tGenres SFW count:\n" 75 | "{sfw_media_img_stats_text}\n" 76 | "\tGenres NSFW count:\n" 77 | "{nsfw_media_img_stats_text}\n" 78 | "ALL count: {all}\n" 79 | "\tGenres SFW count:\n" 80 | "{sfw_media_all_stats_text}\n" 81 | "\tGenres NSFW count:\n" 82 | "{nsfw_media_all_stats_text}\n" 83 | "SFW count: {sfw}\n" 84 | "NSFW count: {nsfw}" 85 | ).format( 86 | total=stats.total, 87 | gif=stats.gif, 88 | img=stats.img, 89 | all=stats.all, 90 | sfw=stats.sfw, 91 | nsfw=stats.nsfw, 92 | sfw_media_gif_stats_text=sfw_media_gif, 93 | nsfw_media_gif_stats_text=nsfw_media_gif, 94 | sfw_media_img_stats_text=sfw_media_img, 95 | nsfw_media_img_stats_text=nsfw_media_img, 96 | sfw_media_all_stats_text=sfw_media_all, 97 | nsfw_media_all_stats_text=nsfw_media_all, 98 | ) 99 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/versions/c410d49a8acb_create_tables.py: -------------------------------------------------------------------------------- 1 | """create tables 2 | 3 | Revision ID: c410d49a8acb 4 | Revises: 5 | Create Date: 2022-06-22 13:21:36.554347 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = 'c410d49a8acb' 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade() -> None: 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table('sources', 21 | sa.Column('id', sa.Integer(), nullable=False), 22 | sa.Column('name', sa.String(), nullable=False), 23 | sa.Column('url', sa.String(), nullable=False), 24 | sa.Column('created', sa.DateTime(), nullable=False), 25 | sa.PrimaryKeyConstraint('id'), 26 | sa.UniqueConstraint('name'), 27 | sa.UniqueConstraint('url') 28 | ) 29 | op.create_table('users', 30 | sa.Column('id', sa.Integer(), nullable=False), 31 | sa.Column('tg_id', sa.Integer(), nullable=False), 32 | sa.Column('language_code', sa.String(), nullable=True), 33 | sa.Column('show_nsfw', sa.Boolean(), nullable=False), 34 | sa.Column('created', sa.DateTime(), nullable=False), 35 | sa.PrimaryKeyConstraint('id'), 36 | sa.UniqueConstraint('tg_id') 37 | ) 38 | op.create_table('media', 39 | sa.Column('id', sa.Integer(), nullable=False), 40 | sa.Column('url', sa.String(), nullable=False), 41 | sa.Column('genre', sa.String(), nullable=True), 42 | sa.Column('media_type', sa.String(), nullable=False), 43 | sa.Column('is_sfw', sa.Boolean(), nullable=True), 44 | sa.Column('source_id', sa.Integer(), nullable=True), 45 | sa.Column('created', sa.DateTime(), nullable=False), 46 | sa.ForeignKeyConstraint( 47 | ['source_id'], ['sources.id'], 48 | onupdate='CASCADE', 49 | ondelete='SET NULL', 50 | ), 51 | sa.PrimaryKeyConstraint('id'), 52 | sa.UniqueConstraint('url', 'genre', 'media_type') 53 | ) 54 | op.create_table('views', 55 | sa.Column('id', sa.Integer(), nullable=False), 56 | sa.Column('user_tg_id', sa.Integer(), nullable=True), 57 | sa.Column('media_id', sa.Integer(), nullable=True), 58 | sa.Column('created', sa.DateTime(), nullable=False), 59 | sa.ForeignKeyConstraint( 60 | ['media_id'], ['media.id'], 61 | onupdate='CASCADE', 62 | ondelete='SET NULL', 63 | ), 64 | sa.ForeignKeyConstraint( 65 | ['user_tg_id'], ['users.tg_id'], 66 | onupdate='CASCADE', 67 | ondelete='CASCADE', 68 | ), 69 | sa.PrimaryKeyConstraint('id') 70 | ) 71 | # ### end Alembic commands ### 72 | 73 | 74 | def downgrade() -> None: 75 | # ### commands auto generated by Alembic - please adjust! ### 76 | op.drop_table('views') 77 | op.drop_table('media') 78 | op.drop_table('users') 79 | op.drop_table('sources') 80 | # ### end Alembic commands ### 81 | -------------------------------------------------------------------------------- /app/infrastructure/media/nekos_fun/client.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.media.base import MediaSource 2 | from app.infrastructure.media.base.exceptions import GenreNotFound 3 | from app.infrastructure.media.base.schemas import Media, MediaGenre 4 | from app.infrastructure.media.base.typehints import MediaGenreType 5 | from structlog import get_logger 6 | from structlog.stdlib import BoundLogger 7 | 8 | logger: BoundLogger = get_logger() 9 | 10 | 11 | class NekosFun(MediaSource): 12 | SOURCE_URL = "http://api.nekos.fun:8080/api" 13 | SOURCE_ID = "nf" 14 | 15 | RAW_SFW_GENRES: dict[str, set[str]] = { 16 | "gif": { 17 | "kiss", 18 | "lick", 19 | "hug", 20 | "baka", 21 | "cry", 22 | "poke", 23 | "smug", 24 | "slap", 25 | "tickle", 26 | "pat", 27 | "laugh", 28 | "feed", 29 | "cuddle", 30 | }, 31 | "img": { 32 | "animalears", 33 | "foxgirl", 34 | "neko", 35 | }, 36 | "all": set(), 37 | } 38 | RAW_NSFW_GENRES: dict[str, set[str]] = { 39 | "gif": { 40 | "boobs", 41 | "cum", 42 | "lesbian", 43 | "anal", 44 | }, 45 | "img": { 46 | "hentai", 47 | "lewd", 48 | "holo", 49 | }, 50 | "all": { 51 | "blowjob", 52 | "spank", 53 | "pussy", 54 | "feet", 55 | }, 56 | } 57 | 58 | async def get_media( 59 | self, 60 | genre: MediaGenreType, 61 | count: int = 1, 62 | ) -> list[Media]: 63 | """ 64 | Get a media url for a given genre. 65 | 66 | :genre: genre to get a media url for 67 | :count (optional): number of media to get 68 | """ 69 | if genre not in self.genres: 70 | raise GenreNotFound 71 | 72 | genre_model = self.parse_genre(genre) 73 | 74 | url = self.generate_url(genre_model) 75 | 76 | if count > 1: 77 | media = [] 78 | 79 | for _ in range(count): 80 | response = await self.session.get(url=url) 81 | response.raise_for_status() 82 | 83 | result = await response.json() 84 | 85 | media.append( 86 | Media( 87 | url=result["image"], 88 | media_type=genre_model.media_type, 89 | is_sfw=genre_model.is_sfw, 90 | raw_genre=genre_model.raw_genre, 91 | ) 92 | ) 93 | return media 94 | 95 | response = await self.session.get(url=url) 96 | response.raise_for_status() 97 | 98 | result = await response.json() 99 | 100 | return [ 101 | Media( 102 | url=result["image"], 103 | media_type=genre_model.media_type, 104 | is_sfw=genre_model.is_sfw, 105 | raw_genre=genre_model.raw_genre, 106 | ) 107 | ] 108 | 109 | def generate_url(self, genre_model: MediaGenre) -> MediaGenreType: 110 | """ 111 | Generate a url for a given genre model. 112 | 113 | :genre_model: genre model to generate a url for 114 | """ 115 | url = "{source_url}/{raw_genre}".format( 116 | source_url=self.SOURCE_URL, 117 | raw_genre=genre_model.raw_genre, 118 | ) 119 | 120 | return url 121 | -------------------------------------------------------------------------------- /app/dialogs/stats.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | from aiogram.types import CallbackQuery 4 | from aiogram_dialog import Dialog, DialogManager, Window 5 | from aiogram_dialog.widgets.kbd import Button, Cancel, Column, Radio 6 | from aiogram_dialog.widgets.text import Format, Multi 7 | from app.domain.media.models import StatsType 8 | from app.infrastructure.database.repositories.uow import UnitOfWork 9 | from app.media_utils import get_stats_text as get_media_stats_text 10 | from app.states import Stats 11 | from app.typehints import I18nGettext 12 | from structlog import get_logger 13 | from structlog.stdlib import BoundLogger 14 | 15 | logger: BoundLogger = get_logger() 16 | 17 | 18 | async def get_text(_: I18nGettext, **kwargs) -> dict[str, str]: 19 | return { 20 | "select_stats_type_text": _("Select a stats type:"), 21 | "cancel_text": _("Go back"), 22 | } 23 | 24 | 25 | async def get_stats_types( 26 | _: I18nGettext, 27 | **kwargs, 28 | ) -> dict[str, list[tuple[str, str]]]: 29 | stats_types = [ 30 | (_("Media in database"), StatsType.MEDIA.name), 31 | (_("Viewed media by me"), StatsType.VIEWED_BY_ME.name), 32 | (_("Viewed media by all"), StatsType.VIEWED_BY_ALL.name), 33 | ] 34 | 35 | return { 36 | "stats_types": stats_types, 37 | } 38 | 39 | 40 | async def get_stats_text( 41 | _: I18nGettext, 42 | dialog_manager: DialogManager, 43 | **kwargs, 44 | ) -> dict[str, str | bool]: 45 | if stats_text := dialog_manager.data.get("stats_text"): 46 | return { 47 | "stats_text": stats_text, 48 | "has_stats_text": True, 49 | } 50 | return { 51 | "has_stats_text": False, 52 | } 53 | 54 | 55 | async def select_stats_type( 56 | c: CallbackQuery, 57 | button: Radio, 58 | manager: DialogManager, 59 | stats_type_name: str, 60 | ): 61 | uow: UnitOfWork = c.bot["uow"] 62 | _: I18nGettext = c.bot["gettext"] 63 | 64 | if stats_type_name == StatsType.MEDIA.name: 65 | stats = await uow.media.get_media_stats() 66 | elif stats_type_name == StatsType.VIEWED_BY_ME.name: 67 | stats = await uow.media.get_viewed_media_stats_by_tg_id(c.from_user.id) 68 | elif stats_type_name == StatsType.VIEWED_BY_ALL.name: 69 | stats = await uow.media.get_viewed_media_stats() 70 | else: 71 | raise NotImplementedError(f"Stats type `{stats_type_name}` is not implemented") 72 | 73 | manager.data["stats_text"] = get_media_stats_text(stats, _) 74 | 75 | await c.answer(cache_time=1) 76 | 77 | 78 | async def finish_dialog( 79 | c: CallbackQuery, 80 | button: Button, 81 | manager: DialogManager, 82 | ): 83 | await c.message.delete() 84 | await manager.done() 85 | 86 | 87 | stats = Dialog( 88 | Window( 89 | Multi( 90 | Format( 91 | "{stats_text}", 92 | when="has_stats_text", 93 | ), 94 | Format("{select_stats_type_text}"), 95 | sep="\n\n", 96 | ), 97 | Column( 98 | Radio( 99 | checked_text=Format("✓ {item[0]}"), 100 | unchecked_text=Format("{item[0]}"), 101 | id="select_stats_type", 102 | item_id_getter=operator.itemgetter(1), 103 | items="stats_types", 104 | on_click=select_stats_type, # type: ignore 105 | ), 106 | ), 107 | Cancel(Format("{cancel_text}")), 108 | getter=[get_text, get_stats_text, get_stats_types], 109 | state=Stats.select_stats_type, 110 | ), 111 | ) 112 | -------------------------------------------------------------------------------- /locales/bot.pot: -------------------------------------------------------------------------------- 1 | # Translations template for get_anime_bot. 2 | # Copyright (C) 2022 ORGANIZATION 3 | # This file is distributed under the same license as the get_anime_bot 4 | # project. 5 | # FIRST AUTHOR , 2022. 6 | # 7 | #, fuzzy 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: get_anime_bot VERSION\n" 11 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 12 | "POT-Creation-Date: 2022-10-09 16:07+0300\n" 13 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 | "Last-Translator: FULL NAME \n" 15 | "Language-Team: LANGUAGE \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.1\n" 20 | "X-Generator: Poedit 3.1.1\n" 21 | 22 | #: app/dialogs/main_menu.py:27 23 | msgid "Change language" 24 | msgstr "" 25 | 26 | #: app/dialogs/main_menu.py:28 27 | msgid "Change settings" 28 | msgstr "" 29 | 30 | #: app/dialogs/settings.py:14 31 | msgid "Choose your settings:" 32 | msgstr "" 33 | 34 | #: app/media_utils/genres.py:76 35 | msgid "" 36 | "Genres:\n" 37 | "\n" 38 | "{genres}" 39 | msgstr "" 40 | 41 | #: app/dialogs/language.py:17 app/dialogs/settings.py:15 42 | #: app/dialogs/stats.py:21 43 | msgid "Go back" 44 | msgstr "" 45 | 46 | #: app/dialogs/main_menu.py:18 47 | msgid "" 48 | "Hi, {first_name}!\n" 49 | "\n" 50 | "Get an anime GIF or image by genre!\n" 51 | "/genres_gif\n" 52 | "/genres_img\n" 53 | "/genres_all" 54 | msgstr "" 55 | 56 | #: app/dialogs/settings.py:24 57 | msgid "Hide NSFW" 58 | msgstr "" 59 | 60 | #: app/handlers/errors.py:15 61 | msgid "" 62 | "It looks like this message belongs to another person or something went " 63 | "wrong" 64 | msgstr "" 65 | 66 | #: app/dialogs/stats.py:29 67 | msgid "Media in database" 68 | msgstr "" 69 | 70 | #: app/media_utils/genres.py:75 71 | msgid "No genres found" 72 | msgstr "" 73 | 74 | #: app/media_utils/stats.py:20 75 | msgid "No media found" 76 | msgstr "" 77 | 78 | #: app/dialogs/language.py:16 79 | msgid "Select a language:" 80 | msgstr "" 81 | 82 | #: app/dialogs/stats.py:20 83 | msgid "Select a stats type:" 84 | msgstr "" 85 | 86 | #: app/dialogs/settings.py:23 87 | msgid "Show NSFW" 88 | msgstr "" 89 | 90 | #: app/handlers/introduction.py:18 91 | msgid "" 92 | "The bot has open source code!\n" 93 | "\n" 94 | "{source_link}" 95 | msgstr "" 96 | 97 | #: app/media_utils/stats.py:76 98 | msgid "" 99 | "Total count: {total}\n" 100 | "GIF count: {gif}\n" 101 | "\tGenres SFW count:\n" 102 | "{sfw_media_gif_stats_text}\n" 103 | "\tGenres NSFW count:\n" 104 | "{nsfw_media_gif_stats_text}\n" 105 | "IMG count: {img}\n" 106 | "\tGenres SFW count:\n" 107 | "{sfw_media_img_stats_text}\n" 108 | "\tGenres NSFW count:\n" 109 | "{nsfw_media_img_stats_text}\n" 110 | "ALL count: {all}\n" 111 | "\tGenres SFW count:\n" 112 | "{sfw_media_all_stats_text}\n" 113 | "\tGenres NSFW count:\n" 114 | "{nsfw_media_all_stats_text}\n" 115 | "SFW count: {sfw}\n" 116 | "NSFW count: {nsfw}" 117 | msgstr "" 118 | 119 | #: app/dialogs/main_menu.py:29 120 | msgid "View statistics" 121 | msgstr "" 122 | 123 | #: app/dialogs/stats.py:31 124 | msgid "Viewed media by all" 125 | msgstr "" 126 | 127 | #: app/dialogs/stats.py:30 128 | msgid "Viewed media by me" 129 | msgstr "" 130 | 131 | #: app/handlers/media.py:153 132 | msgid "You aren't allowed to view NSFW-content publicly!" 133 | msgstr "" 134 | 135 | #: app/handlers/media.py:144 136 | msgid "" 137 | "You aren't allowed to view NSFW-content!\n" 138 | "\n" 139 | "/settings — change settings" 140 | msgstr "" 141 | 142 | #: app/handlers/media.py:82 143 | msgid "You've viewed all media on this genre. Try again later!" 144 | msgstr "" 145 | -------------------------------------------------------------------------------- /locales/en/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # Translations template for get_anime_bot. 2 | # Copyright (C) 2022 ORGANIZATION 3 | # This file is distributed under the same license as the get_anime_bot 4 | # project. 5 | # FIRST AUTHOR , 2022. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: get_anime_bot VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2022-10-09 16:07+0300\n" 12 | "PO-Revision-Date: 2022-10-09 16:07+0300\n" 13 | "Last-Translator: \n" 14 | "Language-Team: \n" 15 | "Language: en\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | "Generated-By: Babel 2.9.1\n" 21 | "X-Generator: Poedit 3.1.1\n" 22 | 23 | #: app/dialogs/main_menu.py:27 24 | msgid "Change language" 25 | msgstr "" 26 | 27 | #: app/dialogs/main_menu.py:28 28 | msgid "Change settings" 29 | msgstr "" 30 | 31 | #: app/dialogs/settings.py:14 32 | msgid "Choose your settings:" 33 | msgstr "" 34 | 35 | #: app/media_utils/genres.py:76 36 | msgid "" 37 | "Genres:\n" 38 | "\n" 39 | "{genres}" 40 | msgstr "" 41 | 42 | #: app/dialogs/language.py:17 app/dialogs/settings.py:15 43 | #: app/dialogs/stats.py:21 44 | msgid "Go back" 45 | msgstr "" 46 | 47 | #: app/dialogs/main_menu.py:18 48 | msgid "" 49 | "Hi, {first_name}!\n" 50 | "\n" 51 | "Get an anime GIF or image by genre!\n" 52 | "/genres_gif\n" 53 | "/genres_img\n" 54 | "/genres_all" 55 | msgstr "" 56 | 57 | #: app/dialogs/settings.py:24 58 | msgid "Hide NSFW" 59 | msgstr "" 60 | 61 | #: app/handlers/errors.py:15 62 | msgid "It looks like this message belongs to another person or something went wrong" 63 | msgstr "" 64 | 65 | #: app/dialogs/stats.py:29 66 | msgid "Media in database" 67 | msgstr "" 68 | 69 | #: app/media_utils/genres.py:75 70 | msgid "No genres found" 71 | msgstr "" 72 | 73 | #: app/media_utils/stats.py:20 74 | msgid "No media found" 75 | msgstr "" 76 | 77 | #: app/dialogs/language.py:16 78 | msgid "Select a language:" 79 | msgstr "" 80 | 81 | #: app/dialogs/stats.py:20 82 | msgid "Select a stats type:" 83 | msgstr "" 84 | 85 | #: app/dialogs/settings.py:23 86 | msgid "Show NSFW" 87 | msgstr "" 88 | 89 | #: app/handlers/introduction.py:18 90 | msgid "" 91 | "The bot has open source code!\n" 92 | "\n" 93 | "{source_link}" 94 | msgstr "" 95 | 96 | #: app/media_utils/stats.py:76 97 | msgid "" 98 | "Total count: {total}\n" 99 | "GIF count: {gif}\n" 100 | "\tGenres SFW count:\n" 101 | "{sfw_media_gif_stats_text}\n" 102 | "\tGenres NSFW count:\n" 103 | "{nsfw_media_gif_stats_text}\n" 104 | "IMG count: {img}\n" 105 | "\tGenres SFW count:\n" 106 | "{sfw_media_img_stats_text}\n" 107 | "\tGenres NSFW count:\n" 108 | "{nsfw_media_img_stats_text}\n" 109 | "ALL count: {all}\n" 110 | "\tGenres SFW count:\n" 111 | "{sfw_media_all_stats_text}\n" 112 | "\tGenres NSFW count:\n" 113 | "{nsfw_media_all_stats_text}\n" 114 | "SFW count: {sfw}\n" 115 | "NSFW count: {nsfw}" 116 | msgstr "" 117 | 118 | #: app/dialogs/main_menu.py:29 119 | msgid "View statistics" 120 | msgstr "" 121 | 122 | #: app/dialogs/stats.py:31 123 | msgid "Viewed media by all" 124 | msgstr "" 125 | 126 | #: app/dialogs/stats.py:30 127 | msgid "Viewed media by me" 128 | msgstr "" 129 | 130 | #: app/handlers/media.py:153 131 | msgid "You aren't allowed to view NSFW-content publicly!" 132 | msgstr "" 133 | 134 | #: app/handlers/media.py:144 135 | msgid "" 136 | "You aren't allowed to view NSFW-content!\n" 137 | "\n" 138 | "/settings — change settings" 139 | msgstr "" 140 | 141 | #: app/handlers/media.py:82 142 | msgid "You've viewed all media on this genre. Try again later!" 143 | msgstr "" 144 | -------------------------------------------------------------------------------- /app/infrastructure/media/nekos_life/client.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.media.base import MediaSource 2 | from app.infrastructure.media.base.exceptions import GenreNotFound 3 | from app.infrastructure.media.base.schemas import Media, MediaGenre 4 | from app.infrastructure.media.base.typehints import MediaGenreType, MediaUrlType 5 | from structlog import get_logger 6 | from structlog.stdlib import BoundLogger 7 | 8 | logger: BoundLogger = get_logger() 9 | 10 | 11 | class NekosLife(MediaSource): 12 | SOURCE_URL = "https://api.nekos.dev/api/v3/images" 13 | SOURCE_ID = "nl" 14 | 15 | RAW_SFW_GENRES: dict[str, set[str]] = { 16 | "gif": { 17 | "tickle", 18 | "poke", 19 | "kiss", 20 | "slap", 21 | "cuddle", 22 | "hug", 23 | "pat", 24 | "feed", 25 | "neko", 26 | "smug", 27 | "baka", 28 | }, 29 | "img": { 30 | "neko", 31 | "kitsune", 32 | "holo", 33 | "wallpaper", 34 | }, 35 | "all": set(), 36 | } 37 | RAW_NSFW_GENRES: dict[str, set[str]] = { 38 | "gif": set(), 39 | "img": set(), 40 | "all": set(), 41 | } 42 | 43 | async def get_media( 44 | self, 45 | genre: MediaGenreType, 46 | count: int = 1, 47 | ) -> list[Media]: 48 | """ 49 | Get a media url for a given genre. 50 | 51 | :genre: genre to get a media url for 52 | :count (optional): number of media to get. 1 <= count <= 20 53 | """ 54 | if genre not in self.genres: 55 | raise GenreNotFound 56 | 57 | if count == 1: 58 | # the source handling the error by 1. 59 | # 0 is good, but `count=0` is 1 url and `count=2` is 2 urls ^_^ 60 | count = 0 61 | 62 | genre_model = self.parse_genre(genre) 63 | 64 | if genre_model.is_sfw: 65 | url = self.generate_sfw_url(genre_model) 66 | else: 67 | url = self.generate_nsfw_url(genre_model) 68 | 69 | response = await self.session.get(url=url, params={"count": count}) 70 | response.raise_for_status() 71 | 72 | result = await response.json() 73 | 74 | if url := result["data"]["response"].get("url"): 75 | return [ 76 | Media( 77 | url=url, 78 | media_type=genre_model.media_type, 79 | is_sfw=genre_model.is_sfw, 80 | raw_genre=genre_model.raw_genre, 81 | ) 82 | ] 83 | return [ 84 | Media( 85 | url=url, 86 | media_type=genre_model.media_type, 87 | is_sfw=genre_model.is_sfw, 88 | raw_genre=genre_model.raw_genre, 89 | ) 90 | for url in result["data"]["response"]["urls"] 91 | ] 92 | 93 | def generate_sfw_url(self, genre_model: MediaGenre) -> MediaUrlType: 94 | """ 95 | Generate a sfw url for a given genre model. 96 | 97 | :genre_model: genre model to generate a url for 98 | """ 99 | url = "{source_url}/sfw/{media_type}/{raw_genre}".format( 100 | source_url=self.SOURCE_URL, 101 | media_type=genre_model.media_type, 102 | raw_genre=genre_model.raw_genre, 103 | ) 104 | 105 | return url 106 | 107 | def generate_nsfw_url(self, genre_model: MediaGenre) -> MediaUrlType: 108 | """ 109 | Generate a nsfw url for a given genre model. 110 | 111 | :genre_model: genre model to generate a url for 112 | """ 113 | url = "{source_url}/nsfw/{media_type}/{raw_genre}".format( 114 | source_url=self.SOURCE_URL, 115 | media_type=genre_model.media_type, 116 | raw_genre=genre_model.raw_genre, 117 | ) 118 | 119 | return url 120 | -------------------------------------------------------------------------------- /app/infrastructure/media/waifu_pics/client.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.media.base import MediaSource 2 | from app.infrastructure.media.base.exceptions import GenreNotFound 3 | from app.infrastructure.media.base.schemas import Media, MediaGenre 4 | from app.infrastructure.media.base.typehints import MediaGenreType, MediaUrlType 5 | from structlog import get_logger 6 | from structlog.stdlib import BoundLogger 7 | 8 | logger: BoundLogger = get_logger() 9 | 10 | 11 | class WaifuPics(MediaSource): 12 | SOURCE_URL = "https://api.waifu.pics" 13 | SOURCE_ID = "wp" 14 | 15 | RAW_SFW_GENRES: dict[str, set[str]] = { 16 | "gif": { 17 | "bully", 18 | "cuddle", 19 | "cry", 20 | "hug", 21 | "kiss", 22 | "lick", 23 | "pat", 24 | "smug", 25 | "bonk", 26 | "yeet", 27 | "blush", 28 | "smile", 29 | "wave", 30 | "nom", 31 | "bite", 32 | "glomp", 33 | "slap", 34 | "kill", 35 | "kick", 36 | "happy", 37 | "wink", 38 | "poke", 39 | "dance", 40 | "cringe", 41 | }, 42 | "img": { 43 | "waifu", 44 | "neko", 45 | "shinobu", 46 | "megumin", 47 | "awoo", 48 | }, 49 | "all": set(), 50 | } 51 | RAW_NSFW_GENRES: dict[str, set[str]] = { 52 | "gif": { 53 | "blowjob", 54 | }, 55 | "img": { 56 | "waifu", 57 | "neko", 58 | "trap", 59 | }, 60 | "all": set(), 61 | } 62 | 63 | async def get_media( 64 | self, 65 | genre: MediaGenreType, 66 | count: int = 1, 67 | ) -> list[Media]: 68 | """ 69 | Get a media url for a given genre. 70 | 71 | :genre: genre to get a media url for 72 | :count (optional): number of media to get. 1 <= count <= 30 73 | """ 74 | if genre not in self.genres: 75 | raise GenreNotFound 76 | 77 | genre_model = self.parse_genre(genre) 78 | 79 | many = count > 1 80 | 81 | if genre_model.is_sfw: 82 | url = self.generate_sfw_url(genre_model, many) 83 | else: 84 | url = self.generate_nsfw_url(genre_model, many) 85 | 86 | if many: 87 | response = await self.session.post(url=url, json={"exclude": []}) 88 | else: 89 | response = await self.session.get(url=url) 90 | response.raise_for_status() 91 | 92 | result = await response.json() 93 | 94 | if many: 95 | return [ 96 | Media( 97 | url=url, 98 | media_type=genre_model.media_type, 99 | is_sfw=genre_model.is_sfw, 100 | raw_genre=genre_model.raw_genre, 101 | ) 102 | for url in result["files"][:many] 103 | ] 104 | else: 105 | return [ 106 | Media( 107 | url=result["url"], 108 | media_type=genre_model.media_type, 109 | is_sfw=genre_model.is_sfw, 110 | raw_genre=genre_model.raw_genre, 111 | ) 112 | ] 113 | 114 | def generate_sfw_url( 115 | self, 116 | genre_model: MediaGenre, 117 | many: bool = False, 118 | ) -> MediaUrlType: 119 | """ 120 | Generate a sfw url for a given genre model. 121 | 122 | :genre_model: genre model to generate a url for 123 | :many: whether to generate a url for multiple media 124 | """ 125 | if many: 126 | url = "{source_url}/many/sfw/{raw_genre}".format( 127 | source_url=self.SOURCE_URL, 128 | raw_genre=genre_model.raw_genre, 129 | ) 130 | else: 131 | url = "{source_url}/sfw/{raw_genre}".format( 132 | source_url=self.SOURCE_URL, 133 | raw_genre=genre_model.raw_genre, 134 | ) 135 | 136 | return url 137 | 138 | def generate_nsfw_url( 139 | self, 140 | genre_model: MediaGenre, 141 | many: bool = False, 142 | ) -> MediaUrlType: 143 | """ 144 | Generate a nsfw url for a given genre model. 145 | 146 | :genre_model: genre model to generate a url for 147 | :many: whether to generate a url for multiple media 148 | """ 149 | if many: 150 | url = "{source_url}/many/nsfw/{raw_genre}".format( 151 | source_url=self.SOURCE_URL, 152 | raw_genre=genre_model.raw_genre, 153 | ) 154 | else: 155 | url = "{source_url}/nsfw/{raw_genre}".format( 156 | source_url=self.SOURCE_URL, 157 | raw_genre=genre_model.raw_genre, 158 | ) 159 | 160 | return url 161 | -------------------------------------------------------------------------------- /locales/be/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # Translations template for get_anime_bot. 2 | # Copyright (C) 2022 ORGANIZATION 3 | # This file is distributed under the same license as the get_anime_bot 4 | # project. 5 | # FIRST AUTHOR , 2022. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: get_anime_bot VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2022-10-09 16:07+0300\n" 12 | "PO-Revision-Date: 2022-10-10 19:56+0300\n" 13 | "Last-Translator: \n" 14 | "Language-Team: \n" 15 | "Language: be\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" 20 | "Generated-By: Babel 2.9.1\n" 21 | "X-Generator: Poedit 3.1.1\n" 22 | 23 | #: app/dialogs/main_menu.py:27 24 | msgid "Change language" 25 | msgstr "Змяніць мову" 26 | 27 | #: app/dialogs/main_menu.py:28 28 | msgid "Change settings" 29 | msgstr "Змяніць настройкі" 30 | 31 | #: app/dialogs/settings.py:14 32 | msgid "Choose your settings:" 33 | msgstr "Абярыце настройкі:" 34 | 35 | #: app/media_utils/genres.py:76 36 | msgid "" 37 | "Genres:\n" 38 | "\n" 39 | "{genres}" 40 | msgstr "" 41 | "Жанры: \n" 42 | "\n" 43 | "{genres}" 44 | 45 | #: app/dialogs/language.py:17 app/dialogs/settings.py:15 46 | #: app/dialogs/stats.py:21 47 | msgid "Go back" 48 | msgstr "Вярнуцца" 49 | 50 | #: app/dialogs/main_menu.py:18 51 | msgid "" 52 | "Hi, {first_name}!\n" 53 | "\n" 54 | "Get an anime GIF or image by genre!\n" 55 | "/genres_gif\n" 56 | "/genres_img\n" 57 | "/genres_all" 58 | msgstr "" 59 | "Прывітанне, {first_name}!\n" 60 | "\n" 61 | "Атрымай анімэ гіфку ці малюнак па жанру!\n" 62 | "/genres_gif\n" 63 | "/genres_img\n" 64 | "/genres_all" 65 | 66 | #: app/dialogs/settings.py:24 67 | msgid "Hide NSFW" 68 | msgstr "Схаваць NSFW" 69 | 70 | #: app/handlers/errors.py:15 71 | msgid "It looks like this message belongs to another person or something went wrong" 72 | msgstr "Здаецца, што гэты сказ належыць іншаму чалавеку, або нешта пайшло не так" 73 | 74 | #: app/dialogs/stats.py:29 75 | msgid "Media in database" 76 | msgstr "Медыя ў базе дадзеных" 77 | 78 | #: app/media_utils/genres.py:75 79 | msgid "No genres found" 80 | msgstr "Жанраў не знойдзена" 81 | 82 | #: app/media_utils/stats.py:20 83 | msgid "No media found" 84 | msgstr "Медыя не знойдзена" 85 | 86 | #: app/dialogs/language.py:16 87 | msgid "Select a language:" 88 | msgstr "Абярыце мову:" 89 | 90 | #: app/dialogs/stats.py:20 91 | msgid "Select a stats type:" 92 | msgstr "Абярыце тып статыстыкі:" 93 | 94 | #: app/dialogs/settings.py:23 95 | msgid "Show NSFW" 96 | msgstr "Паказваць NSFW" 97 | 98 | #: app/handlers/introduction.py:18 99 | msgid "" 100 | "The bot has open source code!\n" 101 | "\n" 102 | "{source_link}" 103 | msgstr "" 104 | "Бот мае адкрыты зыходны код!\n" 105 | "\n" 106 | "{source_link}" 107 | 108 | #: app/media_utils/stats.py:76 109 | msgid "" 110 | "Total count: {total}\n" 111 | "GIF count: {gif}\n" 112 | "\tGenres SFW count:\n" 113 | "{sfw_media_gif_stats_text}\n" 114 | "\tGenres NSFW count:\n" 115 | "{nsfw_media_gif_stats_text}\n" 116 | "IMG count: {img}\n" 117 | "\tGenres SFW count:\n" 118 | "{sfw_media_img_stats_text}\n" 119 | "\tGenres NSFW count:\n" 120 | "{nsfw_media_img_stats_text}\n" 121 | "ALL count: {all}\n" 122 | "\tGenres SFW count:\n" 123 | "{sfw_media_all_stats_text}\n" 124 | "\tGenres NSFW count:\n" 125 | "{nsfw_media_all_stats_text}\n" 126 | "SFW count: {sfw}\n" 127 | "NSFW count: {nsfw}" 128 | msgstr "" 129 | "Агульная колькасць: {total}\n" 130 | "У тым ліку {sfw} SFW і {nsfw} NSFW\n" 131 | "\n" 132 | "Усяго GIF: {gif}\n" 133 | "\tУ тым ліку SFW жанраў:\n" 134 | "{sfw_media_gif_stats_text}\n" 135 | "\tУ тым ліку NSFW жанраў:\n" 136 | "{nsfw_media_gif_stats_text}\n" 137 | "Усяго IMG: {img}\n" 138 | "\tУ тым ліку SFW жанраў:\n" 139 | "{sfw_media_img_stats_text}\n" 140 | "\tУ тым ліку NSFW жанраў:\n" 141 | "{nsfw_media_img_stats_text}\n" 142 | "Усяго ALL: {all}\n" 143 | "\tУ тым ліку SFW жанраў:\n" 144 | "{sfw_media_all_stats_text}\n" 145 | "\tУ тым ліку NSFW жанраў:\n" 146 | "{nsfw_media_all_stats_text}" 147 | 148 | #: app/dialogs/main_menu.py:29 149 | msgid "View statistics" 150 | msgstr "Паглядзець статыстыку" 151 | 152 | #: app/dialogs/stats.py:31 153 | msgid "Viewed media by all" 154 | msgstr "Прагледжаныя ўсімі медыя" 155 | 156 | #: app/dialogs/stats.py:30 157 | msgid "Viewed media by me" 158 | msgstr "Прагледжаныя мною медыя" 159 | 160 | #: app/handlers/media.py:153 161 | msgid "You aren't allowed to view NSFW-content publicly!" 162 | msgstr "Вам не дазволена глядзець NFSW публічна!" 163 | 164 | #: app/handlers/media.py:144 165 | msgid "" 166 | "You aren't allowed to view NSFW-content!\n" 167 | "\n" 168 | "/settings — change settings" 169 | msgstr "" 170 | "Вам не дазволена глядзець NSFW!\n" 171 | "\n" 172 | "/settings — змяніць у настройках" 173 | 174 | #: app/handlers/media.py:82 175 | msgid "You've viewed all media on this genre. Try again later!" 176 | msgstr "Вы праглядзелі ўсе медыя гэтага жанру. Паспрабуйце пазней!" 177 | -------------------------------------------------------------------------------- /locales/ru/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # Translations template for get_anime_bot. 2 | # Copyright (C) 2022 ORGANIZATION 3 | # This file is distributed under the same license as the get_anime_bot 4 | # project. 5 | # FIRST AUTHOR , 2022. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: get_anime_bot VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2022-10-09 15:56+0300\n" 12 | "PO-Revision-Date: 2022-10-09 16:06+0300\n" 13 | "Last-Translator: \n" 14 | "Language-Team: \n" 15 | "Language: ru\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" 20 | "Generated-By: Babel 2.9.1\n" 21 | "X-Generator: Poedit 3.1.1\n" 22 | 23 | #: app/dialogs/main_menu.py:27 24 | msgid "Change language" 25 | msgstr "Изменить язык" 26 | 27 | #: app/dialogs/main_menu.py:28 28 | msgid "Change settings" 29 | msgstr "Изменить настройки" 30 | 31 | #: app/dialogs/settings.py:14 32 | msgid "Choose your settings:" 33 | msgstr "Выберите настройки:" 34 | 35 | #: app/media_utils/genres.py:76 36 | msgid "" 37 | "Genres:\n" 38 | "\n" 39 | "{genres}" 40 | msgstr "" 41 | "Жанры:\n" 42 | "\n" 43 | "{genres}" 44 | 45 | #: app/dialogs/language.py:17 app/dialogs/settings.py:15 46 | #: app/dialogs/stats.py:21 47 | msgid "Go back" 48 | msgstr "Вернуться назад" 49 | 50 | #: app/dialogs/main_menu.py:18 51 | msgid "" 52 | "Hi, {first_name}!\n" 53 | "\n" 54 | "Get an anime GIF or image by genre!\n" 55 | "/genres_gif\n" 56 | "/genres_img\n" 57 | "/genres_all" 58 | msgstr "" 59 | "Привет, {first_name}!\n" 60 | "\n" 61 | "Получи аниме-гифку или изображение по жанру!\n" 62 | "/genres_gif\n" 63 | "/genres_img\n" 64 | "/genres_all" 65 | 66 | #: app/dialogs/settings.py:24 67 | msgid "Hide NSFW" 68 | msgstr "Скрывать NSFW" 69 | 70 | #: app/handlers/errors.py:15 71 | msgid "It looks like this message belongs to another person or something went wrong" 72 | msgstr "Похоже, что это сообщение принадлежит другому человеку или что-то пошло не так" 73 | 74 | #: app/dialogs/stats.py:29 75 | msgid "Media in database" 76 | msgstr "Медиа в базе" 77 | 78 | #: app/media_utils/genres.py:75 79 | msgid "No genres found" 80 | msgstr "Жанров не найдено" 81 | 82 | #: app/media_utils/stats.py:20 83 | msgid "No media found" 84 | msgstr "Медиа не найдено" 85 | 86 | #: app/dialogs/language.py:16 87 | msgid "Select a language:" 88 | msgstr "Выберите язык:" 89 | 90 | #: app/dialogs/stats.py:20 91 | msgid "Select a stats type:" 92 | msgstr "Выберите тип статистики:" 93 | 94 | #: app/dialogs/settings.py:23 95 | msgid "Show NSFW" 96 | msgstr "Показывать NSFW" 97 | 98 | #: app/handlers/introduction.py:18 99 | msgid "" 100 | "The bot has open source code!\n" 101 | "\n" 102 | "{source_link}" 103 | msgstr "" 104 | "Бот имеет открытый исходный код!\n" 105 | "\n" 106 | "{source_link}" 107 | 108 | #: app/media_utils/stats.py:76 109 | msgid "" 110 | "Total count: {total}\n" 111 | "GIF count: {gif}\n" 112 | "\tGenres SFW count:\n" 113 | "{sfw_media_gif_stats_text}\n" 114 | "\tGenres NSFW count:\n" 115 | "{nsfw_media_gif_stats_text}\n" 116 | "IMG count: {img}\n" 117 | "\tGenres SFW count:\n" 118 | "{sfw_media_img_stats_text}\n" 119 | "\tGenres NSFW count:\n" 120 | "{nsfw_media_img_stats_text}\n" 121 | "ALL count: {all}\n" 122 | "\tGenres SFW count:\n" 123 | "{sfw_media_all_stats_text}\n" 124 | "\tGenres NSFW count:\n" 125 | "{nsfw_media_all_stats_text}\n" 126 | "SFW count: {sfw}\n" 127 | "NSFW count: {nsfw}" 128 | msgstr "" 129 | "Общее количество: {total}\n" 130 | "Количество GIF: {gif}\n" 131 | "\tКоличества SFW жанров:\n" 132 | "{sfw_media_gif_stats_text}\n" 133 | "\tКоличества NSFW жанров:\n" 134 | "{nsfw_media_gif_stats_text}\n" 135 | "Количество IMG: {img}\n" 136 | "\tКоличества SFW жанров:\n" 137 | "{sfw_media_img_stats_text}\n" 138 | "\tКоличества NSFW жанров:\n" 139 | "{nsfw_media_img_stats_text}\n" 140 | "Количество ALL: {all}\n" 141 | "\tКоличества SFW жанров:\n" 142 | "{sfw_media_all_stats_text}\n" 143 | "\tКоличества NSFW жанров:\n" 144 | "{nsfw_media_all_stats_text}\n" 145 | "Количество SFW: {sfw}\n" 146 | "Количество NSFW: {nsfw}" 147 | 148 | #: app/dialogs/main_menu.py:29 149 | msgid "View statistics" 150 | msgstr "Посмотреть статистику" 151 | 152 | #: app/dialogs/stats.py:31 153 | msgid "Viewed media by all" 154 | msgstr "Просмотренные всеми медиа" 155 | 156 | #: app/dialogs/stats.py:30 157 | msgid "Viewed media by me" 158 | msgstr "Просмотренные мной медиа" 159 | 160 | #: app/handlers/media.py:153 161 | msgid "You aren't allowed to view NSFW-content publicly!" 162 | msgstr "Вы не можете просматривать NSFW-контент публично!" 163 | 164 | #: app/handlers/media.py:144 165 | msgid "" 166 | "You aren't allowed to view NSFW-content!\n" 167 | "\n" 168 | "/settings — change settings" 169 | msgstr "" 170 | "Вы не можете просматривать NSFW-контент!\n" 171 | "\n" 172 | "/settings — изменить настройки" 173 | 174 | #: app/handlers/media.py:82 175 | msgid "You've viewed all media on this genre. Try again later!" 176 | msgstr "Вы просмотрели все медиа с этим жанром. Попробуйте снова позже!" 177 | -------------------------------------------------------------------------------- /locales/ua/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # Translations template for get_anime_bot. 2 | # Copyright (C) 2022 ORGANIZATION 3 | # This file is distributed under the same license as the get_anime_bot 4 | # project. 5 | # FIRST AUTHOR , 2022. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: get_anime_bot VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2022-10-09 16:07+0300\n" 12 | "PO-Revision-Date: 2022-10-09 16:22+0300\n" 13 | "Last-Translator: \n" 14 | "Language-Team: \n" 15 | "Language: uk\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" 20 | "Generated-By: Babel 2.9.1\n" 21 | "X-Generator: Poedit 3.1.1\n" 22 | 23 | #: app/dialogs/main_menu.py:27 24 | msgid "Change language" 25 | msgstr "Виберіть мову" 26 | 27 | #: app/dialogs/main_menu.py:28 28 | msgid "Change settings" 29 | msgstr "Виберіть налаштування" 30 | 31 | #: app/dialogs/settings.py:14 32 | msgid "Choose your settings:" 33 | msgstr "Виберіть налаштування:" 34 | 35 | #: app/media_utils/genres.py:76 36 | msgid "" 37 | "Genres:\n" 38 | "\n" 39 | "{genres}" 40 | msgstr "" 41 | "Жанри:\n" 42 | "\n" 43 | "{genres}" 44 | 45 | #: app/dialogs/language.py:17 app/dialogs/settings.py:15 46 | #: app/dialogs/stats.py:21 47 | msgid "Go back" 48 | msgstr "Повернутися" 49 | 50 | #: app/dialogs/main_menu.py:18 51 | msgid "" 52 | "Hi, {first_name}!\n" 53 | "\n" 54 | "Get an anime GIF or image by genre!\n" 55 | "/genres_gif\n" 56 | "/genres_img\n" 57 | "/genres_all" 58 | msgstr "" 59 | "Привіт, {first_name}!\n" 60 | "\n" 61 | "Отримайте GIF аніме або зображення за жанром!\n" 62 | "/genres_gif\n" 63 | "/genres_img\n" 64 | "/genres_all" 65 | 66 | #: app/dialogs/settings.py:24 67 | msgid "Hide NSFW" 68 | msgstr "Сховати NSFW" 69 | 70 | #: app/handlers/errors.py:15 71 | msgid "It looks like this message belongs to another person or something went wrong" 72 | msgstr "Здається, це повідомлення належить іншій особі або щось пішло не так :(" 73 | 74 | #: app/dialogs/stats.py:29 75 | msgid "Media in database" 76 | msgstr "Медіа в базі даних" 77 | 78 | #: app/media_utils/genres.py:75 79 | msgid "No genres found" 80 | msgstr "Нема такого жанру" 81 | 82 | #: app/media_utils/stats.py:20 83 | msgid "No media found" 84 | msgstr "Медіа не знайдено" 85 | 86 | #: app/dialogs/language.py:16 87 | msgid "Select a language:" 88 | msgstr "Виберіть мову:" 89 | 90 | #: app/dialogs/stats.py:20 91 | msgid "Select a stats type:" 92 | msgstr "Виберіть тип статистики:" 93 | 94 | #: app/dialogs/settings.py:23 95 | msgid "Show NSFW" 96 | msgstr "Показувати NSFW" 97 | 98 | #: app/handlers/introduction.py:18 99 | msgid "" 100 | "The bot has open source code!\n" 101 | "\n" 102 | "{source_link}" 103 | msgstr "" 104 | "Бот має відкритий код!\n" 105 | "\n" 106 | "{source_link}" 107 | 108 | #: app/media_utils/stats.py:76 109 | msgid "" 110 | "Total count: {total}\n" 111 | "GIF count: {gif}\n" 112 | "\tGenres SFW count:\n" 113 | "{sfw_media_gif_stats_text}\n" 114 | "\tGenres NSFW count:\n" 115 | "{nsfw_media_gif_stats_text}\n" 116 | "IMG count: {img}\n" 117 | "\tGenres SFW count:\n" 118 | "{sfw_media_img_stats_text}\n" 119 | "\tGenres NSFW count:\n" 120 | "{nsfw_media_img_stats_text}\n" 121 | "ALL count: {all}\n" 122 | "\tGenres SFW count:\n" 123 | "{sfw_media_all_stats_text}\n" 124 | "\tGenres NSFW count:\n" 125 | "{nsfw_media_all_stats_text}\n" 126 | "SFW count: {sfw}\n" 127 | "NSFW count: {nsfw}" 128 | msgstr "" 129 | "Загальна кількість: {total}\n" 130 | "Кількість GIF: {gif}\n" 131 | "\tЗагальна кількість SFW жанрів:\n" 132 | "{sfw_media_gif_stats_text}\n" 133 | "\tЗагальна кількість NSFW жанрів:\n" 134 | "{nsfw_media_gif_stats_text}\n" 135 | "Кількість IMG: {img}\n" 136 | "\tЗагальна кількість SFW жанрів:\n" 137 | "{sfw_media_img_stats_text}\n" 138 | "\tЗагальна кількість NSFW жанрів:\n" 139 | "{nsfw_media_img_stats_text}\n" 140 | "Кількість ALL: {all}\n" 141 | "\tЗагальна кількість SFW жанрів:\n" 142 | "{sfw_media_all_stats_text}\n" 143 | "\tЗагальна кількість NSFW жанрів:\n" 144 | "{nsfw_media_all_stats_text}\n" 145 | "Кількість SFW: {sfw}\n" 146 | "Кількість NSFW: {nsfw}" 147 | 148 | #: app/dialogs/main_menu.py:29 149 | msgid "View statistics" 150 | msgstr "Переглянути статистику" 151 | 152 | #: app/dialogs/stats.py:31 153 | msgid "Viewed media by all" 154 | msgstr "Переглянуті медіа всіма" 155 | 156 | #: app/dialogs/stats.py:30 157 | msgid "Viewed media by me" 158 | msgstr "Переглянуті мною медіа" 159 | 160 | #: app/handlers/media.py:153 161 | msgid "You aren't allowed to view NSFW-content publicly!" 162 | msgstr "Вам не дозволено переглядати NSFW контент публічно!" 163 | 164 | #: app/handlers/media.py:144 165 | msgid "" 166 | "You aren't allowed to view NSFW-content!\n" 167 | "\n" 168 | "/settings — change settings" 169 | msgstr "" 170 | "Вам не дозволено переглядати NSFW контент!\n" 171 | "\n" 172 | "/settings — виберіть налаштування" 173 | 174 | #: app/handlers/media.py:82 175 | msgid "You've viewed all media on this genre. Try again later!" 176 | msgstr "Ви подивилися усі медіа цього жанру. Спробуйте пізніше!" 177 | -------------------------------------------------------------------------------- /app/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Bot, Dispatcher 4 | from aiogram.contrib.fsm_storage.memory import MemoryStorage 5 | from aiogram.contrib.middlewares.environment import EnvironmentMiddleware 6 | from aiogram.types import ( 7 | BotCommand, 8 | BotCommandScopeAllGroupChats, 9 | BotCommandScopeAllPrivateChats, 10 | ) 11 | from aiogram_dialog import DialogRegistry 12 | from sqlalchemy.orm import sessionmaker 13 | from structlog import get_logger 14 | from structlog.stdlib import BoundLogger 15 | 16 | from app.config_reader import load_config 17 | from app.constants import LOCALES_DIR 18 | from app.dialogs import language_dialog, main_menu_dialog, settings_dialog, stats_dialog 19 | from app.handlers import ( 20 | register_error_handlers, 21 | register_genre_handlers, 22 | register_introduction_handlers, 23 | ) 24 | from app.infrastructure.database import make_connection_string, sa_sessionmaker 25 | from app.infrastructure.media import MediaSource, NekosFun, NekosLife, WaifuPics 26 | from app.infrastructure.scheduler import start_parse_media 27 | from app.language_utils.language import DEFAULT_LANGUAGE 28 | from app.logging_config import logging_configure 29 | from app.middlewares import ACLMiddleware, DatabaseMiddleware, I18nMiddleware 30 | 31 | logger: BoundLogger = get_logger() 32 | 33 | 34 | async def set_bot_commands(bot: Bot): 35 | cmd_help = BotCommand( 36 | command="help", 37 | description="Show menu", 38 | ) 39 | cmd_gif = BotCommand( 40 | command="genres_gif", 41 | description="GIF by genre", 42 | ) 43 | cmd_img = BotCommand( 44 | command="genres_img", 45 | description="image by genre", 46 | ) 47 | cmd_all = BotCommand( 48 | command="genres_all", 49 | description=" GIF or image by genre", 50 | ) 51 | cmd_source = BotCommand( 52 | command="source", 53 | description="Show source code", 54 | ) 55 | cmd_language = BotCommand( 56 | command="language", 57 | description="Change language", 58 | ) 59 | cmd_settings = BotCommand( 60 | command="settings", 61 | description="Change settings", 62 | ) 63 | cmd_stats = BotCommand( 64 | command="stats", 65 | description="Show statistics", 66 | ) 67 | 68 | public = [ 69 | cmd_help, 70 | cmd_gif, 71 | cmd_img, 72 | cmd_all, 73 | ] 74 | private = [ 75 | cmd_help, 76 | cmd_gif, 77 | cmd_img, 78 | cmd_all, 79 | cmd_source, 80 | cmd_language, 81 | cmd_settings, 82 | cmd_stats, 83 | ] 84 | 85 | await bot.set_my_commands(public, BotCommandScopeAllGroupChats()) 86 | await bot.set_my_commands(private, BotCommandScopeAllPrivateChats()) 87 | 88 | 89 | async def start_scheduler( 90 | sources: set[MediaSource], 91 | sa_sessionmaker: sessionmaker, 92 | ): 93 | await start_parse_media(sources, sa_sessionmaker) 94 | 95 | 96 | def setup_middlewares( 97 | dp: Dispatcher, 98 | sources: set[MediaSource], 99 | sm: sessionmaker, 100 | ): 101 | dp.setup_middleware(DatabaseMiddleware(sm)) 102 | dp.setup_middleware(ACLMiddleware()) 103 | dp.setup_middleware( 104 | I18nMiddleware( 105 | domain="bot", 106 | path=LOCALES_DIR, 107 | default=DEFAULT_LANGUAGE.code, 108 | ) 109 | ) 110 | dp.setup_middleware(EnvironmentMiddleware({"sources": sources})) 111 | 112 | 113 | def register_handlers(dp: Dispatcher, sources: set[MediaSource]): 114 | register_introduction_handlers(dp) 115 | register_genre_handlers(dp, sources) 116 | register_error_handlers(dp) 117 | logger.info("Handlers are registered") 118 | 119 | 120 | def register_dialogs(dr: DialogRegistry): 121 | dr.register(main_menu_dialog) 122 | dr.register(language_dialog) 123 | dr.register(settings_dialog) 124 | dr.register(stats_dialog) 125 | logger.info("Dialogs are registered") 126 | 127 | 128 | async def main(): 129 | logging_configure() 130 | logger.info("Logging is configured") 131 | 132 | config = load_config() 133 | logger.info("Configuration loaded") 134 | 135 | bot = Bot( 136 | token=config.bot.token, 137 | parse_mode=None, 138 | disable_web_page_preview=None, 139 | ) 140 | dp = Dispatcher( 141 | bot=bot, 142 | storage=MemoryStorage(), 143 | ) 144 | dr = DialogRegistry(dp) 145 | 146 | nekos_life = NekosLife() 147 | nekos_fun = NekosFun() 148 | waifu_pics = WaifuPics() 149 | 150 | sources: set[MediaSource] = {nekos_life, nekos_fun, waifu_pics} 151 | 152 | sm = sa_sessionmaker(make_connection_string(config.database)) 153 | 154 | await set_bot_commands(bot) 155 | logger.info("Bot commands are set") 156 | 157 | setup_middlewares(dp, sources, sm) 158 | logger.info("Middlewares are registered") 159 | 160 | register_handlers(dp, sources) 161 | logger.info("Handlers are registered") 162 | 163 | register_dialogs(dr) 164 | logger.info("Dialogs are registered") 165 | 166 | await start_scheduler(sources, sm) 167 | logger.info("Scheduler is started") 168 | 169 | try: 170 | logger.info("Bot starting!") 171 | await dp.start_polling( 172 | allowed_updates=[ 173 | "message_handlers", 174 | "callback_query_handlers", 175 | ], 176 | ) 177 | finally: 178 | logger.error("Bot stopped!") 179 | 180 | for source in sources: 181 | await source.close() 182 | logger.warning("Closed sources") 183 | 184 | 185 | asyncio.run(main()) 186 | -------------------------------------------------------------------------------- /app/infrastructure/scheduler/media.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from itertools import cycle 3 | from typing import NoReturn 4 | 5 | from aiohttp import ClientError 6 | from app.infrastructure.database.repositories import UnitOfWork 7 | from app.infrastructure.media import MediaSource, NekosFun, NekosLife, WaifuPics 8 | from app.infrastructure.media.base.schemas import Media 9 | from sqlalchemy.exc import IntegrityError, NoResultFound 10 | from sqlalchemy.orm import sessionmaker 11 | from structlog import get_logger 12 | from structlog.stdlib import BoundLogger 13 | 14 | logger: BoundLogger = get_logger() 15 | 16 | SLEEP_FOR_NEKOS_FUN = 1 17 | SLEEP_FOR_NEKOS_LIFE = 1 18 | SLEEP_FOR_WAIFU_PICS = 1 19 | 20 | SLEEP_AFTER_ERROR = 30 21 | 22 | 23 | async def create_source_and_get_source_id( 24 | source: MediaSource, 25 | uow: UnitOfWork, 26 | ) -> int: 27 | source_name = source.__class__.__name__ 28 | try: 29 | source_model = await uow.sources.get_by_name(source_name) 30 | except NoResultFound: 31 | # save the source to the database 32 | await uow.sources.create(source_name, source.SOURCE_URL) 33 | await uow.commit() 34 | 35 | source_model = await uow.sources.get_by_name(source_name) 36 | 37 | return source_model.id # type: ignore 38 | 39 | 40 | async def create_media( 41 | media: Media, 42 | source_id: int, 43 | uow: UnitOfWork, 44 | ): 45 | await uow.media.create( 46 | url=media.url, 47 | source_id=source_id, 48 | media_type=media.media_type, 49 | is_sfw=media.is_sfw, 50 | genre=media.raw_genre, 51 | ) 52 | 53 | 54 | async def parse_nekos_fun( 55 | source: NekosFun, 56 | uow: UnitOfWork, 57 | ) -> NoReturn: # type: ignore 58 | source_id = await create_source_and_get_source_id(source, uow) 59 | 60 | for genre in cycle(source.genres): 61 | try: 62 | media_many = await source.get_media(genre, count=5) 63 | except ClientError: 64 | logger.warning( 65 | "Failed to get media", 66 | exc_info=True, 67 | stack_info=True, 68 | ) 69 | await asyncio.sleep(SLEEP_AFTER_ERROR) 70 | continue 71 | 72 | for media in media_many: 73 | try: 74 | await create_media(media, source_id, uow) 75 | except IntegrityError: 76 | await uow.rollback() 77 | else: 78 | await uow.commit() 79 | await asyncio.sleep(SLEEP_FOR_NEKOS_FUN) 80 | 81 | 82 | async def parse_nekos_life( 83 | source: NekosLife, 84 | uow: UnitOfWork, 85 | ) -> NoReturn: # type: ignore 86 | source_id = await create_source_and_get_source_id(source, uow) 87 | 88 | for genre in cycle(source.genres): 89 | try: 90 | media_many = await source.get_media(genre, count=20) 91 | except ClientError: 92 | logger.warning( 93 | "Failed to get media", 94 | exc_info=True, 95 | stack_info=True, 96 | ) 97 | await asyncio.sleep(SLEEP_AFTER_ERROR) 98 | continue 99 | 100 | for media in media_many: 101 | try: 102 | await create_media(media, source_id, uow) 103 | except IntegrityError: 104 | await uow.rollback() 105 | else: 106 | await uow.commit() 107 | await asyncio.sleep(SLEEP_FOR_NEKOS_LIFE) 108 | 109 | 110 | async def parse_waifu_pics( 111 | source: WaifuPics, 112 | uow: UnitOfWork, 113 | ) -> NoReturn: # type: ignore 114 | source_id = await create_source_and_get_source_id(source, uow) 115 | 116 | for genre in cycle(source.genres): 117 | try: 118 | media_many = await source.get_media(genre, count=30) 119 | except ClientError: 120 | logger.warning( 121 | "Failed to get media", 122 | exc_info=True, 123 | stack_info=True, 124 | ) 125 | await asyncio.sleep(SLEEP_AFTER_ERROR) 126 | continue 127 | 128 | for media in media_many: 129 | try: 130 | await create_media(media, source_id, uow) 131 | except IntegrityError: 132 | await uow.rollback() 133 | else: 134 | await uow.commit() 135 | 136 | await asyncio.sleep(SLEEP_FOR_WAIFU_PICS) 137 | 138 | 139 | async def create_uow( 140 | sa_sessionmaker: sessionmaker, 141 | ) -> UnitOfWork: 142 | async with sa_sessionmaker() as session: # type: ignore 143 | return UnitOfWork(session) 144 | 145 | 146 | async def start_parse_media( 147 | sources: set[MediaSource], 148 | sa_sessionmaker: sessionmaker, 149 | ): 150 | tasks = [] 151 | for source in sources: 152 | if isinstance(source, NekosFun): 153 | tasks.append( 154 | parse_nekos_fun( 155 | source, 156 | await create_uow(sa_sessionmaker), 157 | ), 158 | ) 159 | elif isinstance(source, NekosLife): 160 | tasks.append( 161 | parse_nekos_life( 162 | source, 163 | await create_uow(sa_sessionmaker), 164 | ), 165 | ) 166 | elif isinstance(source, WaifuPics): 167 | tasks.append( 168 | parse_waifu_pics( 169 | source, 170 | await create_uow(sa_sessionmaker), 171 | ), 172 | ) 173 | else: 174 | raise NotImplementedError("Unknown source") 175 | 176 | asyncio.gather(*tasks, return_exceptions=False) 177 | -------------------------------------------------------------------------------- /app/infrastructure/media/base/client.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | 4 | from aiohttp import ClientSession, ClientTimeout 5 | from app.infrastructure.media.base.schemas import Media, MediaGenre 6 | from app.infrastructure.media.base.typehints import MediaGenreType 7 | 8 | 9 | class MediaSource(ABC): 10 | SOURCE_URL: str 11 | SOURCE_ID: str 12 | 13 | RAW_SFW_GENRES: dict[str, set[str]] 14 | RAW_NSFW_GENRES: dict[str, set[str]] 15 | 16 | def __init__(self): 17 | self._session: Optional[ClientSession] = None 18 | 19 | @abstractmethod 20 | async def get_media( 21 | self, 22 | genre: MediaGenreType, 23 | count: int = 1, 24 | ) -> list[Media]: 25 | """ 26 | Get media for the given genre. 27 | 28 | :genre: genre to get media for 29 | :count (optional): number of media to get 30 | """ 31 | ... 32 | 33 | @property 34 | def session(self) -> ClientSession: 35 | """ 36 | Get a session for the nekos.life API. 37 | """ 38 | if self._session is None or self._session.closed: 39 | self._session = ClientSession( 40 | timeout=ClientTimeout(total=30), 41 | ) 42 | return self._session 43 | 44 | async def close(self) -> None: 45 | """ 46 | Close the session. 47 | """ 48 | if self._session is not None and not self._session.closed: 49 | await self._session.close() 50 | 51 | @property 52 | def sfw_genres(self) -> set[MediaGenreType]: 53 | """ 54 | Genres that are safe for work. 55 | """ 56 | source_id = self.SOURCE_ID 57 | 58 | return { 59 | # key=gif, genre=neko => neko_gif__SOURCE_ID 60 | # key=img, genre=neko => neko_img__SOURCE_ID 61 | # key=all, genre=neko => neko_all__SOURCE_ID 62 | f"{genre}_{key}__{source_id}" 63 | for key, genres in self.RAW_SFW_GENRES.items() 64 | for genre in genres 65 | } 66 | 67 | @property 68 | def sfw_genres_gif(self) -> set[MediaGenreType]: 69 | """ 70 | Genres that are safe for work and are gifs. 71 | """ 72 | source_id = self.SOURCE_ID 73 | 74 | return { 75 | # key=gif, genre=neko => neko_gif__SOURCE_ID 76 | f"{genre}_gif__{source_id}" 77 | for genre in self.RAW_SFW_GENRES["gif"] 78 | } 79 | 80 | @property 81 | def sfw_genres_img(self) -> set[MediaGenreType]: 82 | """ 83 | Genres that are safe for work and are images. 84 | """ 85 | source_id = self.SOURCE_ID 86 | 87 | return { 88 | # key=img, genre=neko => neko_img__SOURCE_ID 89 | f"{genre}_img__{source_id}" 90 | for genre in self.RAW_SFW_GENRES["img"] 91 | } 92 | 93 | @property 94 | def sfw_genres_all(self) -> set[MediaGenreType]: 95 | """ 96 | Genres that are safe for work and are gifs and images. 97 | """ 98 | source_id = self.SOURCE_ID 99 | 100 | return { 101 | # key=all, genre=neko => neko_all__SOURCE_ID 102 | f"{genre}_all__{source_id}" 103 | for genre in self.RAW_SFW_GENRES["all"] 104 | } 105 | 106 | @property 107 | def nsfw_genres(self) -> set[MediaGenreType]: 108 | """ 109 | Genres that are not safe for work. 110 | """ 111 | source_id = self.SOURCE_ID 112 | 113 | return { 114 | # key=gif, genre=neko => neko_gif_nsfw__SOURCE_ID 115 | # key=img, genre=neko => neko_img_nsfw__SOURCE_ID 116 | # key=all, genre=neko => neko_all_nsfw__SOURCE_ID 117 | f"{genre}_{key}_nsfw__{source_id}" 118 | for key, genres in self.RAW_NSFW_GENRES.items() 119 | for genre in genres 120 | } 121 | 122 | @property 123 | def nsfw_genres_gif(self) -> set[MediaGenreType]: 124 | """ 125 | Genres that are not safe for work and are gifs. 126 | """ 127 | source_id = self.SOURCE_ID 128 | 129 | return { 130 | # key=gif, genre=neko => neko_gif_nsfw__SOURCE_ID 131 | f"{genre}_gif_nsfw__{source_id}" 132 | for genre in self.RAW_NSFW_GENRES["gif"] 133 | } 134 | 135 | @property 136 | def nsfw_genres_img(self) -> set[MediaGenreType]: 137 | """ 138 | Genres that are not safe for work and are images. 139 | """ 140 | source_id = self.SOURCE_ID 141 | 142 | return { 143 | # key=img, genre=neko => neko_img_nsfw__SOURCE_ID 144 | f"{genre}_img_nsfw__{source_id}" 145 | for genre in self.RAW_NSFW_GENRES["img"] 146 | } 147 | 148 | @property 149 | def nsfw_genres_all(self) -> set[MediaGenreType]: 150 | """ 151 | Genres that are not safe for work and are gifs and images. 152 | """ 153 | source_id = self.SOURCE_ID 154 | 155 | return { 156 | # key=all, genre=neko => neko_all_nsfw__SOURCE_ID 157 | f"{genre}_all_nsfw__{source_id}" 158 | for genre in self.RAW_NSFW_GENRES["all"] 159 | } 160 | 161 | @property 162 | def genres(self) -> set[MediaGenreType]: 163 | """ 164 | All genres. 165 | """ 166 | return self.sfw_genres | self.nsfw_genres 167 | 168 | @property 169 | def genres_gif(self) -> set[MediaGenreType]: 170 | """ 171 | All gif genres. 172 | """ 173 | return self.sfw_genres_gif | self.nsfw_genres_gif 174 | 175 | @property 176 | def genres_img(self) -> set[MediaGenreType]: 177 | """ 178 | All image genres. 179 | """ 180 | return self.sfw_genres_img | self.nsfw_genres_img 181 | 182 | @property 183 | def genres_all(self) -> set[MediaGenreType]: 184 | """ 185 | All gif and image genres. 186 | """ 187 | return self.sfw_genres_all | self.nsfw_genres_all 188 | 189 | def parse_genre(self, genre: MediaGenreType) -> MediaGenre: 190 | """ 191 | Parse a genre into a `MediaGenre`. 192 | 193 | :genre: genre to parse 194 | """ 195 | raw_genre, media_type, *args = genre.rsplit("_") 196 | is_nsfw = args[0] == "nsfw" 197 | 198 | if is_nsfw: 199 | return MediaGenre( 200 | raw_genre=raw_genre, 201 | media_type=media_type, 202 | is_sfw=False, 203 | ) 204 | return MediaGenre( 205 | raw_genre=raw_genre, 206 | media_type=media_type, 207 | is_sfw=True, 208 | ) 209 | -------------------------------------------------------------------------------- /app/handlers/media.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from aiogram import Dispatcher 4 | from aiogram.types import ( 5 | KeyboardButton, 6 | Message, 7 | ReplyKeyboardMarkup, 8 | ReplyKeyboardRemove, 9 | ) 10 | from aiogram.utils.exceptions import FileIsTooBig, WrongFileIdentifier 11 | from app.filters import CheckGenreIn, NSFWSettings 12 | from app.infrastructure.database.models import UserModel 13 | from app.infrastructure.database.repositories import UnitOfWork 14 | from app.infrastructure.media import MediaSource 15 | from app.infrastructure.media.base.schemas import MediaGenre 16 | from app.media_utils import get_sorted_genres, get_text_by_genres 17 | from app.typehints import I18nGettext 18 | from sqlalchemy.exc import IntegrityError 19 | from structlog import get_logger 20 | from structlog.stdlib import BoundLogger 21 | 22 | logger: BoundLogger = get_logger() 23 | 24 | 25 | async def genres_gif_cmd( 26 | m: Message, 27 | _: I18nGettext, 28 | sources: set[MediaSource], 29 | user: UserModel, 30 | ): 31 | genres = get_sorted_genres(sources, user.show_nsfw, "gif") # type: ignore 32 | text = get_text_by_genres(genres, _) 33 | 34 | await m.answer(text, reply_markup=ReplyKeyboardRemove()) 35 | 36 | 37 | async def genres_img_cmd( 38 | m: Message, 39 | _: I18nGettext, 40 | sources: set[MediaSource], 41 | user: UserModel, 42 | ): 43 | genres = get_sorted_genres(sources, user.show_nsfw, "img") # type: ignore 44 | text = get_text_by_genres(genres, _) 45 | 46 | await m.answer(text, reply_markup=ReplyKeyboardRemove()) 47 | 48 | 49 | async def genres_all_cmd( 50 | m: Message, 51 | _: I18nGettext, 52 | sources: set[MediaSource], 53 | user: UserModel, 54 | ): 55 | genres = get_sorted_genres(sources, user.show_nsfw, "all") # type: ignore 56 | text = get_text_by_genres(genres, _) 57 | 58 | await m.answer(text, reply_markup=ReplyKeyboardRemove()) 59 | 60 | 61 | async def genre_cmd( 62 | m: Message, 63 | _: I18nGettext, 64 | sources: set[MediaSource], 65 | uow: UnitOfWork, 66 | ): 67 | genre: str = m.get_command(pure=True) # type: ignore 68 | 69 | media_genre: MediaGenre 70 | for source in sources: 71 | if genre not in source.genres: 72 | continue 73 | 74 | media_genre = source.parse_genre(genre) 75 | 76 | media_many = await uow.media.get_not_viewed( 77 | tg_id=m.from_user.id, 78 | genre=media_genre.raw_genre, # type: ignore 79 | media_type=media_genre.media_type, # type: ignore 80 | is_sfw=media_genre.is_sfw, # type: ignore 81 | limit=1, 82 | ) 83 | 84 | if not media_many: 85 | await m.reply( 86 | text=_( 87 | "You've viewed all media on this genre. " "Try again later!", 88 | ), 89 | ) 90 | return 91 | else: 92 | media = media_many[0] 93 | 94 | if m.chat.type == "private": 95 | markup = ReplyKeyboardMarkup( 96 | resize_keyboard=True, 97 | one_time_keyboard=False, 98 | row_width=1, 99 | keyboard=[ 100 | [KeyboardButton(text=f"/{genre}")], 101 | [KeyboardButton(text="/help")], 102 | ], 103 | ) 104 | else: 105 | markup = None 106 | 107 | try: 108 | if media.media_type == "gif": 109 | await m.reply_animation( 110 | media.url, 111 | parse_mode=None, 112 | disable_notification=False, 113 | allow_sending_without_reply=True, 114 | reply_markup=markup, 115 | ) 116 | else: 117 | await m.reply_document( 118 | media.url, 119 | parse_mode=None, 120 | disable_notification=False, 121 | allow_sending_without_reply=True, 122 | reply_markup=markup, 123 | ) 124 | except WrongFileIdentifier: 125 | logger.warning("Failed to send media", media=media) 126 | 127 | await genre_cmd(m, _, sources, uow) 128 | return 129 | except FileIsTooBig: 130 | logger.warning("Failed to send media. File is too big", media=media) 131 | 132 | await genre_cmd(m, _, sources, uow) 133 | 134 | try: 135 | await uow.views.create( 136 | tg_id=m.from_user.id, 137 | media_id=media.id, # type: ignore 138 | ) 139 | except IntegrityError: 140 | await uow.rollback() 141 | else: 142 | await uow.commit() 143 | 144 | 145 | async def forbidden_genre_cmd_private(m: Message, _: I18nGettext): 146 | await m.answer( 147 | text=_( 148 | "You aren't allowed to view NSFW-content!\n\n" "/settings — change settings" 149 | ), 150 | ) 151 | 152 | 153 | async def forbidden_genre_cmd_public(m: Message, _: I18nGettext): 154 | await m.answer( 155 | text=_("You aren't allowed to view NSFW-content publicly!"), 156 | ) 157 | 158 | 159 | def register_genre_handlers(dp: Dispatcher, sources: set[MediaSource]): 160 | sfw_genres = chain.from_iterable(source.sfw_genres for source in sources) 161 | nsfw_genres = chain.from_iterable(source.nsfw_genres for source in sources) 162 | 163 | SFWGenres = CheckGenreIn(genres=sfw_genres) 164 | NSFWGenres = CheckGenreIn(genres=nsfw_genres) 165 | 166 | dp.register_message_handler( 167 | genres_gif_cmd, 168 | commands={"genres_gif"}, 169 | content_types={"text"}, 170 | state="*", 171 | ) 172 | dp.register_message_handler( 173 | genres_img_cmd, 174 | commands={"genres_img"}, 175 | content_types={"text"}, 176 | state="*", 177 | ) 178 | dp.register_message_handler( 179 | genres_all_cmd, 180 | commands={"genres_all"}, 181 | content_types={"text"}, 182 | state="*", 183 | ) 184 | dp.register_message_handler( 185 | genre_cmd, 186 | SFWGenres, 187 | content_types={"text"}, 188 | state="*", 189 | ) 190 | dp.register_message_handler( 191 | genre_cmd, 192 | NSFWGenres, 193 | NSFWSettings(can_show_nsfw=True), 194 | content_types={"text"}, 195 | chat_type="private", 196 | state="*", 197 | ) 198 | dp.register_message_handler( 199 | forbidden_genre_cmd_private, 200 | NSFWGenres, 201 | NSFWSettings(can_show_nsfw=False), 202 | content_types={"text"}, 203 | chat_type="private", 204 | state="*", 205 | ) 206 | dp.register_message_handler( 207 | forbidden_genre_cmd_public, 208 | NSFWGenres, 209 | content_types={"text"}, 210 | state="*", 211 | ) 212 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/media.py: -------------------------------------------------------------------------------- 1 | from app.domain.media.dto.stats import Media, Stats 2 | from app.infrastructure.database.models import MediaModel, ViewModel 3 | from app.infrastructure.database.repositories.repo import Repo 4 | from pydantic import parse_obj_as 5 | from sqlalchemy import case, func, insert, select 6 | from structlog import get_logger 7 | from structlog.stdlib import BoundLogger 8 | 9 | logger: BoundLogger = get_logger() 10 | 11 | 12 | class MediaRepo(Repo): 13 | async def get_media_stats(self) -> Stats: 14 | query = select( 15 | func.count(1).label("total"), 16 | func.count(case([(MediaModel.media_type == "gif", 1)])).label("gif"), 17 | func.count(case([(MediaModel.media_type == "img", 1)])).label("img"), 18 | func.count(case([(MediaModel.media_type == "all", 1)])).label("all"), 19 | func.count(case([(MediaModel.is_sfw.is_(True), 1)])).label("sfw"), 20 | func.count(case([(MediaModel.is_sfw.is_(False), 1)])).label("nsfw"), 21 | ) 22 | second_query = select( 23 | func.count(1).label("total"), 24 | MediaModel.genre.label("genre"), 25 | MediaModel.media_type.label("media_type"), 26 | MediaModel.is_sfw.label("is_sfw"), 27 | ).group_by( 28 | MediaModel.genre, 29 | MediaModel.media_type, 30 | MediaModel.is_sfw, 31 | ) 32 | 33 | result = await self.session.execute(query) 34 | stats = result.one() 35 | 36 | result = await self.session.execute(second_query) 37 | media_stats = parse_obj_as(list[Media], result.all()) 38 | 39 | return Stats( 40 | total=stats["total"], 41 | gif=stats["gif"], 42 | img=stats["img"], 43 | all=stats["all"], 44 | sfw=stats["sfw"], 45 | nsfw=stats["nsfw"], 46 | media=media_stats, 47 | ) 48 | 49 | async def get_viewed_media_stats(self) -> Stats: 50 | query = ( 51 | select( 52 | func.count(1).label("total"), 53 | func.count(case([(MediaModel.media_type == "gif", 1)])).label("gif"), 54 | func.count(case([(MediaModel.media_type == "img", 1)])).label("img"), 55 | func.count(case([(MediaModel.media_type == "all", 1)])).label("all"), 56 | func.count(case([(MediaModel.is_sfw.is_(True), 1)])).label("sfw"), 57 | func.count(case([(MediaModel.is_sfw.is_(False), 1)])).label("nsfw"), 58 | ) 59 | .join( 60 | MediaModel, 61 | ViewModel.media_id == MediaModel.id, 62 | ) 63 | .select_from( 64 | ViewModel, 65 | ) 66 | ) 67 | second_query = ( 68 | select( 69 | func.count(1).label("total"), 70 | MediaModel.genre.label("genre"), 71 | MediaModel.media_type.label("media_type"), 72 | MediaModel.is_sfw.label("is_sfw"), 73 | ) 74 | .join( 75 | MediaModel, 76 | ViewModel.media_id == MediaModel.id, 77 | ) 78 | .select_from( 79 | ViewModel, 80 | ) 81 | .group_by( 82 | MediaModel.genre, 83 | MediaModel.media_type, 84 | MediaModel.is_sfw, 85 | ) 86 | ) 87 | 88 | result = await self.session.execute(query) 89 | stats = result.one() 90 | 91 | result = await self.session.execute(second_query) 92 | media_stats = parse_obj_as(list[Media], result.all()) 93 | 94 | return Stats( 95 | total=stats["total"], 96 | gif=stats["gif"], 97 | img=stats["img"], 98 | all=stats["all"], 99 | sfw=stats["sfw"], 100 | nsfw=stats["nsfw"], 101 | media=media_stats, 102 | ) 103 | 104 | async def get_viewed_media_stats_by_tg_id(self, tg_id: int) -> Stats: 105 | query = ( 106 | select( 107 | func.count(1).label("total"), 108 | func.count(case([(MediaModel.media_type == "gif", 1)])).label("gif"), 109 | func.count(case([(MediaModel.media_type == "img", 1)])).label("img"), 110 | func.count(case([(MediaModel.media_type == "all", 1)])).label("all"), 111 | func.count(case([(MediaModel.is_sfw.is_(True), 1)])).label("sfw"), 112 | func.count(case([(MediaModel.is_sfw.is_(False), 1)])).label("nsfw"), 113 | ) 114 | .join( 115 | MediaModel, 116 | ViewModel.media_id == MediaModel.id, 117 | ) 118 | .select_from( 119 | ViewModel, 120 | ) 121 | .where( 122 | ViewModel.user_tg_id == tg_id, 123 | ) 124 | ) 125 | second_query = ( 126 | select( 127 | func.count(1).label("total"), 128 | MediaModel.genre.label("genre"), 129 | MediaModel.media_type.label("media_type"), 130 | MediaModel.is_sfw.label("is_sfw"), 131 | ) 132 | .join( 133 | MediaModel, 134 | ViewModel.media_id == MediaModel.id, 135 | ) 136 | .select_from( 137 | ViewModel, 138 | ) 139 | .group_by( 140 | MediaModel.genre, 141 | MediaModel.media_type, 142 | MediaModel.is_sfw, 143 | ) 144 | ) 145 | 146 | result = await self.session.execute(query) 147 | stats = result.one() 148 | 149 | result = await self.session.execute(second_query) 150 | media_stats = parse_obj_as(list[Media], result.all()) 151 | 152 | return Stats( 153 | total=stats["total"], 154 | gif=stats["gif"], 155 | img=stats["img"], 156 | all=stats["all"], 157 | sfw=stats["sfw"], 158 | nsfw=stats["nsfw"], 159 | media=media_stats, 160 | ) 161 | 162 | async def get_not_viewed( 163 | self, 164 | tg_id: int, 165 | genre: str, 166 | media_type: str, 167 | is_sfw: bool, 168 | limit: int = 1, 169 | ) -> list[MediaModel]: 170 | viewed_media_query = select(ViewModel.media_id).where( 171 | ViewModel.user_tg_id == tg_id, 172 | ) 173 | query = ( 174 | select(MediaModel) 175 | .where( 176 | MediaModel.genre == genre, 177 | MediaModel.media_type == media_type, 178 | MediaModel.is_sfw.is_(is_sfw), 179 | MediaModel.id.not_in(viewed_media_query), 180 | ) 181 | .order_by(func.random()) 182 | .limit(limit) 183 | ) 184 | 185 | result = await self.session.execute(query) 186 | 187 | return result.scalars().all() 188 | 189 | async def create( 190 | self, 191 | url: str, 192 | source_id: int, 193 | media_type: str, 194 | is_sfw: bool, 195 | genre: str | None = None, 196 | ): 197 | query = insert(MediaModel).values( 198 | url=url, 199 | genre=genre, 200 | source_id=source_id, 201 | media_type=media_type, 202 | is_sfw=is_sfw, 203 | ) 204 | 205 | await self.session.execute(query) 206 | --------------------------------------------------------------------------------