├── aiogram_bot ├── __init__.py ├── models │ ├── __init__.py │ ├── base.py │ ├── user.py │ ├── chat.py │ └── db.py ├── services │ ├── __init__.py │ ├── apscheduller.py │ ├── hastebin.py │ ├── healthcheck.py │ └── join_list.py ├── utils │ ├── __init__.py │ ├── chat_admin.py │ ├── superuser.py │ ├── redis.py │ ├── logging.py │ ├── executor.py │ ├── timedelta.py │ ├── cli.py │ ├── before_start.py │ └── chat_settings.py ├── filters │ ├── sender_chat.py │ ├── is_reply.py │ ├── superuser.py │ ├── chat_property.py │ ├── __init__.py │ └── has_permissions.py ├── __main__.py ├── handlers │ ├── __init__.py │ ├── superuser.py │ ├── channel_filter.py │ ├── hastebin.py │ ├── base.py │ ├── new_chat_members.py │ ├── chat_settings.py │ └── simple_admin.py ├── middlewares │ ├── __init__.py │ ├── i18n.py │ └── acl.py ├── misc.py └── config.py ├── migrations ├── README ├── script.py.mako ├── versions │ ├── e0d427aae93e_update_user_id_to_int64.py │ ├── 4c0d7a22344e_add_chat_type_column.py │ ├── c7025892455f_add_superuser_column.py │ ├── d6553c0e950b_add_do_not_disturb_column.py │ ├── 9d333f105ea0_added_additional_chat_settings.py │ ├── 2cdcb2a2c589_chat_settings_and_user_conversation_flag.py │ ├── 57bc88e06e52_rename_superuser_column_and_add_is_.py │ ├── c639acad707a_added_settings_for_channel_messages.py │ └── 4201ee77de3d_add_user_and_chat_models.py └── env.py ├── .gitignore ├── .flake8 ├── .isort.cfg ├── .dockerignore ├── docker-compose.dev.yml ├── .env.dist ├── scripts └── docker-entrypoint.sh ├── Dockerfile ├── docker-compose.yml ├── Pipfile ├── LICENSE ├── pyproject.toml ├── docker-compose.stack.yml ├── alembic.ini ├── README.md ├── Makefile ├── locales ├── bot.pot ├── en │ └── LC_MESSAGES │ │ └── bot.po ├── uk │ └── LC_MESSAGES │ │ └── bot.po └── ru │ └── LC_MESSAGES │ └── bot.po └── Pipfile.lock /aiogram_bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aiogram_bot/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aiogram_bot/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aiogram_bot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aiogram_bot/filters/sender_chat.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | *.py[cod] 3 | __pycache__ 4 | 5 | *.mo 6 | 7 | .env 8 | -------------------------------------------------------------------------------- /aiogram_bot/__main__.py: -------------------------------------------------------------------------------- 1 | from aiogram_bot.utils.cli import cli 2 | 3 | if __name__ == "__main__": 4 | cli() 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503, F403, F401 3 | max-line-length = 99 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output=3 3 | include_trailing_comma=True 4 | force_grid_wrap=0 5 | use_parentheses=True 6 | line_length=99 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Project 2 | .idea/ 3 | .github/ 4 | .flake8 5 | .*ignore 6 | .isort.cfg 7 | Makefile 8 | README.md 9 | 10 | # Cache 11 | *.py[cod] 12 | __pycache__ 13 | 14 | # Texts 15 | *.mo 16 | 17 | # Environment 18 | .env* 19 | 20 | # Docker 21 | docker-compose*.yml 22 | Dockerfile 23 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | bot: 5 | command: 6 | - run-polling 7 | ports: 8 | - ${BOT_PUBLIC_PORT}:80 9 | 10 | redis: 11 | ports: 12 | - ${REDIS_PORT}:6379 13 | 14 | postgres: 15 | ports: 16 | - ${POSTGRES_PORT}:5432 17 | -------------------------------------------------------------------------------- /aiogram_bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Import all submodules here 3 | 4 | isort:skip_file 5 | """ 6 | 7 | from . import simple_admin 8 | from . import base 9 | from . import chat_settings 10 | from . import new_chat_members 11 | from . import superuser 12 | from . import hastebin 13 | from . import channel_filter 14 | -------------------------------------------------------------------------------- /aiogram_bot/models/base.py: -------------------------------------------------------------------------------- 1 | # Import all the models, so that Base has them before being 2 | # imported by Alembic 3 | 4 | from .chat import Chat, ChatAllowedChannels 5 | from .db import db 6 | from .user import User 7 | 8 | __all__ = ( 9 | "db", 10 | "Chat", 11 | "ChatAllowedChannels", 12 | "User", 13 | ) 14 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | COMPOSE_PROJECT_NAME=aiogram_bot 2 | COMPOSE_FILE=docker-compose.yml 3 | 4 | TELEGRAM_TOKEN= 5 | BOT_PUBLIC_PORT=80 6 | DOMAIN=example.com 7 | WEBHOOK_BASE_PATH=/webhook 8 | 9 | REDIS_HOST=localhost 10 | REDIS_PORT=6379 11 | REDIS_DB_FSM=0 12 | REDIS_DB_JOBSTORE=1 13 | 14 | POSTGRES_HOST=localhost 15 | POSTGRES_PORT=5432 16 | POSTGRES_PASSWORD=password 17 | POSTGRES_USER=aiogram 18 | POSTGRES_DB=aiogram 19 | -------------------------------------------------------------------------------- /aiogram_bot/utils/chat_admin.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from aiogram.types import ChatMember 4 | 5 | from aiogram_bot.misc import bot 6 | 7 | 8 | async def get_chat_administrator(chat_id: int, user_id: int) -> Optional[ChatMember]: 9 | admins = await bot.get_chat_administrators(chat_id) 10 | try: 11 | return next(filter(lambda member: member.user.id == user_id, admins)) 12 | except StopIteration: 13 | return None 14 | -------------------------------------------------------------------------------- /aiogram_bot/filters/is_reply.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from aiogram import types 4 | from aiogram.dispatcher.filters import BoundFilter 5 | 6 | 7 | @dataclass 8 | class IsReplyFilter(BoundFilter): 9 | """ 10 | Filtered message should be reply to another message 11 | """ 12 | 13 | key = "is_reply" 14 | 15 | is_reply: bool 16 | 17 | async def check(self, message: types.Message) -> bool: 18 | return self.is_reply and message.reply_to_message 19 | -------------------------------------------------------------------------------- /aiogram_bot/filters/superuser.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from aiogram.dispatcher.filters import BoundFilter 4 | from aiogram.dispatcher.handler import ctx_data 5 | 6 | from aiogram_bot.models.user import User 7 | 8 | 9 | @dataclass 10 | class IsSuperuserFilter(BoundFilter): 11 | key = "is_superuser" 12 | is_superuser: bool 13 | 14 | async def check(self, obj) -> bool: 15 | data = ctx_data.get() 16 | user: User = data["user"] 17 | return user.is_superuser 18 | -------------------------------------------------------------------------------- /scripts/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | PYTHON="python -O" 6 | APP="exec ${PYTHON} -m aiogram_bot" 7 | 8 | ${PYTHON} -m aiogram_bot.utils.before_start 9 | 10 | function migrate () { 11 | if [[ ! -z "${RUN_MIGRATIONS}" ]]; then 12 | alembic upgrade head 13 | fi 14 | } 15 | 16 | case "$1" in 17 | run-webhook) 18 | migrate 19 | ${APP} webhook 20 | ;; 21 | 22 | run-polling) 23 | migrate 24 | ${APP} polling 25 | ;; 26 | 27 | *) 28 | ${@} 29 | 30 | esac 31 | -------------------------------------------------------------------------------- /aiogram_bot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.contrib.middlewares.logging import LoggingMiddleware 3 | from loguru import logger 4 | 5 | from aiogram_bot.middlewares.acl import ACLMiddleware 6 | 7 | 8 | def setup(dispatcher: Dispatcher): 9 | logger.info("Configure middlewares...") 10 | from aiogram_bot.misc import i18n 11 | 12 | dispatcher.middleware.setup(LoggingMiddleware("bot")) 13 | dispatcher.middleware.setup(ACLMiddleware()) 14 | dispatcher.middleware.setup(i18n) 15 | -------------------------------------------------------------------------------- /aiogram_bot/filters/chat_property.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from aiogram.dispatcher.filters import BoundFilter 4 | from aiogram.dispatcher.handler import ctx_data 5 | 6 | from aiogram_bot.models.chat import Chat 7 | 8 | 9 | @dataclass 10 | class ChatPropertyFilter(BoundFilter): 11 | key = "chat_property" 12 | chat_property: str 13 | 14 | async def check(self, obj) -> bool: 15 | data = ctx_data.get() 16 | chat: Chat = data["chat"] 17 | return getattr(chat, self.chat_property, False) 18 | -------------------------------------------------------------------------------- /migrations/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(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/e0d427aae93e_update_user_id_to_int64.py: -------------------------------------------------------------------------------- 1 | """Update user_id to int64 2 | 3 | Revision ID: e0d427aae93e 4 | Revises: 57bc88e06e52 5 | Create Date: 2021-12-05 23:11:42.879573 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "e0d427aae93e" 13 | down_revision = "57bc88e06e52" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | op.alter_column("users", "id", type_=sa.BigInteger, existing_type=sa.Integer) 20 | 21 | 22 | def downgrade(): 23 | op.alter_column("users", "id", type_=sa.Integer, existing_type=sa.BigInteger) 24 | -------------------------------------------------------------------------------- /migrations/versions/4c0d7a22344e_add_chat_type_column.py: -------------------------------------------------------------------------------- 1 | """add chat type column 2 | 3 | Revision ID: 4c0d7a22344e 4 | Revises: 4201ee77de3d 5 | Create Date: 2019-10-16 23:14:43.280821 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "4c0d7a22344e" 13 | down_revision = "4201ee77de3d" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column("chats", sa.Column("type", sa.String(), nullable=True)) 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_column("chats", "type") 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /aiogram_bot/utils/superuser.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from aiogram_bot.models.user import User 4 | 5 | 6 | async def create_super_user(user_id: int, remove: bool) -> bool: 7 | user = await User.query.where(User.id == user_id).gino.first() 8 | if not user: 9 | logger.error("User is not registered in bot") 10 | raise ValueError("User is not registered in bot") 11 | 12 | logger.info( 13 | "Loaded user {user}. It's registered at {register_date}.", 14 | user=user.id, 15 | register_date=user.created_at, 16 | ) 17 | await user.update(is_superuser=not remove).apply() 18 | if remove: 19 | logger.warning("User {user} now IS NOT superuser", user=user_id) 20 | else: 21 | logger.warning("User {user} now IS superuser", user=user_id) 22 | return True 23 | -------------------------------------------------------------------------------- /aiogram_bot/models/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy.sql import expression 4 | 5 | from aiogram_bot.models.db import BaseModel, TimedBaseModel, db 6 | 7 | 8 | class User(TimedBaseModel): 9 | __tablename__ = "users" 10 | 11 | id = db.Column(db.BigInteger, primary_key=True, index=True, unique=True) 12 | 13 | is_superuser = db.Column(db.Boolean, server_default=expression.false()) 14 | start_conversation = db.Column(db.Boolean, server_default=expression.false()) 15 | do_not_disturb = db.Column( 16 | db.Boolean, default=False, server_default=expression.false(), nullable=False 17 | ) 18 | 19 | 20 | class UserRelatedModel(BaseModel): 21 | __abstract__ = True 22 | 23 | user_id = db.Column( 24 | db.ForeignKey(f"{User.__tablename__}.id", ondelete="CASCADE", onupdate="CASCADE"), 25 | nullable=False, 26 | ) 27 | -------------------------------------------------------------------------------- /aiogram_bot/utils/redis.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import aioredis 4 | 5 | 6 | class BaseRedis: 7 | def __init__(self, host: str, port: int = 6379, db: int = 0): 8 | self.host = host 9 | self.port = port 10 | self.db = db 11 | 12 | self._redis: Optional[aioredis.Redis] = None 13 | 14 | @property 15 | def closed(self): 16 | return not self._redis 17 | 18 | async def connect(self): 19 | if self.closed: 20 | self._redis = await aioredis.from_url(f"redis://{self.host}:{self.port}/{self.db}") 21 | 22 | async def disconnect(self): 23 | if not self.closed: 24 | await self._redis.close() 25 | 26 | @property 27 | def redis(self) -> aioredis.Redis: 28 | if self.closed: 29 | raise RuntimeError("Redis connection is not opened") 30 | return self._redis 31 | -------------------------------------------------------------------------------- /migrations/versions/c7025892455f_add_superuser_column.py: -------------------------------------------------------------------------------- 1 | """add superuser column 2 | 3 | Revision ID: c7025892455f 4 | Revises: d6553c0e950b 5 | Create Date: 2019-10-24 12:04:33.969241 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "c7025892455f" 13 | down_revision = "d6553c0e950b" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column( 21 | "users", 22 | sa.Column("superuser", sa.Boolean(), server_default=sa.text("false"), nullable=True), 23 | ) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column("users", "superuser") 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/d6553c0e950b_add_do_not_disturb_column.py: -------------------------------------------------------------------------------- 1 | """add do_not_disturb column 2 | 3 | Revision ID: d6553c0e950b 4 | Revises: 2cdcb2a2c589 5 | Create Date: 2019-10-21 21:06:28.079557 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "d6553c0e950b" 13 | down_revision = "2cdcb2a2c589" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column( 21 | "users", 22 | sa.Column("do_not_disturb", sa.Boolean(), server_default=sa.text("false"), nullable=False), 23 | ) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column("users", "do_not_disturb") 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /aiogram_bot/middlewares/i18n.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Any, Tuple 3 | 4 | from aiogram.contrib.middlewares.i18n import I18nMiddleware as BaseI18nMiddleware 5 | 6 | 7 | @dataclass 8 | class LanguageData: 9 | flag: str 10 | title: str 11 | label: str = field(init=False, default=None) 12 | 13 | def __post_init__(self): 14 | self.label = f"{self.flag} {self.title}" 15 | 16 | 17 | class I18nMiddleware(BaseI18nMiddleware): 18 | AVAILABLE_LANGUAGES = { 19 | "en": LanguageData("🇺🇸", "English"), 20 | "ru": LanguageData("🇷🇺", "Русский"), 21 | "uk": LanguageData("🇺🇦", "Українська"), 22 | } 23 | 24 | async def get_user_locale(self, action: str, args: Tuple[Any]) -> str: 25 | data: dict = args[-1] 26 | if "chat" in data: 27 | return data["chat"].language or self.default 28 | return self.default 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim-bullseye as production 2 | LABEL maintainer="Alex Root Junior " \ 3 | description="Telegram Bot" 4 | 5 | ENV PYTHONPATH "${PYTHONPATH}:/app" 6 | ENV PATH "/app/scripts:${PATH}" 7 | 8 | EXPOSE 80 9 | WORKDIR /app 10 | 11 | # Install Poetry 12 | RUN set +x \ 13 | && apt update \ 14 | && apt upgrade -y \ 15 | && apt install -y curl gcc build-essential \ 16 | && curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python -\ 17 | && cd /usr/local/bin \ 18 | && ln -s /opt/poetry/bin/poetry \ 19 | && poetry config virtualenvs.create false \ 20 | && rm -rf /var/lib/apt/lists/* 21 | 22 | # Add code & install dependencies 23 | COPY pyproject.toml poetry.lock /app/ 24 | RUN poetry install -n --only main --no-root 25 | 26 | ADD . /app/ 27 | RUN chmod +x scripts/* \ 28 | && poetry install -n --only-root \ 29 | && pybabel compile -d locales -D bot 30 | 31 | ENTRYPOINT ["docker-entrypoint.sh"] 32 | CMD ["run-webhook"] 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | bot: 5 | build: 6 | context: . 7 | restart: on-failure 8 | stop_signal: SIGINT 9 | depends_on: 10 | - redis 11 | - postgres 12 | environment: 13 | BOT_PUBLIC_PORT: 80 14 | TELEGRAM_TOKEN: 15 | DOMAIN: 16 | REDIS_HOST: redis 17 | REDIS_PORT: 6379 18 | REDIS_DB_FSM: 19 | REDIS_DB_JOBSTORE: 20 | POSTGRES_HOST: postgres 21 | POSTGRES_PORT: 5432 22 | POSTGRES_PASSWORD: 23 | POSTGRES_USER: 24 | POSTGRES_DB: 25 | 26 | redis: 27 | image: redis:5-alpine 28 | restart: on-failure 29 | volumes: 30 | - redis-data:/data 31 | 32 | postgres: 33 | image: postgres:12-alpine 34 | restart: on-failure 35 | volumes: 36 | - postgres-data:/var/lib/postgresql/data 37 | environment: 38 | POSTGRES_PASSWORD: 39 | POSTGRES_USER: 40 | POSTGRES_DB: 41 | 42 | volumes: 43 | redis-data: 44 | postgres-data: 45 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [requires] 7 | python_version = "3.7" 8 | 9 | [scripts] 10 | app = "python -m app" 11 | texts_update = "./scripts/exportTexts.sh" 12 | texts_compile = "./scripts/compileTexts.sh" 13 | 14 | [dev-packages] 15 | black = "==19.3b0" 16 | isort = "==4.3.21" 17 | flake8 = "==3.7.8" 18 | ipython = "==7.8.0" 19 | aiohttp-autoreload = "==0.0.1" 20 | 21 | [packages] 22 | aiogram = "==2.9.2" 23 | click = "==7.0" 24 | loguru = "==0.3.2" 25 | uvloop = "*" 26 | aiohttp = "==3.6.2" 27 | envparse = "==0.2.0" 28 | sqlalchemy = "==1.3.10" 29 | sqlalchemy-utils = "==0.34.2" 30 | psycopg2-binary = "==2.8.4" 31 | asyncpg = "==0.19.0" 32 | gino = "==0.8.3" 33 | alembic = "==1.2.1" 34 | aioredis = "==1.3.0" 35 | cchardet = "==2.1.4" 36 | aiodns = "==2.0.0" 37 | tenacity = "==5.1.1" 38 | apscheduler = "==3.6.1" 39 | redis = "==3.3.11" 40 | aiohttp-healthcheck = "==1.3.1" 41 | requests = "*" 42 | 43 | [pipenv] 44 | allow_prereleases = true 45 | -------------------------------------------------------------------------------- /aiogram_bot/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from loguru import logger 3 | 4 | from aiogram_bot.filters.chat_property import ChatPropertyFilter 5 | 6 | 7 | def setup(dispatcher: Dispatcher): 8 | logger.info("Configure filters...") 9 | from .has_permissions import BotHasPermissions, HasPermissions 10 | from .is_reply import IsReplyFilter 11 | from .superuser import IsSuperuserFilter 12 | 13 | text_messages = [ 14 | dispatcher.message_handlers, 15 | dispatcher.edited_message_handlers, 16 | dispatcher.channel_post_handlers, 17 | dispatcher.edited_channel_post_handlers, 18 | ] 19 | 20 | dispatcher.filters_factory.bind(IsReplyFilter, event_handlers=text_messages) 21 | dispatcher.filters_factory.bind(HasPermissions, event_handlers=text_messages) 22 | dispatcher.filters_factory.bind(BotHasPermissions, event_handlers=text_messages) 23 | dispatcher.filters_factory.bind(ChatPropertyFilter, event_handlers=text_messages) 24 | dispatcher.filters_factory.bind(IsSuperuserFilter) 25 | -------------------------------------------------------------------------------- /migrations/versions/9d333f105ea0_added_additional_chat_settings.py: -------------------------------------------------------------------------------- 1 | """Added additional chat settings 2 | 3 | Revision ID: 9d333f105ea0 4 | Revises: c639acad707a 5 | Create Date: 2021-12-12 19:59:22.610129 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '9d333f105ea0' 14 | down_revision = 'c639acad707a' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('chats', sa.Column('report_to_admins', sa.Boolean(), server_default=sa.text('true'), nullable=True)) 22 | op.add_column('chats', sa.Column('restrict_commands', sa.Boolean(), server_default=sa.text('true'), nullable=True)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column('chats', 'restrict_commands') 29 | op.drop_column('chats', 'report_to_admins') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Alex Root Junior 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software 4 | and associated documentation files (the "Software"), to deal in the Software without restriction, 5 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 6 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 7 | furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or 10 | substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING 13 | BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 14 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 15 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /aiogram_bot/services/apscheduller.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.utils.executor import Executor 3 | from apscheduler.executors.asyncio import AsyncIOExecutor 4 | from apscheduler.jobstores.redis import RedisJobStore 5 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 6 | from pytz import utc 7 | 8 | from aiogram_bot import config 9 | 10 | DEFAULT = "default" 11 | 12 | jobstores = { 13 | DEFAULT: RedisJobStore( 14 | db=config.REDIS_DB_JOBSTORE, host=config.REDIS_HOST, port=config.REDIS_PORT 15 | ) 16 | } 17 | executors = {DEFAULT: AsyncIOExecutor()} 18 | job_defaults = {"coalesce": False, "max_instances": 3, "misfire_grace_time": 3600} 19 | 20 | scheduler = AsyncIOScheduler( 21 | jobstores=jobstores, executors=executors, job_defaults=job_defaults, timezone=utc 22 | ) 23 | 24 | 25 | async def on_startup(dispatcher: Dispatcher): 26 | scheduler.start() 27 | 28 | 29 | async def on_shutdown(dispatcher: Dispatcher): 30 | scheduler.shutdown() 31 | 32 | 33 | def setup(executor: Executor): 34 | executor.on_startup(on_startup) 35 | executor.on_shutdown(on_shutdown) 36 | -------------------------------------------------------------------------------- /aiogram_bot/services/hastebin.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | from urllib.parse import urljoin 3 | 4 | from aiogram import Dispatcher 5 | from aiogram.utils.executor import Executor 6 | from aiohttp import ClientSession 7 | 8 | from aiogram_bot import config 9 | 10 | 11 | class HasteBinClient: 12 | def __init__(self, base_url: str) -> None: 13 | self.base_url = base_url 14 | self.session = ClientSession() 15 | 16 | def format_url(self, uri: str) -> str: 17 | return urljoin(self.base_url, uri) 18 | 19 | async def create_document(self, content: bytes) -> Dict[str, Any]: 20 | response = await self.session.post(url=self.format_url("/documents"), data=content) 21 | response.raise_for_status() 22 | return await response.json() 23 | 24 | 25 | hastebin = HasteBinClient(config.HASTEBIN_URL) 26 | 27 | 28 | async def on_startup(dispatcher: Dispatcher): 29 | pass 30 | 31 | 32 | async def on_shutdown(dispatcher: Dispatcher): 33 | await hastebin.session.close() 34 | 35 | 36 | def setup(runner: Executor): 37 | runner.on_startup(on_startup) 38 | runner.on_shutdown(on_shutdown) 39 | -------------------------------------------------------------------------------- /aiogram_bot/handlers/superuser.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | 3 | from aiogram_bot.misc import dp, i18n 4 | from aiogram_bot.utils.superuser import create_super_user 5 | 6 | _ = i18n.gettext 7 | 8 | 9 | @dp.message_handler(commands=["set_superuser"], commands_prefix="!", is_superuser=True) 10 | async def cmd_superuser(message: types.Message): 11 | args = message.get_args() 12 | if not args or not args[0].isdigit(): 13 | return False 14 | args = args.split() 15 | user_id = int(args[0]) 16 | remove = len(args) == 2 and args[1] == "-rm" 17 | 18 | try: 19 | result = await create_super_user(user_id=user_id, remove=remove) 20 | except ValueError: 21 | result = False 22 | 23 | if result: 24 | return await message.answer( 25 | _("Successful changed is_superuser to {is_superuser} for user {user}").format( 26 | is_superuser=not remove, user=user_id 27 | ) 28 | ) 29 | return await message.answer( 30 | _("Failed to set is_superuser to {is_superuser} for user {user}").format( 31 | is_superuser=not remove, user=user_id 32 | ) 33 | ) 34 | -------------------------------------------------------------------------------- /migrations/versions/2cdcb2a2c589_chat_settings_and_user_conversation_flag.py: -------------------------------------------------------------------------------- 1 | """chat settings and user conversation flag 2 | 3 | Revision ID: 2cdcb2a2c589 4 | Revises: 4c0d7a22344e 5 | Create Date: 2019-10-20 19:17:44.930296 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "2cdcb2a2c589" 13 | down_revision = "4c0d7a22344e" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column( 21 | "chats", 22 | sa.Column("join_filter", sa.Boolean(), server_default=sa.text("false"), nullable=True), 23 | ) 24 | op.add_column( 25 | "users", 26 | sa.Column( 27 | "start_conversation", sa.Boolean(), server_default=sa.text("false"), nullable=True 28 | ), 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_column("users", "start_conversation") 36 | op.drop_column("chats", "join_filter") 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aiogram_bot" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Alex Root Junior "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.9" 9 | aiogram = "^2.23.1" 10 | click = "^8.0.1" 11 | loguru = "^0.5.3" 12 | uvloop = "^0.16.0" 13 | aiohttp = "^3.7.4" 14 | envparse = "^0.2.0" 15 | SQLAlchemy = "1.3.24" 16 | SQLAlchemy-Utils = "^0.37.8" 17 | psycopg2-binary = "^2.9.1" 18 | asyncpg = "^0.24.0" 19 | gino = "0.8.3" 20 | alembic = "^1.6.5" 21 | aioredis = "^2.0.1" 22 | redis = "^3.5.3" 23 | aiohttp_healthcheck = "^1.3.1" 24 | requests = "^2.26.0" 25 | APScheduler = "^3.7.0" 26 | tenacity = "^8.0.1" 27 | sentry-sdk = "^1.5.0" 28 | magic-filter = "^1.0.9" 29 | 30 | [tool.poetry.dev-dependencies] 31 | black = "^21.7b0" 32 | isort = "^5.9.3" 33 | flake8 = "^3.9.2" 34 | ipython = "^7.26.0" 35 | 36 | [build-system] 37 | requires = ["poetry-core>=1.0.0"] 38 | build-backend = "poetry.core.masonry.api" 39 | 40 | [tool.black] 41 | line-length = 99 42 | include = '\.pyi?$' 43 | exclude = ''' 44 | /( 45 | \.git 46 | | \.mypy_cache 47 | | \.pytest_cache 48 | | \.tox 49 | | venv 50 | | build 51 | | dist 52 | )/ 53 | ''' 54 | -------------------------------------------------------------------------------- /aiogram_bot/misc.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import sentry_sdk 4 | from aiogram import Bot, Dispatcher, types 5 | from aiogram.contrib.fsm_storage.redis import RedisStorage2 6 | from loguru import logger 7 | 8 | from aiogram_bot import config 9 | from aiogram_bot.middlewares.i18n import I18nMiddleware 10 | 11 | app_dir: Path = Path(__file__).parent.parent 12 | locales_dir = app_dir / "locales" 13 | 14 | bot = Bot(config.TELEGRAM_TOKEN, parse_mode=types.ParseMode.HTML) 15 | storage = RedisStorage2(host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB_FSM) 16 | dp = Dispatcher(bot, storage=storage) 17 | i18n = I18nMiddleware("bot", locales_dir, default="en") 18 | 19 | # if config.SENTRY_URL: 20 | # logger.info("Setup Sentry SDK") 21 | # sentry_sdk.init( 22 | # config.SENTRY_URL, 23 | # traces_sample_rate=1.0, 24 | # ) 25 | 26 | 27 | def setup(): 28 | from aiogram_bot import filters, middlewares 29 | from aiogram_bot.utils import executor 30 | 31 | middlewares.setup(dp) 32 | filters.setup(dp) 33 | executor.setup() 34 | 35 | logger.info("Configure handlers...") 36 | # noinspection PyUnresolvedReferences 37 | import aiogram_bot.handlers 38 | -------------------------------------------------------------------------------- /aiogram_bot/utils/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from loguru import logger 4 | 5 | 6 | class InterceptHandler(logging.Handler): 7 | LEVELS_MAP = { 8 | logging.CRITICAL: "CRITICAL", 9 | logging.ERROR: "ERROR", 10 | logging.WARNING: "WARNING", 11 | logging.INFO: "INFO", 12 | logging.DEBUG: "DEBUG", 13 | } 14 | 15 | def _get_level(self, record): 16 | return self.LEVELS_MAP.get(record.levelno, record.levelno) 17 | 18 | def emit(self, record): 19 | # Get corresponding Loguru level if it exists 20 | try: 21 | level = logger.level(record.levelname).name 22 | except ValueError: 23 | level = record.levelno 24 | 25 | # Find caller from where originated the logged message 26 | frame, depth = logging.currentframe(), 2 27 | while frame.f_code.co_filename == logging.__file__: 28 | frame = frame.f_back 29 | depth += 1 30 | 31 | logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) 32 | 33 | 34 | def setup(): 35 | logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO) 36 | logger.disable("sqlalchemy.engine.base") 37 | -------------------------------------------------------------------------------- /aiogram_bot/middlewares/acl.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from aiogram import types 4 | from aiogram.dispatcher.middlewares import BaseMiddleware 5 | 6 | from aiogram_bot.models.chat import Chat 7 | from aiogram_bot.models.user import User 8 | 9 | 10 | class ACLMiddleware(BaseMiddleware): 11 | async def setup_chat(self, data: dict, user: types.User, chat: Optional[types.Chat] = None): 12 | user_id = user.id 13 | chat_id = chat.id if chat else user.id 14 | chat_type = chat.type if chat else "private" 15 | 16 | user = await User.get(user_id) 17 | if user is None: 18 | user = await User.create(id=user_id) 19 | chat = await Chat.get(chat_id) 20 | if chat is None: 21 | chat = await Chat.create(id=chat_id, type=chat_type) 22 | 23 | data["user"] = user 24 | data["chat"] = chat 25 | 26 | async def on_pre_process_message(self, message: types.Message, data: dict): 27 | await self.setup_chat(data, message.from_user, message.chat) 28 | 29 | async def on_pre_process_callback_query(self, query: types.CallbackQuery, data: dict): 30 | await self.setup_chat(data, query.from_user, query.message.chat if query.message else None) 31 | -------------------------------------------------------------------------------- /migrations/versions/57bc88e06e52_rename_superuser_column_and_add_is_.py: -------------------------------------------------------------------------------- 1 | """Rename superuser column and add is_official for chats 2 | 3 | Revision ID: 57bc88e06e52 4 | Revises: c7025892455f 5 | Create Date: 2019-10-24 22:14:57.104716 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "57bc88e06e52" 13 | down_revision = "c7025892455f" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column( 21 | "chats", 22 | sa.Column("is_official", sa.Boolean(), server_default=sa.text("false"), nullable=True), 23 | ) 24 | op.add_column( 25 | "users", 26 | sa.Column("is_superuser", sa.Boolean(), server_default=sa.text("false"), nullable=True), 27 | ) 28 | op.drop_column("users", "superuser") 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.add_column( 35 | "users", 36 | sa.Column( 37 | "superuser", 38 | sa.BOOLEAN(), 39 | server_default=sa.text("false"), 40 | autoincrement=False, 41 | nullable=True, 42 | ), 43 | ) 44 | op.drop_column("users", "is_superuser") 45 | op.drop_column("chats", "is_official") 46 | # ### end Alembic commands ### 47 | -------------------------------------------------------------------------------- /aiogram_bot/handlers/channel_filter.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | from aiogram import types 4 | from aiogram.utils.exceptions import BadRequest 5 | from loguru import logger 6 | from magic_filter import F 7 | 8 | from aiogram_bot.handlers.simple_admin import command_ban_sender_chat 9 | from aiogram_bot.misc import dp 10 | from aiogram_bot.models.chat import Chat, ChatAllowedChannels 11 | 12 | 13 | @dp.message_handler( 14 | F.ilter(F.sender_chat & (F.sender_chat.id != F.chat.id) & ~F.is_automatic_forward), 15 | content_types=types.ContentTypes.ANY, 16 | ) 17 | async def sender_chat_messages_handler(message: types.Message, chat: Chat): 18 | logger.info("Handled channel message") 19 | result = False 20 | target = message.sender_chat 21 | 22 | allowed_channel = await ChatAllowedChannels.get((message.chat.id, target.id)) 23 | if allowed_channel: 24 | logger.info("Is allowed {}", allowed_channel) 25 | return False 26 | 27 | if chat.ban_channels: 28 | logger.info("Check ban channels") 29 | result = await command_ban_sender_chat(message=message, target=target) 30 | 31 | if chat.delete_channel_messages: 32 | logger.info("Check delete message") 33 | logger.info( 34 | "Delete message from channel {} in chat {}", 35 | message.sender_chat.id, 36 | message.chat.id, 37 | ) 38 | with suppress(BadRequest): 39 | await message.delete() 40 | result = True 41 | 42 | return result 43 | -------------------------------------------------------------------------------- /aiogram_bot/config.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import secrets 3 | 4 | from envparse import env 5 | 6 | TELEGRAM_TOKEN = env.str("TELEGRAM_TOKEN") 7 | BOT_PUBLIC_PORT = env.int("BOT_PUBLIC_PORT", default=8080) 8 | 9 | DOMAIN = env.str("DOMAIN", default="example.com") 10 | SECRET_KEY = secrets.token_urlsafe(48) 11 | WEBHOOK_BASE_PATH = env.str("WEBHOOK_BASE_PATH", default="/webhook") 12 | WEBHOOK_PATH = f"{WEBHOOK_BASE_PATH}/{SECRET_KEY}" 13 | WEBHOOK_URL = f"https://{DOMAIN}{WEBHOOK_PATH}" 14 | 15 | REDIS_HOST = env.str("REDIS_HOST", default="localhost") 16 | REDIS_PORT = env.int("REDIS_PORT", default=6379) 17 | REDIS_DB_FSM = env.int("REDIS_DB_FSM", default=0) 18 | REDIS_DB_JOBSTORE = env.int("REDIS_DB_JOBSTORE", default=1) 19 | REDIS_DB_JOIN_LIST = env.int("REDIS_DB_JOIN_LIST", default=2) 20 | 21 | POSTGRES_HOST = env.str("POSTGRES_HOST", default="localhost") 22 | POSTGRES_PORT = env.int("POSTGRES_PORT", default=5432) 23 | POSTGRES_PASSWORD = env.str("POSTGRES_PASSWORD", default="") 24 | POSTGRES_USER = env.str("POSTGRES_USER", default="aiogram") 25 | POSTGRES_DB = env.str("POSTGRES_DB", default="aiogram") 26 | POSTGRES_URI = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" 27 | 28 | JOIN_CONFIRM_DURATION = datetime.timedelta(minutes=2) 29 | JOIN_NO_MEDIA_DURATION = datetime.timedelta(minutes=2) 30 | 31 | SUPERUSER_STARTUP_NOTIFIER = env.bool("SUPERUSER_STARTUP_NOTIFIER", default=False) 32 | HASTEBIN_URL = env.str("HASTEBIN_URL", default="https://paste.aiogram.dev") 33 | 34 | SENTRY_URL = env.str("SENTRY_URL", default="") 35 | -------------------------------------------------------------------------------- /aiogram_bot/utils/executor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import suppress 3 | 4 | from aiogram import Dispatcher 5 | from aiogram.utils.exceptions import TelegramAPIError 6 | from aiogram.utils.executor import Executor 7 | from loguru import logger 8 | 9 | from aiogram_bot import config 10 | from aiogram_bot.misc import dp 11 | from aiogram_bot.models import db 12 | from aiogram_bot.models.user import User 13 | from aiogram_bot.services import apscheduller, healthcheck, join_list 14 | 15 | runner = Executor(dp) 16 | 17 | 18 | async def on_startup_webhook(dispatcher: Dispatcher): 19 | logger.info("Configure Web-Hook URL to: {url}", url=config.WEBHOOK_URL) 20 | await dispatcher.bot.set_webhook(config.WEBHOOK_URL) 21 | 22 | 23 | async def on_startup_notify(dispatcher: Dispatcher): 24 | for user in await User.query.where(User.is_superuser == True).gino.all(): # NOQA 25 | with suppress(TelegramAPIError): 26 | await dispatcher.bot.send_message( 27 | chat_id=user.id, text="Bot started", disable_notification=True 28 | ) 29 | logger.info("Notified superuser {user} about bot is started.", user=user.id) 30 | await asyncio.sleep(0.2) 31 | 32 | 33 | def setup(): 34 | logger.info("Configure executor...") 35 | db.setup(runner) 36 | join_list.setup(runner) 37 | apscheduller.setup(runner) 38 | healthcheck.setup(runner) 39 | runner.on_startup(on_startup_webhook, webhook=True, polling=False) 40 | if config.SUPERUSER_STARTUP_NOTIFIER: 41 | runner.on_startup(on_startup_notify) 42 | -------------------------------------------------------------------------------- /aiogram_bot/models/chat.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.sql import expression 2 | 3 | from aiogram_bot.models.db import BaseModel, TimedBaseModel, db 4 | 5 | 6 | class Chat(TimedBaseModel): 7 | __tablename__ = "chats" 8 | 9 | id = db.Column(db.BigInteger, primary_key=True, index=True) 10 | type = db.Column(db.String) 11 | is_official = db.Column(db.Boolean, server_default=expression.false()) 12 | 13 | language = db.Column(db.String(12), default="en") 14 | join_filter = db.Column(db.Boolean, server_default=expression.false()) 15 | ban_channels = db.Column(db.Boolean, server_default=expression.false()) 16 | delete_channel_messages = db.Column(db.Boolean, server_default=expression.false()) 17 | report_to_admins = db.Column(db.Boolean, server_default=expression.true()) 18 | restrict_commands = db.Column(db.Boolean, server_default=expression.true()) 19 | 20 | 21 | class ChatRelatedModel(BaseModel): 22 | __abstract__ = True 23 | 24 | chat_id = db.Column( 25 | db.ForeignKey(f"{Chat.__tablename__}.id", ondelete="CASCADE", onupdate="CASCADE"), 26 | nullable=False, 27 | index=True, 28 | ) 29 | 30 | 31 | class ChatAllowedChannels(TimedBaseModel): 32 | __tablename__ = "chats_allowed_channels" 33 | 34 | chat_id = db.Column( 35 | db.ForeignKey(f"{Chat.__tablename__}.id", ondelete="CASCADE", onupdate="CASCADE"), 36 | nullable=False, 37 | primary_key=True, 38 | ) 39 | channel_id = db.Column(db.BigInteger, primary_key=True, nullable=False) 40 | added_by = db.Column( 41 | db.ForeignKey("users.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False 42 | ) 43 | -------------------------------------------------------------------------------- /docker-compose.stack.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | x-deploy: &base-deploy 4 | restart_policy: 5 | condition: on-failure 6 | placement: 7 | constraints: 8 | - node.labels.aiogram_bot == true 9 | 10 | services: 11 | bot: 12 | image: docker.illemius.xyz/aiogram/bot:latest 13 | command: 14 | - run-webhook 15 | healthcheck: 16 | test: ["CMD", "python", "-c", "import requests;assert requests.get('http://localhost:80/healthcheck').status_code == 200"] 17 | start_period: 5s 18 | environment: 19 | RUN_MIGRATIONS: 'true' 20 | WEBHOOK_BASE_PATH: 21 | networks: 22 | - default 23 | - web 24 | depends_on: 25 | - redis 26 | - postgres 27 | deploy: 28 | <<: *base-deploy 29 | update_config: 30 | parallelism: 2 31 | delay: 10s 32 | order: start-first 33 | failure_action: rollback 34 | labels: 35 | traefik.enable: true 36 | traefik.docker.network: web 37 | traefik.http.routers.aiogram-bot-https.entrypoints: web-secure 38 | traefik.http.routers.aiogram-bot-https.rule: Host(`${DOMAIN}`) && PathPrefix(`${WEBHOOK_BASE_PATH}`) 39 | traefik.http.routers.aiogram-bot-https.service: aiogram-bot 40 | traefik.http.routers.aiogram-bot-https.tls: true 41 | traefik.http.services.aiogram-bot.loadbalancer.server.port: 80 42 | 43 | redis: 44 | networks: 45 | - default 46 | deploy: 47 | <<: *base-deploy 48 | 49 | postgres: 50 | networks: 51 | - default 52 | deploy: 53 | <<: *base-deploy 54 | 55 | networks: 56 | default: 57 | driver: overlay 58 | web: 59 | driver: overlay 60 | external: true 61 | -------------------------------------------------------------------------------- /aiogram_bot/utils/timedelta.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | import typing 4 | 5 | from aiogram import types 6 | 7 | PATTERN = re.compile(r"(?P\d+)(?P[wdhms])") 8 | LINE_PATTERN = re.compile(r"^(\d+[wdhms]){1,}$") 9 | 10 | MODIFIERS = { 11 | "w": datetime.timedelta(weeks=1), 12 | "d": datetime.timedelta(days=1), 13 | "h": datetime.timedelta(hours=1), 14 | "m": datetime.timedelta(minutes=1), 15 | "s": datetime.timedelta(seconds=1), 16 | } 17 | 18 | 19 | class TimedeltaParseError(Exception): 20 | pass 21 | 22 | 23 | def parse_timedelta(value: str) -> datetime.timedelta: 24 | match = LINE_PATTERN.match(value) 25 | if not match: 26 | raise TimedeltaParseError("Invalid time format") 27 | 28 | try: 29 | result = datetime.timedelta() 30 | for match in PATTERN.finditer(value): 31 | value, modifier = match.groups() 32 | 33 | result += int(value) * MODIFIERS[modifier] 34 | except OverflowError: 35 | raise TimedeltaParseError("Timedelta value is too large") 36 | 37 | return result 38 | 39 | 40 | async def parse_timedelta_from_message( 41 | message: types.Message, 42 | ) -> typing.Optional[datetime.timedelta]: 43 | _, *args = message.text.split() 44 | 45 | if args: # Parse custom duration 46 | try: 47 | duration = parse_timedelta(args[0]) 48 | except TimedeltaParseError: 49 | await message.reply("Failed to parse duration") 50 | return 51 | if duration <= datetime.timedelta(seconds=30): 52 | return datetime.timedelta(seconds=30) 53 | return duration 54 | else: 55 | return datetime.timedelta(minutes=15) 56 | -------------------------------------------------------------------------------- /aiogram_bot/models/db.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List 3 | 4 | import sqlalchemy as sa 5 | from aiogram import Dispatcher 6 | from aiogram.utils.executor import Executor 7 | from gino import Gino 8 | from loguru import logger 9 | 10 | from aiogram_bot import config 11 | 12 | db = Gino() 13 | 14 | 15 | class BaseModel(db.Model): 16 | __abstract__ = True 17 | 18 | def __str__(self): 19 | model = self.__class__.__name__ 20 | table: sa.Table = sa.inspect(self.__class__) 21 | primary_key_columns: List[sa.Column] = table.primary_key.columns 22 | values = { 23 | column.name: getattr(self, self._column_name_map[column.name]) 24 | for column in primary_key_columns 25 | } 26 | values_str = " ".join(f"{name}={value!r}" for name, value in values.items()) 27 | return f"<{model} {values_str}>" 28 | 29 | 30 | class TimedBaseModel(BaseModel): 31 | __abstract__ = True 32 | 33 | created_at = db.Column(db.DateTime(True), server_default=db.func.now()) 34 | updated_at = db.Column( 35 | db.DateTime(True), 36 | default=datetime.datetime.utcnow, 37 | onupdate=datetime.datetime.utcnow, 38 | server_default=db.func.now(), 39 | ) 40 | 41 | 42 | async def on_startup(dispatcher: Dispatcher): 43 | logger.info("Setup PostgreSQL Connection") 44 | await db.set_bind(config.POSTGRES_URI) 45 | 46 | 47 | async def on_shutdown(dispatcher: Dispatcher): 48 | bind = db.pop_bind() 49 | if bind: 50 | logger.info("Close PostgreSQL Connection") 51 | await bind.close() 52 | 53 | 54 | def setup(executor: Executor): 55 | executor.on_startup(on_startup) 56 | executor.on_shutdown(on_shutdown) 57 | -------------------------------------------------------------------------------- /aiogram_bot/services/healthcheck.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.utils.executor import Executor 3 | from aiohttp_healthcheck import HealthCheck 4 | from loguru import logger 5 | 6 | from aiogram_bot import config 7 | 8 | health = HealthCheck() 9 | 10 | 11 | def setup(executor: Executor): 12 | executor.on_startup(on_startup, webhook=True, polling=False) 13 | 14 | 15 | async def on_startup(dispatcher: Dispatcher): 16 | from aiogram_bot.utils.executor import runner 17 | 18 | logger.info("Setup healthcheck") 19 | 20 | health.add_check(check_redis) 21 | health.add_check(check_postgres) 22 | health.add_check(check_webhook) 23 | runner.web_app.router.add_get("/healthcheck", health) 24 | 25 | 26 | async def check_redis(): 27 | from aiogram_bot.misc import storage 28 | 29 | try: 30 | redis = await storage.redis() 31 | info = await redis.info() 32 | except Exception as e: 33 | return False, str(e) 34 | return True, f"Redis {info['server']['redis_version']}" 35 | 36 | 37 | async def check_postgres(): 38 | from aiogram_bot.models.db import db 39 | 40 | try: 41 | version = await db.scalar("select version();") 42 | except Exception as e: 43 | return False, str(e) 44 | return True, version 45 | 46 | 47 | async def check_webhook(): 48 | from aiogram_bot.misc import bot 49 | 50 | webhook = await bot.get_webhook_info() 51 | if webhook.url and webhook.url == config.WEBHOOK_URL: 52 | return True, f"Webhook configured. Pending updates count {webhook.pending_update_count}" 53 | else: 54 | logger.error("Configured wrong webhook URL {webhook}", webhook=webhook.url) 55 | return False, "Configured invalid webhook URL" 56 | -------------------------------------------------------------------------------- /migrations/versions/c639acad707a_added_settings_for_channel_messages.py: -------------------------------------------------------------------------------- 1 | """Added settings for channel messages 2 | 3 | Revision ID: c639acad707a 4 | Revises: e0d427aae93e 5 | Create Date: 2021-12-09 23:15:10.306531 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c639acad707a' 14 | down_revision = 'e0d427aae93e' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('chats_allowed_channels', 22 | sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), 23 | sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), 24 | sa.Column('chat_id', sa.BigInteger(), nullable=False), 25 | sa.Column('channel_id', sa.BigInteger(), nullable=False), 26 | sa.Column('added_by', sa.BigInteger(), nullable=False), 27 | sa.ForeignKeyConstraint(['added_by'], ['users.id'], onupdate='CASCADE', ondelete='CASCADE'), 28 | sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], onupdate='CASCADE', ondelete='CASCADE'), 29 | sa.PrimaryKeyConstraint('chat_id', 'channel_id') 30 | ) 31 | op.add_column('chats', sa.Column('ban_channels', sa.Boolean(), server_default=sa.text('false'), nullable=True)) 32 | op.add_column('chats', sa.Column('delete_channel_messages', sa.Boolean(), server_default=sa.text('false'), nullable=True)) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_column('chats', 'delete_channel_messages') 39 | op.drop_column('chats', 'ban_channels') 40 | op.drop_table('chats_allowed_channels') 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config, pool 5 | 6 | from aiogram_bot.config import POSTGRES_URI 7 | from aiogram_bot.models.base import db 8 | 9 | config = context.config 10 | fileConfig(config.config_file_name) 11 | target_metadata = db 12 | config.set_main_option("sqlalchemy.url", POSTGRES_URI) 13 | 14 | 15 | def run_migrations_offline(): 16 | """Run migrations in 'offline' mode. 17 | 18 | This configures the context with just a URL 19 | and not an Engine, though an Engine is acceptable 20 | here as well. By skipping the Engine creation 21 | we don't even need a DBAPI to be available. 22 | 23 | Calls to context.execute() here emit the given string to the 24 | script output. 25 | 26 | """ 27 | url = config.get_main_option("sqlalchemy.url") 28 | context.configure( 29 | url=url, 30 | target_metadata=target_metadata, 31 | literal_binds=True, 32 | dialect_opts={"paramstyle": "named"}, 33 | ) 34 | 35 | with context.begin_transaction(): 36 | context.run_migrations() 37 | 38 | 39 | def run_migrations_online(): 40 | """Run migrations in 'online' mode. 41 | 42 | In this scenario we need to create an Engine 43 | and associate a connection with the context. 44 | 45 | """ 46 | connectable = engine_from_config( 47 | config.get_section(config.config_ini_section), 48 | prefix="sqlalchemy.", 49 | poolclass=pool.NullPool, 50 | 51 | ) 52 | 53 | with connectable.connect() as connection: 54 | context.configure(connection=connection, target_metadata=target_metadata) 55 | 56 | with context.begin_transaction(): 57 | context.run_migrations() 58 | 59 | 60 | if context.is_offline_mode(): 61 | run_migrations_offline() 62 | else: 63 | run_migrations_online() 64 | -------------------------------------------------------------------------------- /aiogram_bot/handlers/hastebin.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | from aiogram import md, types 4 | from aiogram.types.chat_member import ChatMemberAdministrator, ChatMemberOwner 5 | from aiogram.utils.exceptions import TelegramAPIError 6 | 7 | from aiogram_bot.misc import bot, dp, i18n 8 | from aiogram_bot.services.hastebin import hastebin 9 | 10 | _ = i18n.gettext 11 | 12 | 13 | @dp.message_handler(commands=["paste"]) 14 | async def command_paste(message: types.Message): 15 | messages_to_delete = [] 16 | if message.reply_to_message and message.reply_to_message.from_user.id != bot.id: 17 | content = message.reply_to_message.text or message.reply_to_message.caption 18 | dst = message.reply_to_message 19 | member = await bot.get_chat_member(message.chat.id, message.from_user.id) 20 | if isinstance(member, ChatMemberOwner) or ( 21 | isinstance(member, ChatMemberAdministrator) and member.can_delete_messages 22 | ): 23 | messages_to_delete.append(dst) 24 | else: 25 | content = message.get_args() 26 | dst = message 27 | messages_to_delete.append(dst) 28 | 29 | if not content or (len(content) < 30 and content.count('\n') < 2): 30 | return await message.reply(_("Content to move is too short!")) 31 | 32 | content = content.encode() 33 | response = await hastebin.create_document(content) 34 | 35 | document_url = hastebin.format_url(response["key"]) 36 | text = _( 37 | "Message originally posted by {author} was moved to {url} service\n" 38 | "Content size: {size} bytes" 39 | ).format( 40 | author=dst.from_user.get_mention(as_html=True), 41 | url=md.hlink("HasteBin", document_url), 42 | size=len(content), 43 | ) 44 | await dst.reply(text, allow_sending_without_reply=True) 45 | 46 | for message_to_delete in messages_to_delete: 47 | with suppress(TelegramAPIError): 48 | await message_to_delete.delete() 49 | -------------------------------------------------------------------------------- /migrations/versions/4201ee77de3d_add_user_and_chat_models.py: -------------------------------------------------------------------------------- 1 | """add user and chat models 2 | 3 | Revision ID: 4201ee77de3d 4 | Revises: 5 | Create Date: 2019-10-16 21:54:08.625057 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "4201ee77de3d" 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "chats", 22 | sa.Column( 23 | "created_at", 24 | sa.DateTime(timezone=True), 25 | server_default=sa.text("now()"), 26 | nullable=True, 27 | ), 28 | sa.Column( 29 | "updated_at", 30 | sa.DateTime(timezone=True), 31 | server_default=sa.text("now()"), 32 | nullable=True, 33 | ), 34 | sa.Column("id", sa.BigInteger(), nullable=False), 35 | sa.Column("language", sa.String(length=12), nullable=True), 36 | sa.PrimaryKeyConstraint("id"), 37 | ) 38 | op.create_index(op.f("ix_chats_id"), "chats", ["id"], unique=False) 39 | op.create_table( 40 | "users", 41 | sa.Column( 42 | "created_at", 43 | sa.DateTime(timezone=True), 44 | server_default=sa.text("now()"), 45 | nullable=True, 46 | ), 47 | sa.Column( 48 | "updated_at", 49 | sa.DateTime(timezone=True), 50 | server_default=sa.text("now()"), 51 | nullable=True, 52 | ), 53 | sa.Column("id", sa.Integer(), nullable=False), 54 | sa.PrimaryKeyConstraint("id"), 55 | ) 56 | op.create_index(op.f("ix_users_id"), "users", ["id"], unique=True) 57 | # ### end Alembic commands ### 58 | 59 | 60 | def downgrade(): 61 | # ### commands auto generated by Alembic - please adjust! ### 62 | op.drop_index(op.f("ix_users_id"), table_name="users") 63 | op.drop_table("users") 64 | op.drop_index(op.f("ix_chats_id"), table_name="chats") 65 | op.drop_table("chats") 66 | # ### end Alembic commands ### 67 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = driver://user:pass@localhost/dbname 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = INFO 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /aiogram_bot/utils/cli.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import click 4 | from aiogram.__main__ import SysInfo 5 | from loguru import logger 6 | 7 | try: 8 | import aiohttp_autoreload 9 | except ImportError: 10 | aiohttp_autoreload = None 11 | 12 | 13 | @click.group() 14 | def cli(): 15 | from aiogram_bot import misc 16 | from aiogram_bot.utils import logging 17 | 18 | logging.setup() 19 | misc.setup() 20 | 21 | 22 | def auto_reload_mixin(func): 23 | @click.option( 24 | "--autoreload", is_flag=True, default=False, help="Reload application on file changes" 25 | ) 26 | @functools.wraps(func) 27 | def wrapper(autoreload: bool, *args, **kwargs): 28 | if autoreload and aiohttp_autoreload: 29 | logger.warning( 30 | "Application started in live-reload mode. Please disable it in production!" 31 | ) 32 | aiohttp_autoreload.start() 33 | elif autoreload and not aiohttp_autoreload: 34 | click.echo("`aiohttp_autoreload` is not installed.", err=True) 35 | return func(*args, **kwargs) 36 | 37 | return wrapper 38 | 39 | 40 | @cli.command() 41 | def version(): 42 | """ 43 | Get application version 44 | """ 45 | click.echo(SysInfo()) 46 | 47 | 48 | @cli.command() 49 | @click.option("--skip-updates", is_flag=True, default=False, help="Skip pending updates") 50 | @auto_reload_mixin 51 | def polling(skip_updates: bool): 52 | """ 53 | Start application in polling mode 54 | """ 55 | 56 | from aiogram_bot.utils.executor import runner 57 | 58 | runner.skip_updates = skip_updates 59 | runner.start_polling(reset_webhook=True) 60 | 61 | 62 | @cli.command() 63 | @auto_reload_mixin 64 | def webhook(): 65 | """ 66 | Run application in webhook mode 67 | """ 68 | from aiogram_bot import config 69 | from aiogram_bot.utils.executor import runner 70 | 71 | runner.start_webhook(webhook_path=config.WEBHOOK_PATH, port=config.BOT_PUBLIC_PORT) 72 | 73 | 74 | @cli.command() 75 | @click.argument("user_id", type=int) 76 | @click.option("--remove", "--rm", is_flag=True, default=False, help="Remove superuser rights") 77 | def superuser(user_id: int, remove: bool): 78 | from aiogram_bot.utils.executor import runner 79 | from aiogram_bot.utils.superuser import create_super_user 80 | 81 | try: 82 | result = runner.start(create_super_user(user_id, remove)) 83 | except Exception as e: 84 | logger.exception("Failed to create superuser: {e}", e=e) 85 | result = None 86 | 87 | if not result: 88 | exit(1) 89 | -------------------------------------------------------------------------------- /aiogram_bot/utils/before_start.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import tenacity 4 | from loguru import logger 5 | from tenacity import _utils 6 | 7 | from aiogram_bot import config 8 | from aiogram_bot.models.db import db 9 | from aiogram_bot.utils.redis import BaseRedis 10 | 11 | TIMEOUT_BETWEEN_ATTEMPTS = 2 12 | MAX_TIMEOUT = 30 13 | 14 | 15 | def before_log(retry_state): 16 | if retry_state.outcome.failed: 17 | verb, value = "raised", retry_state.outcome.exception() 18 | else: 19 | verb, value = "returned", retry_state.outcome.result() 20 | 21 | logger.info( 22 | "Retrying {callback} in {sleep} seconds as it {verb} {value}", 23 | callback=_utils.get_callback_name(retry_state.fn), 24 | sleep=getattr(retry_state.next_action, "sleep"), 25 | verb=verb, 26 | value=value, 27 | ) 28 | 29 | 30 | def after_log(retry_state): 31 | logger.info( 32 | "Finished call to {callback!r} after {time:.2f}, this was the {attempt} time calling it.", 33 | callback=_utils.get_callback_name(retry_state.fn), 34 | time=retry_state.seconds_since_start, 35 | attempt=_utils.to_ordinal(retry_state.attempt_number), 36 | ) 37 | 38 | 39 | @tenacity.retry( 40 | wait=tenacity.wait_fixed(TIMEOUT_BETWEEN_ATTEMPTS), 41 | stop=tenacity.stop_after_delay(MAX_TIMEOUT), 42 | before_sleep=before_log, 43 | after=after_log, 44 | ) 45 | async def wait_redis(): 46 | connector = BaseRedis(host=config.REDIS_HOST, port=config.REDIS_PORT, db=0) 47 | try: 48 | await connector.connect() 49 | info = await connector.redis.info() 50 | logger.info("Connected to Redis server v{redis}", redis=info["redis_version"]) 51 | finally: 52 | await connector.disconnect() 53 | 54 | 55 | @tenacity.retry( 56 | wait=tenacity.wait_fixed(TIMEOUT_BETWEEN_ATTEMPTS), 57 | stop=tenacity.stop_after_delay(MAX_TIMEOUT), 58 | before_sleep=before_log, 59 | after=after_log, 60 | ) 61 | async def wait_postgres(): 62 | await db.set_bind(config.POSTGRES_URI) 63 | version = await db.scalar("SELECT version();") 64 | logger.info("Connected to {postgres}", postgres=version) 65 | 66 | 67 | async def main(): 68 | logger.info("Wait for RedisDB...") 69 | 70 | try: 71 | await wait_redis() 72 | except tenacity.RetryError: 73 | logger.error("Failed to establish connection with RedisDB.") 74 | exit(1) 75 | 76 | logger.info("Wait for PostgreSQL...") 77 | try: 78 | await wait_postgres() 79 | except tenacity.RetryError: 80 | logger.error("Failed to establish connection with PostgreSQL.") 81 | exit(1) 82 | logger.info("Ready.") 83 | 84 | 85 | if __name__ == "__main__": 86 | asyncio.run(main()) 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiogram_bot 2 | 3 | This bot is used as example of usage [aiogram](https://github.com/aiogram/aiogram) framework 4 | and as admin-helper in our community chats. 5 | 6 | ## What this bot can do? 7 | 8 | - May exist 9 | - Watch new chat members and filter users (ask question and restrict user) 10 | - Has simple admin commands for making restrictions 11 | - Chat admins notifier (command which send message to all admins when someone is report message in chat) 12 | - Can be translated (en, ru, uk languages) 13 | 14 | ## Development 15 | 16 | ### System dependencies 17 | 18 | - Python 3.7 19 | - pipenv 20 | - Docker 21 | - docker-compose 22 | - make 23 | 24 | ### Setup environment 25 | 26 | - Install dependencies in venv: `pipenv install --dev` 27 | - Copy `.env.dist` to `.env` file and change values in this file 28 | - Run databases in docker: `make docker-up-db` 29 | - Apply migrations: `make migrate` 30 | 31 | ### Project structure 32 | 33 | - Application package is in `app` 34 | - All text translations is placed in `locales` 35 | - Migrations is placed in `migrations` 36 | - Entry-point is `app/__main__.py` (Can be executed as `python -m app`) 37 | ... 38 | 39 | ### Contributing 40 | 41 | Before you will make commit need to run `black`, `isort` and `Flake8` via command `make lint` 42 | If you change Database models you will need to generate migrations: `make migration message="do something"` 43 | 44 | ## Deployment 45 | 46 | Here listed only Docker deployment methods. 47 | That's mean you can't read here how to deploy the bot with other methods instead of Docker 48 | but you can do that manually. 49 | 50 | Also this bot can't be normally started in Docker with polling mode 51 | because in this mode aiohttp server will be not started and healthcheck can not be started. 52 | 53 | ### docker-compose 54 | 55 | Pre-requirements: 56 | - Docker 57 | - docker-compose 58 | 59 | Steps: 60 | - Prepare `.env` file 61 | - ... (TODO) 62 | - `make app-create` - for first deploy, for updating or restarting 63 | 64 | Stopping: 65 | - `make docker-stop` 66 | 67 | Destroying (with volumes): 68 | - `make docker-destroy` 69 | 70 | ### Docker Swarm 71 | 72 | Pre-requirements: 73 | - Docker (with activated swarm mode) 74 | - traefik 2.0 in Docker (with overlay network named `web`) 75 | 76 | ### Commands: 77 | 78 | ... 79 | 80 | ### How this bot is deployed now? 81 | 82 | In Docker Swarm at [Illemius](https://illemius.xyz) with CI/CD 83 | 84 | Steps: 85 | 1. GitHub Actions: 86 | 1. Build docker image 87 | 1. Publish it to the private **Illemius** Docker registry 88 | 1. Trigger Portainer webhook in the **Illemius** cluster via cURL 89 | 1. Portainer will trigger updating of the bot service 90 | 1. Docker run new instance of container at specified node 91 | 1. When container is started by first step it will run migrations 92 | 1. Docker wait until new instance will be healthy 93 | 1. Traefik watch Docker container and update the routes when new one is available 94 | 1. Stop old instance of Bot container 95 | -------------------------------------------------------------------------------- /aiogram_bot/handlers/base.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | from aiogram import __main__ as aiogram_core 3 | from aiogram import types 4 | from aiogram.dispatcher.filters import CommandHelp, CommandStart 5 | from aiogram.utils.markdown import hbold, hlink, quote_html 6 | from loguru import logger 7 | 8 | from aiogram_bot.misc import dp, i18n 9 | from aiogram_bot.models.user import User 10 | 11 | _ = i18n.gettext 12 | 13 | 14 | @dp.message_handler(CommandStart()) 15 | async def cmd_start(message: types.Message, user: User): 16 | logger.info("User {user} start conversation with bot", user=message.from_user.id) 17 | await message.answer( 18 | _( 19 | "Hello, {user}.\n" 20 | "Send /help if you want to read my commands list " 21 | "and also you can change language by sending /settings command.\n" 22 | "My source code: {source_url}" 23 | ).format( 24 | user=hbold(message.from_user.full_name), 25 | source_url=hlink("GitHub", "https://github.com/aiogram/bot"), 26 | ) 27 | ) 28 | 29 | await user.update(start_conversation=True).apply() 30 | 31 | 32 | @dp.message_handler(CommandHelp()) 33 | async def cmd_help(message: types.Message): 34 | logger.info("User {user} read help in {chat}", user=message.from_user.id, chat=message.chat.id) 35 | text = [ 36 | hbold(_("Here you can read the list of my commands:")), 37 | _("{command} - Start conversation with bot").format(command="/start"), 38 | _("{command} - Get this message").format(command="/help"), 39 | _("{command} - Chat or user settings").format(command="/settings"), 40 | _("{command} - My version").format(command="/version"), 41 | _("{command} - Publish content to HasteBin").format(command="/paste"), 42 | "", 43 | ] 44 | 45 | if message.chat.type in {types.ChatType.PRIVATE}: 46 | text.extend([_("In chats this commands list can be other")]) 47 | else: 48 | text.extend( 49 | [ 50 | hbold(_("Available only in groups:")), 51 | _("{command} - Report message to chat administrators").format( 52 | command="/report, !report, @admin" 53 | ), 54 | _("{command} - Set RO mode for user").format(command="!ro"), 55 | _("{command} - Ban user").format(command="!ban"), 56 | "", 57 | _("In private chats this commands list can be other"), 58 | ] 59 | ) 60 | await message.reply("\n".join(text)) 61 | 62 | 63 | @dp.message_handler(commands=["version"]) 64 | async def cmd_version(message: types.Message): 65 | await message.reply( 66 | _("My Engine:\n{aiogram}").format(aiogram=quote_html(str(aiogram_core.SysInfo()))) 67 | ) 68 | 69 | 70 | @dp.errors_handler() 71 | async def errors_handler(update: types.Update, exception: Exception): 72 | try: 73 | raise exception 74 | except Exception as e: 75 | logger.exception("Cause exception {e} in update {update}", e=e, update=update) 76 | sentry_sdk.capture_exception(e) 77 | return True 78 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include .env 2 | 3 | tail := 200 4 | PYTHONPATH := $(shell pwd):${PYTHONPATH} 5 | 6 | PROJECT := aiogram_bot 7 | LOCALES_DOMAIN := bot 8 | LOCALES_DIR := locales 9 | VERSION := 0.1 10 | COPYRIGHT := Illemius 11 | PIPENV_VERBOSITY := -1 12 | 13 | # ================================================================================================= 14 | # Base 15 | # ================================================================================================= 16 | 17 | default:help 18 | 19 | help: 20 | @echo "aiogram bot" 21 | 22 | # ================================================================================================= 23 | # Development 24 | # ================================================================================================= 25 | 26 | isort: 27 | poetry run isort aiogram_bot 28 | 29 | black: 30 | poetry run black aiogram_bot 31 | 32 | flake8: 33 | poetry run flake8 aiogram_bot 34 | 35 | lint: isort black flake8 36 | 37 | entrypoint: 38 | pipenv run bash ../docker-entrypoint.sh ${args} 39 | 40 | texts-update: 41 | poetry run pybabel extract . \ 42 | -o ${LOCALES_DIR}/${LOCALES_DOMAIN}.pot \ 43 | --project=${PROJECT} \ 44 | --version=${VERSION} \ 45 | --copyright-holder=${COPYRIGHT} \ 46 | -k __:1,2 \ 47 | --sort-by-file -w 99 48 | poetry run pybabel update \ 49 | -d ${LOCALES_DIR} \ 50 | -D ${LOCALES_DOMAIN} \ 51 | --update-header-comment \ 52 | -i ${LOCALES_DIR}/${LOCALES_DOMAIN}.pot 53 | 54 | texts-compile: 55 | poetry run pybabel compile -d ${LOCALES_DIR} -D ${LOCALES_DOMAIN} 56 | 57 | texts-create-language: 58 | poetry run pybabel init -i ${LOCALES_DIR}/${LOCALES_DOMAIN}.pot -d ${LOCALES_DIR} -D ${LOCALES_DOMAIN} -l ${language} 59 | 60 | alembic: 61 | PYTHONPATH=$(shell pwd):${PYTHONPATH} poetry run alembic ${args} 62 | 63 | migrate: 64 | PYTHONPATH=$(shell pwd):${PYTHONPATH} poetry run alembic upgrade head 65 | 66 | migration: 67 | PYTHONPATH=$(shell pwd):${PYTHONPATH} poetry run alembic revision --autogenerate -m "${message}" 68 | 69 | downgrade: 70 | PYTHONPATH=$(shell pwd):${PYTHONPATH} poetry run alembic downgrade -1 71 | 72 | beforeStart: docker-up-db migrate texts-compile 73 | 74 | app: 75 | pipenv run python -m aiogram_bot ${args} 76 | 77 | start: 78 | $(MAKE) beforeStart 79 | $(MAKE) aiogram_bot args="run-polling" 80 | 81 | # ================================================================================================= 82 | # Docker 83 | # ================================================================================================= 84 | 85 | docker-config: 86 | docker-compose config 87 | 88 | docker-ps: 89 | docker-compose ps 90 | 91 | docker-build: 92 | docker-compose build 93 | 94 | docker-up-db: 95 | docker-compose up -d redis postgres 96 | 97 | docker-up: 98 | docker-compose up -d --remove-orphans 99 | 100 | docker-stop: 101 | docker-compose stop 102 | 103 | docker-down: 104 | docker-compose down 105 | 106 | docker-destroy: 107 | docker-compose down -v --remove-orphans 108 | 109 | docker-logs: 110 | docker-compose logs -f --tail=${tail} ${args} 111 | 112 | # ================================================================================================= 113 | # Application in Docker 114 | # ================================================================================================= 115 | 116 | app-create: docker-build docker-stop docker-up 117 | 118 | app-logs: 119 | $(MAKE) docker-logs args="bot" 120 | 121 | app-stop: docker-stop 122 | 123 | app-down: docker-down 124 | 125 | app-start: docker-stop docker-up 126 | 127 | app-destroy: docker-destroy 128 | -------------------------------------------------------------------------------- /aiogram_bot/services/join_list.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | from contextlib import suppress 4 | from typing import List 5 | 6 | from aiogram import Dispatcher 7 | from aiogram.utils.exceptions import MessageToDeleteNotFound 8 | from aiogram.utils.executor import Executor 9 | from loguru import logger 10 | 11 | from aiogram_bot import config 12 | from aiogram_bot.misc import bot 13 | from aiogram_bot.services.apscheduller import scheduler 14 | from aiogram_bot.utils.redis import BaseRedis 15 | 16 | JOB_PREFIX = "join_list_cleaner" 17 | 18 | 19 | class JoinListService(BaseRedis): 20 | def __init__(self, prefix="chat", *args, **kwargs): 21 | super(JoinListService, self).__init__(*args, **kwargs) 22 | self.prefix = prefix 23 | 24 | def create_key(self, chat_id: int, message_id: int) -> str: 25 | return f"{self.prefix}:{chat_id}:{message_id}" 26 | 27 | async def create_list(self, chat_id: int, message_id: int, users: List[int]): 28 | key = self.create_key(chat_id=chat_id, message_id=message_id) 29 | for user_id in users: 30 | score = time.time() 31 | logger.info("Add user to join-list {key} {value}", key=key, score=score, value=user_id) 32 | await self.redis.zadd(key, {str(user_id): score}) 33 | scheduler.add_job( 34 | join_expired, 35 | "date", 36 | id=f"{JOB_PREFIX}:{chat_id}:{message_id}", 37 | run_date=datetime.datetime.utcnow() + config.JOIN_CONFIRM_DURATION, 38 | kwargs={"chat_id": chat_id, "message_id": message_id}, 39 | ) 40 | 41 | async def pop_user_from_list(self, chat_id: int, message_id: int, user_id: int): 42 | key = self.create_key(chat_id=chat_id, message_id=message_id) 43 | in_list = await self.redis.zrem(key, user_id) 44 | if in_list: 45 | logger.info("Remove user from join-list {key} {user_id}", key=key, user_id=user_id) 46 | return in_list 47 | 48 | async def check_list(self, chat_id: int, message_id: int) -> List[int]: 49 | key = self.create_key(chat_id=chat_id, message_id=message_id) 50 | users_list = await self.redis.zrange(key, -1, -2) 51 | return list(map(int, users_list)) 52 | 53 | 54 | async def join_expired(chat_id: int, message_id: int): 55 | users = await join_list.check_list(chat_id=chat_id, message_id=message_id) 56 | if not users: 57 | logger.info( 58 | "All users from join-list in chat {chat} and message {message} already answer for question", 59 | chat=chat_id, 60 | message=message_id, 61 | ) 62 | return 63 | 64 | for user_id in users: 65 | await join_list.pop_user_from_list(chat_id=chat_id, message_id=message_id, user_id=user_id) 66 | logger.info( 67 | "Kick chat member {user} from chat {chat} " 68 | "in due to user do not answer to the question in message {message}.", 69 | user=user_id, 70 | chat=chat_id, 71 | message=message_id, 72 | ) 73 | await bot.kick_chat_member(chat_id=chat_id, user_id=user_id) 74 | await bot.unban_chat_member(chat_id=chat_id, user_id=user_id) 75 | 76 | with suppress(MessageToDeleteNotFound): 77 | await bot.delete_message(chat_id, message_id) 78 | 79 | 80 | join_list = JoinListService( 81 | host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB_JOIN_LIST 82 | ) 83 | 84 | 85 | async def on_startup(dispatcher: Dispatcher): 86 | await join_list.connect() 87 | 88 | 89 | async def on_shutdown(dispatcher: Dispatcher): 90 | await join_list.disconnect() 91 | 92 | 93 | def setup(runner: Executor): 94 | runner.on_startup(on_startup) 95 | runner.on_shutdown(on_shutdown) 96 | -------------------------------------------------------------------------------- /aiogram_bot/filters/has_permissions.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from dataclasses import dataclass 3 | 4 | from aiogram import types 5 | from aiogram.dispatcher.filters import Filter 6 | 7 | 8 | @dataclass 9 | class HasPermissions(Filter): 10 | """ 11 | Validate the user has specified permissions in chat 12 | """ 13 | 14 | can_post_messages: bool = False 15 | can_edit_messages: bool = False 16 | can_delete_messages: bool = False 17 | can_restrict_members: bool = False 18 | can_promote_members: bool = False 19 | can_change_info: bool = False 20 | can_invite_users: bool = False 21 | can_pin_messages: bool = False 22 | 23 | ARGUMENTS = { 24 | "user_can_post_messages": "can_post_messages", 25 | "user_can_edit_messages": "can_edit_messages", 26 | "user_can_delete_messages": "can_delete_messages", 27 | "user_can_restrict_members": "can_restrict_members", 28 | "user_can_promote_members": "can_promote_members", 29 | "user_can_change_info": "can_change_info", 30 | "user_can_invite_users": "can_invite_users", 31 | "user_can_pin_messages": "can_pin_messages", 32 | } 33 | PAYLOAD_ARGUMENT_NAME = "user_member" 34 | 35 | def __post_init__(self): 36 | self.required_permissions = { 37 | arg: True for arg in self.ARGUMENTS.values() if getattr(self, arg) 38 | } 39 | 40 | @classmethod 41 | def validate( 42 | cls, full_config: typing.Dict[str, typing.Any] 43 | ) -> typing.Optional[typing.Dict[str, typing.Any]]: 44 | config = {} 45 | for alias, argument in cls.ARGUMENTS.items(): 46 | if alias in full_config: 47 | config[argument] = full_config.pop(alias) 48 | return config 49 | 50 | def _get_cached_value(self, message: types.Message): 51 | try: 52 | return message.conf[self.PAYLOAD_ARGUMENT_NAME] 53 | except KeyError: 54 | return None 55 | 56 | def _set_cached_value(self, message: types.Message, member: types.ChatMember): 57 | message.conf[self.PAYLOAD_ARGUMENT_NAME] = member 58 | 59 | async def _get_chat_member(self, message: types.Message): 60 | chat_member: types.ChatMember = self._get_cached_value(message) 61 | if chat_member is None: 62 | admins = await message.chat.get_administrators() 63 | target_user_id = await self.get_target_id(message) 64 | try: 65 | chat_member = next(filter(lambda member: member.user.id == target_user_id, admins)) 66 | except StopIteration: 67 | return False 68 | self._set_cached_value(message, chat_member) 69 | return chat_member 70 | 71 | async def check( 72 | self, message: types.Message 73 | ) -> typing.Union[bool, typing.Dict[str, typing.Any]]: 74 | chat_member = await self._get_chat_member(message) 75 | if not chat_member: 76 | return False 77 | if chat_member.status == types.ChatMemberStatus.CREATOR: 78 | return chat_member 79 | for permission, value in self.required_permissions.items(): 80 | if not getattr(chat_member, permission): 81 | return False 82 | 83 | return {self.PAYLOAD_ARGUMENT_NAME: chat_member} 84 | 85 | async def get_target_id(self, message: types.Message) -> int: 86 | return message.from_user.id 87 | 88 | 89 | class BotHasPermissions(HasPermissions): 90 | """ 91 | Validate the bot has permissions in chat 92 | """ 93 | 94 | ARGUMENTS = { 95 | "bot_can_post_messages": "can_post_messages", 96 | "bot_can_edit_messages": "can_edit_messages", 97 | "bot_can_delete_messages": "can_delete_messages", 98 | "bot_can_restrict_members": "can_restrict_members", 99 | "bot_can_promote_members": "can_promote_members", 100 | "bot_can_change_info": "can_change_info", 101 | "bot_can_invite_users": "can_invite_users", 102 | "bot_can_pin_messages": "can_pin_messages", 103 | } 104 | PAYLOAD_ARGUMENT_NAME = "bot_member" 105 | 106 | async def get_target_id(self, message: types.Message) -> int: 107 | return (await message.bot.me).id 108 | -------------------------------------------------------------------------------- /aiogram_bot/utils/chat_settings.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from aiogram import types 4 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 5 | from aiogram.utils.callback_data import CallbackData 6 | from aiogram.utils.markdown import hbold 7 | 8 | from aiogram_bot.misc import i18n 9 | from aiogram_bot.models.chat import Chat 10 | from aiogram_bot.models.user import User 11 | 12 | cb_chat_settings = CallbackData("chat", "id", "property", "value") 13 | cb_user_settings = CallbackData("user", "property", "value") 14 | 15 | _ = i18n.gettext 16 | 17 | FLAG_STATUS = ["❌", "✅"] 18 | 19 | PROPERTY_JOIN = "join" 20 | PROPERTY_BAN_CHANNELS = "ban_channels" 21 | PROPERTY_DEL_CHANNEL_MESSAGES = "del_channel_messages" 22 | PROPERTY_LANGUAGE = "language" 23 | 24 | 25 | def get_chat_settings_markup( 26 | telegram_chat: types.Chat, chat: Chat 27 | ) -> Tuple[str, InlineKeyboardMarkup]: 28 | return ( 29 | _("Settings for chat {chat_title}").format(chat_title=hbold(telegram_chat.title)), 30 | InlineKeyboardMarkup( 31 | inline_keyboard=[ 32 | [ 33 | InlineKeyboardButton( 34 | text=_("{status} Join filter").format( 35 | status=FLAG_STATUS[chat.join_filter] 36 | ), 37 | callback_data=cb_chat_settings.new( 38 | id=chat.id, property=PROPERTY_JOIN, value="switch" 39 | ), 40 | ) 41 | ], 42 | [ 43 | InlineKeyboardButton( 44 | text=_("{status} Ban channels").format( 45 | status=FLAG_STATUS[chat.ban_channels] 46 | ), 47 | callback_data=cb_chat_settings.new( 48 | id=chat.id, property=PROPERTY_BAN_CHANNELS, value="switch" 49 | ), 50 | ) 51 | ], 52 | [ 53 | InlineKeyboardButton( 54 | text=_("{status} Delete channel messages").format( 55 | status=FLAG_STATUS[chat.delete_channel_messages] 56 | ), 57 | callback_data=cb_chat_settings.new( 58 | id=chat.id, property=PROPERTY_DEL_CHANNEL_MESSAGES, value="switch" 59 | ), 60 | ) 61 | ], 62 | [ 63 | InlineKeyboardButton( 64 | text=_("{flag} Language").format( 65 | flag=i18n.AVAILABLE_LANGUAGES[i18n.ctx_locale.get()].flag 66 | ), 67 | callback_data=cb_chat_settings.new( 68 | id=chat.id, property=PROPERTY_LANGUAGE, value="change" 69 | ), 70 | ) 71 | ], 72 | [ 73 | InlineKeyboardButton( 74 | text=_("Done"), 75 | callback_data=cb_chat_settings.new( 76 | id=chat.id, property="done", value="true" 77 | ), 78 | ) 79 | ], 80 | ] 81 | ), 82 | ) 83 | 84 | 85 | def get_user_settings_markup(chat: Chat, user: User) -> Tuple[str, InlineKeyboardMarkup]: 86 | return ( 87 | _("Personal settings"), 88 | InlineKeyboardMarkup( 89 | inline_keyboard=[ 90 | [ 91 | InlineKeyboardButton( 92 | text=_("{status} Do not disturb").format( 93 | status=FLAG_STATUS[user.do_not_disturb] 94 | ), 95 | callback_data=cb_user_settings.new( 96 | property="do_not_disturb", value="switch" 97 | ), 98 | ) 99 | ], 100 | [ 101 | InlineKeyboardButton( 102 | text=_("{flag} Language").format( 103 | flag=i18n.AVAILABLE_LANGUAGES[chat.language].flag 104 | ), 105 | callback_data=cb_user_settings.new(property="language", value="change"), 106 | ) 107 | ], 108 | [ 109 | InlineKeyboardButton( 110 | text=_("Done"), 111 | callback_data=cb_user_settings.new(property="done", value="true"), 112 | ) 113 | ], 114 | ] 115 | ), 116 | ) 117 | -------------------------------------------------------------------------------- /aiogram_bot/handlers/new_chat_members.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import random 4 | from contextlib import suppress 5 | 6 | from aiogram import types 7 | from aiogram.dispatcher.handler import SkipHandler 8 | from aiogram.utils.callback_data import CallbackData 9 | from aiogram.utils.exceptions import BadRequest, MessageToDeleteNotFound 10 | from loguru import logger 11 | 12 | from aiogram_bot import config 13 | from aiogram_bot.misc import dp, i18n 14 | from aiogram_bot.models.chat import Chat 15 | from aiogram_bot.services.join_list import join_list 16 | 17 | _ = i18n.gettext 18 | 19 | cb_join_list = CallbackData("join_chat", "answer") 20 | 21 | 22 | @dp.message_handler(content_types=types.ContentTypes.NEW_CHAT_MEMBERS) 23 | async def new_chat_member(message: types.Message, chat: Chat): 24 | if not chat.join_filter: 25 | return False 26 | 27 | if message.date < datetime.datetime.now() - datetime.timedelta(minutes=30): 28 | logger.warning( 29 | "Join message {message} in chat {chat} is too old. Skip filtering. (Age: {age})", 30 | message=message.message_id, 31 | chat=chat.id, 32 | age=datetime.datetime.now() - message.date, 33 | ) 34 | return False 35 | 36 | if message.from_user not in message.new_chat_members: 37 | logger.opt(lazy=True).info( 38 | "User {user} add new members to chat {chat}: {new_members}", 39 | user=lambda: message.from_user.id, 40 | chat=lambda: message.chat.id, 41 | new_members=lambda: ", ".join([str(u.id) for u in message.new_chat_members]), 42 | ) 43 | # TODO: Validate is admin add new members 44 | else: 45 | logger.opt(lazy=True).info( 46 | "New chat members in chat {chat}: {new_members}", 47 | chat=lambda: message.chat.id, 48 | new_members=lambda: ", ".join([str(u.id) for u in message.new_chat_members]), 49 | ) 50 | 51 | users = {} 52 | for new_member in message.new_chat_members: 53 | try: 54 | chat_member = await message.chat.get_member(new_member.id) 55 | if chat_member.status == "restricted": 56 | return False # ignore user that's been restricted to avoid capcha abusing. 57 | else: 58 | await message.chat.restrict( 59 | new_member.id, permissions=types.ChatPermissions(can_send_messages=False) 60 | ) 61 | users[new_member.id] = new_member.get_mention() 62 | except BadRequest as e: 63 | logger.error( 64 | "Cannot restrict chat member {user} in chat {chat} with error: {error}", 65 | user=new_member.id, 66 | chat=chat.id, 67 | error=e, 68 | ) 69 | continue 70 | 71 | buttons = [ 72 | types.InlineKeyboardButton(_("I'm bot"), callback_data=cb_join_list.new(answer="bot")), 73 | types.InlineKeyboardButton(_("I'm pet"), callback_data=cb_join_list.new(answer="pet")), 74 | types.InlineKeyboardButton( 75 | _("I'm spammer"), callback_data=cb_join_list.new(answer="spammer") 76 | ), 77 | types.InlineKeyboardButton( 78 | _("I'm scammer"), callback_data=cb_join_list.new(answer="scammer") 79 | ), 80 | ] 81 | random.shuffle(buttons) 82 | buttons.insert( 83 | random.randint(1, len(buttons)), 84 | types.InlineKeyboardButton(_("I'm human"), callback_data=cb_join_list.new(answer="human")), 85 | ) 86 | msg = await message.reply( 87 | _( 88 | "{users}, Welcome to the chat. \n" 89 | "Please confirm that you are a human. " 90 | "User filter is enabled in this chat, so if you don't answer my question, " 91 | "I will be forced to remove you from this chat." 92 | ).format(users=", ".join(users.values())), 93 | reply_markup=types.InlineKeyboardMarkup(row_width=3).add(*buttons), 94 | ) 95 | await join_list.create_list( 96 | chat_id=message.chat.id, message_id=msg.message_id, users=users.keys() 97 | ) 98 | return True 99 | 100 | 101 | @dp.message_handler(content_types=types.ContentTypes.LEFT_CHAT_MEMBER) 102 | async def left_chat_member(message: types.Message): 103 | # TODO: Remove user from join-list when user was left from chat 104 | raise SkipHandler 105 | 106 | 107 | @dp.callback_query_handler(cb_join_list.filter()) 108 | async def cq_join_list(query: types.CallbackQuery, callback_data: dict): 109 | answer = callback_data["answer"] 110 | logger.info( 111 | "User {user} choose answer {answer} in join-list in chat {chat} and message {message}", 112 | user=query.from_user.id, 113 | chat=query.message.chat.id, 114 | answer=repr(answer), 115 | message=query.message.message_id, 116 | ) 117 | in_list = await join_list.pop_user_from_list( 118 | chat_id=query.message.chat.id, 119 | message_id=query.message.message_id, 120 | user_id=query.from_user.id, 121 | ) 122 | if not in_list: 123 | return await query.answer(_("This message is not for you!"), show_alert=True) 124 | 125 | if answer == "human": 126 | await query.answer(_("Good answer!")) 127 | await query.message.chat.restrict( 128 | query.from_user.id, 129 | permissions=types.ChatPermissions(can_send_messages=True), 130 | until_date=config.JOIN_NO_MEDIA_DURATION, 131 | ) 132 | else: 133 | await query.answer(_("Bad answer."), show_alert=True) 134 | await asyncio.sleep(2) 135 | await query.message.chat.unban(query.from_user.id) 136 | 137 | users_list = await join_list.check_list( 138 | chat_id=query.message.chat.id, message_id=query.message.message_id 139 | ) 140 | if not users_list: 141 | with suppress(MessageToDeleteNotFound): 142 | await query.message.delete() 143 | 144 | return True 145 | -------------------------------------------------------------------------------- /locales/bot.pot: -------------------------------------------------------------------------------- 1 | # Translations template for aiogram_bot. 2 | # Copyright (C) 2021 Illemius 3 | # This file is distributed under the same license as the aiogram_bot project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: aiogram_bot 0.1\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2021-12-09 23:51+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=utf-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Generated-By: Babel 2.9.1\n" 19 | 20 | #: aiogram_bot/handlers/base.py:18 21 | msgid "" 22 | "Hello, {user}.\n" 23 | "Send /help if you want to read my commands list and also you can change language by sending " 24 | "/settings command.\n" 25 | "My source code: {source_url}" 26 | msgstr "" 27 | 28 | #: aiogram_bot/handlers/base.py:36 29 | msgid "Here you can read the list of my commands:" 30 | msgstr "" 31 | 32 | #: aiogram_bot/handlers/base.py:37 33 | msgid "{command} - Start conversation with bot" 34 | msgstr "" 35 | 36 | #: aiogram_bot/handlers/base.py:38 37 | msgid "{command} - Get this message" 38 | msgstr "" 39 | 40 | #: aiogram_bot/handlers/base.py:39 41 | msgid "{command} - Chat or user settings" 42 | msgstr "" 43 | 44 | #: aiogram_bot/handlers/base.py:40 45 | msgid "{command} - My version" 46 | msgstr "" 47 | 48 | #: aiogram_bot/handlers/base.py:41 49 | msgid "{command} - Publish content to HasteBin" 50 | msgstr "" 51 | 52 | #: aiogram_bot/handlers/base.py:46 53 | msgid "In chats this commands list can be other" 54 | msgstr "" 55 | 56 | #: aiogram_bot/handlers/base.py:50 57 | msgid "Available only in groups:" 58 | msgstr "" 59 | 60 | #: aiogram_bot/handlers/base.py:51 61 | msgid "{command} - Report message to chat administrators" 62 | msgstr "" 63 | 64 | #: aiogram_bot/handlers/base.py:54 65 | msgid "{command} - Set RO mode for user" 66 | msgstr "" 67 | 68 | #: aiogram_bot/handlers/base.py:55 69 | msgid "{command} - Ban user" 70 | msgstr "" 71 | 72 | #: aiogram_bot/handlers/base.py:57 73 | msgid "In private chats this commands list can be other" 74 | msgstr "" 75 | 76 | #: aiogram_bot/handlers/base.py:66 77 | msgid "" 78 | "My Engine:\n" 79 | "{aiogram}" 80 | msgstr "" 81 | 82 | #: aiogram_bot/handlers/chat_settings.py:56 aiogram_bot/handlers/chat_settings.py:98 83 | #: aiogram_bot/handlers/chat_settings.py:147 84 | msgid "Invalid chat" 85 | msgstr "" 86 | 87 | #: aiogram_bot/handlers/chat_settings.py:70 88 | msgid "Choose chat language" 89 | msgstr "" 90 | 91 | #: aiogram_bot/handlers/chat_settings.py:102 aiogram_bot/handlers/chat_settings.py:151 92 | msgid "You cannot change settings of this chat!" 93 | msgstr "" 94 | 95 | #: aiogram_bot/handlers/chat_settings.py:114 96 | msgid "Language changed to {new_language}" 97 | msgstr "" 98 | 99 | #: aiogram_bot/handlers/chat_settings.py:124 100 | msgid "Do not disturb mode reconfigured" 101 | msgstr "" 102 | 103 | #: aiogram_bot/handlers/chat_settings.py:137 104 | msgid "Settings saved" 105 | msgstr "" 106 | 107 | #: aiogram_bot/handlers/chat_settings.py:167 108 | msgid "Invalid property" 109 | msgstr "" 110 | 111 | #: aiogram_bot/handlers/chat_settings.py:169 112 | msgid "Settings updated" 113 | msgstr "" 114 | 115 | #: aiogram_bot/handlers/hastebin.py:30 116 | msgid "Content to move is too short!" 117 | msgstr "" 118 | 119 | #: aiogram_bot/handlers/hastebin.py:36 120 | msgid "" 121 | "Message originally posted by {author} was moved to {url} service\n" 122 | "Content size: {size} bytes" 123 | msgstr "" 124 | 125 | #: aiogram_bot/handlers/new_chat_members.py:72 126 | msgid "I'm bot" 127 | msgstr "" 128 | 129 | #: aiogram_bot/handlers/new_chat_members.py:73 130 | msgid "I'm pet" 131 | msgstr "" 132 | 133 | #: aiogram_bot/handlers/new_chat_members.py:75 134 | msgid "I'm spammer" 135 | msgstr "" 136 | 137 | #: aiogram_bot/handlers/new_chat_members.py:78 138 | msgid "I'm scammer" 139 | msgstr "" 140 | 141 | #: aiogram_bot/handlers/new_chat_members.py:84 142 | msgid "I'm human" 143 | msgstr "" 144 | 145 | #: aiogram_bot/handlers/new_chat_members.py:87 146 | msgid "" 147 | "{users}, Welcome to the chat. \n" 148 | "Please confirm that you are a human. User filter is enabled in this chat, so if you don't answer" 149 | " my question, I will be forced to remove you from this chat." 150 | msgstr "" 151 | 152 | #: aiogram_bot/handlers/new_chat_members.py:123 153 | msgid "This message is not for you!" 154 | msgstr "" 155 | 156 | #: aiogram_bot/handlers/new_chat_members.py:126 157 | msgid "Good answer!" 158 | msgstr "" 159 | 160 | #: aiogram_bot/handlers/new_chat_members.py:133 161 | msgid "Bad answer." 162 | msgstr "" 163 | 164 | #: aiogram_bot/handlers/simple_admin.py:50 165 | msgid "" 166 | "Channel {channel} was permanently banned and the channel owner will no longer be able to send " 167 | "messages here on behalf of any of his channels." 168 | msgstr "" 169 | 170 | #: aiogram_bot/handlers/simple_admin.py:86 171 | msgid "Read-only activated for user {user}. Duration: {duration}" 172 | msgstr "" 173 | 174 | #: aiogram_bot/handlers/simple_admin.py:121 175 | msgid "User {user} banned for {duration}" 176 | msgstr "" 177 | 178 | #: aiogram_bot/handlers/simple_admin.py:150 179 | msgid "" 180 | "Please use this command is only in reply to message what do you want to report and this message " 181 | "will be reported to chat administrators." 182 | msgstr "" 183 | 184 | #: aiogram_bot/handlers/simple_admin.py:157 185 | msgid "[ALERT] User {user} is reported message in chat {chat}." 186 | msgstr "" 187 | 188 | #: aiogram_bot/handlers/simple_admin.py:179 189 | msgid "This message is reported to chat administrators." 190 | msgstr "" 191 | 192 | #: aiogram_bot/handlers/simple_admin.py:191 193 | msgid "User {user} leave this chat..." 194 | msgstr "" 195 | 196 | #: aiogram_bot/handlers/simple_admin.py:227 197 | msgid "Channel {channel} allowed in this chat" 198 | msgstr "" 199 | 200 | #: aiogram_bot/handlers/superuser.py:25 201 | msgid "Successful changed is_superuser to {is_superuser} for user {user}" 202 | msgstr "" 203 | 204 | #: aiogram_bot/handlers/superuser.py:30 205 | msgid "Failed to set is_superuser to {is_superuser} for user {user}" 206 | msgstr "" 207 | 208 | #: aiogram_bot/utils/chat_settings.py:29 209 | msgid "Settings for chat {chat_title}" 210 | msgstr "" 211 | 212 | #: aiogram_bot/utils/chat_settings.py:34 213 | msgid "{status} Join filter" 214 | msgstr "" 215 | 216 | #: aiogram_bot/utils/chat_settings.py:44 217 | msgid "{status} Ban channels" 218 | msgstr "" 219 | 220 | #: aiogram_bot/utils/chat_settings.py:54 221 | msgid "{status} Delete channel messages" 222 | msgstr "" 223 | 224 | #: aiogram_bot/utils/chat_settings.py:64 aiogram_bot/utils/chat_settings.py:102 225 | msgid "{flag} Language" 226 | msgstr "" 227 | 228 | #: aiogram_bot/utils/chat_settings.py:74 aiogram_bot/utils/chat_settings.py:110 229 | msgid "Done" 230 | msgstr "" 231 | 232 | #: aiogram_bot/utils/chat_settings.py:87 233 | msgid "Personal settings" 234 | msgstr "" 235 | 236 | #: aiogram_bot/utils/chat_settings.py:92 237 | msgid "{status} Do not disturb" 238 | msgstr "" 239 | 240 | -------------------------------------------------------------------------------- /locales/en/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # English translations for aiogram_bot. 2 | # Copyright (C) 2021 Illemius 3 | # This file is distributed under the same license as the aiogram_bot 4 | # project. 5 | # FIRST AUTHOR , 2021. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: aiogram_bot 0.1\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2021-12-09 23:51+0200\n" 12 | "PO-Revision-Date: 2019-10-22 00:12+0300\n" 13 | "Last-Translator: \n" 14 | "Language: en\n" 15 | "Language-Team: en \n" 16 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=utf-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Generated-By: Babel 2.9.1\n" 21 | 22 | #: aiogram_bot/handlers/base.py:18 23 | msgid "" 24 | "Hello, {user}.\n" 25 | "Send /help if you want to read my commands list and also you can change " 26 | "language by sending /settings command.\n" 27 | "My source code: {source_url}" 28 | msgstr "" 29 | 30 | #: aiogram_bot/handlers/base.py:36 31 | msgid "Here you can read the list of my commands:" 32 | msgstr "" 33 | 34 | #: aiogram_bot/handlers/base.py:37 35 | msgid "{command} - Start conversation with bot" 36 | msgstr "" 37 | 38 | #: aiogram_bot/handlers/base.py:38 39 | msgid "{command} - Get this message" 40 | msgstr "" 41 | 42 | #: aiogram_bot/handlers/base.py:39 43 | msgid "{command} - Chat or user settings" 44 | msgstr "" 45 | 46 | #: aiogram_bot/handlers/base.py:40 47 | msgid "{command} - My version" 48 | msgstr "" 49 | 50 | #: aiogram_bot/handlers/base.py:41 51 | msgid "{command} - Publish content to HasteBin" 52 | msgstr "" 53 | 54 | #: aiogram_bot/handlers/base.py:46 55 | msgid "In chats this commands list can be other" 56 | msgstr "" 57 | 58 | #: aiogram_bot/handlers/base.py:50 59 | msgid "Available only in groups:" 60 | msgstr "" 61 | 62 | #: aiogram_bot/handlers/base.py:51 63 | msgid "{command} - Report message to chat administrators" 64 | msgstr "" 65 | 66 | #: aiogram_bot/handlers/base.py:54 67 | msgid "{command} - Set RO mode for user" 68 | msgstr "" 69 | 70 | #: aiogram_bot/handlers/base.py:55 71 | msgid "{command} - Ban user" 72 | msgstr "" 73 | 74 | #: aiogram_bot/handlers/base.py:57 75 | msgid "In private chats this commands list can be other" 76 | msgstr "" 77 | 78 | #: aiogram_bot/handlers/base.py:66 79 | msgid "" 80 | "My Engine:\n" 81 | "{aiogram}" 82 | msgstr "" 83 | 84 | #: aiogram_bot/handlers/chat_settings.py:56 85 | #: aiogram_bot/handlers/chat_settings.py:98 86 | #: aiogram_bot/handlers/chat_settings.py:147 87 | msgid "Invalid chat" 88 | msgstr "" 89 | 90 | #: aiogram_bot/handlers/chat_settings.py:70 91 | msgid "Choose chat language" 92 | msgstr "" 93 | 94 | #: aiogram_bot/handlers/chat_settings.py:102 95 | #: aiogram_bot/handlers/chat_settings.py:151 96 | msgid "You cannot change settings of this chat!" 97 | msgstr "" 98 | 99 | #: aiogram_bot/handlers/chat_settings.py:114 100 | msgid "Language changed to {new_language}" 101 | msgstr "" 102 | 103 | #: aiogram_bot/handlers/chat_settings.py:124 104 | msgid "Do not disturb mode reconfigured" 105 | msgstr "" 106 | 107 | #: aiogram_bot/handlers/chat_settings.py:137 108 | msgid "Settings saved" 109 | msgstr "" 110 | 111 | #: aiogram_bot/handlers/chat_settings.py:167 112 | msgid "Invalid property" 113 | msgstr "" 114 | 115 | #: aiogram_bot/handlers/chat_settings.py:169 116 | msgid "Settings updated" 117 | msgstr "" 118 | 119 | #: aiogram_bot/handlers/hastebin.py:30 120 | msgid "Content to move is too short!" 121 | msgstr "" 122 | 123 | #: aiogram_bot/handlers/hastebin.py:36 124 | msgid "" 125 | "Message originally posted by {author} was moved to {url} service\n" 126 | "Content size: {size} bytes" 127 | msgstr "" 128 | 129 | #: aiogram_bot/handlers/new_chat_members.py:72 130 | msgid "I'm bot" 131 | msgstr "" 132 | 133 | #: aiogram_bot/handlers/new_chat_members.py:73 134 | msgid "I'm pet" 135 | msgstr "" 136 | 137 | #: aiogram_bot/handlers/new_chat_members.py:75 138 | msgid "I'm spammer" 139 | msgstr "" 140 | 141 | #: aiogram_bot/handlers/new_chat_members.py:78 142 | msgid "I'm scammer" 143 | msgstr "" 144 | 145 | #: aiogram_bot/handlers/new_chat_members.py:84 146 | msgid "I'm human" 147 | msgstr "" 148 | 149 | #: aiogram_bot/handlers/new_chat_members.py:87 150 | msgid "" 151 | "{users}, Welcome to the chat. \n" 152 | "Please confirm that you are a human. User filter is enabled in this chat," 153 | " so if you don't answer my question, I will be forced to remove you from " 154 | "this chat." 155 | msgstr "" 156 | 157 | #: aiogram_bot/handlers/new_chat_members.py:123 158 | msgid "This message is not for you!" 159 | msgstr "" 160 | 161 | #: aiogram_bot/handlers/new_chat_members.py:126 162 | msgid "Good answer!" 163 | msgstr "" 164 | 165 | #: aiogram_bot/handlers/new_chat_members.py:133 166 | msgid "Bad answer." 167 | msgstr "" 168 | 169 | #: aiogram_bot/handlers/simple_admin.py:50 170 | msgid "" 171 | "Channel {channel} was permanently banned and the channel owner will no " 172 | "longer be able to send messages here on behalf of any of his channels." 173 | msgstr "" 174 | 175 | #: aiogram_bot/handlers/simple_admin.py:86 176 | msgid "Read-only activated for user {user}. Duration: {duration}" 177 | msgstr "" 178 | 179 | #: aiogram_bot/handlers/simple_admin.py:121 180 | msgid "User {user} banned for {duration}" 181 | msgstr "" 182 | 183 | #: aiogram_bot/handlers/simple_admin.py:150 184 | msgid "" 185 | "Please use this command is only in reply to message what do you want to " 186 | "report and this message will be reported to chat administrators." 187 | msgstr "" 188 | 189 | #: aiogram_bot/handlers/simple_admin.py:157 190 | msgid "[ALERT] User {user} is reported message in chat {chat}." 191 | msgstr "🔥🔥🔥 User {user} is reported message in chat {chat}." 192 | 193 | #: aiogram_bot/handlers/simple_admin.py:179 194 | msgid "This message is reported to chat administrators." 195 | msgstr "" 196 | 197 | #: aiogram_bot/handlers/simple_admin.py:191 198 | msgid "User {user} leave this chat..." 199 | msgstr "" 200 | 201 | #: aiogram_bot/handlers/simple_admin.py:227 202 | msgid "Channel {channel} allowed in this chat" 203 | msgstr "" 204 | 205 | #: aiogram_bot/handlers/superuser.py:25 206 | msgid "Successful changed is_superuser to {is_superuser} for user {user}" 207 | msgstr "" 208 | 209 | #: aiogram_bot/handlers/superuser.py:30 210 | msgid "Failed to set is_superuser to {is_superuser} for user {user}" 211 | msgstr "" 212 | 213 | #: aiogram_bot/utils/chat_settings.py:29 214 | msgid "Settings for chat {chat_title}" 215 | msgstr "" 216 | 217 | #: aiogram_bot/utils/chat_settings.py:34 218 | msgid "{status} Join filter" 219 | msgstr "" 220 | 221 | #: aiogram_bot/utils/chat_settings.py:44 222 | msgid "{status} Ban channels" 223 | msgstr "" 224 | 225 | #: aiogram_bot/utils/chat_settings.py:54 226 | msgid "{status} Delete channel messages" 227 | msgstr "" 228 | 229 | #: aiogram_bot/utils/chat_settings.py:64 aiogram_bot/utils/chat_settings.py:102 230 | msgid "{flag} Language" 231 | msgstr "" 232 | 233 | #: aiogram_bot/utils/chat_settings.py:74 aiogram_bot/utils/chat_settings.py:110 234 | msgid "Done" 235 | msgstr "" 236 | 237 | #: aiogram_bot/utils/chat_settings.py:87 238 | msgid "Personal settings" 239 | msgstr "" 240 | 241 | #: aiogram_bot/utils/chat_settings.py:92 242 | msgid "{status} Do not disturb" 243 | msgstr "" 244 | 245 | #~ msgid "" 246 | #~ "Channel {channel} was permanently banned " 247 | #~ "and the channel owner will no " 248 | #~ "longer be able to send messages " 249 | #~ "here on behalf of any of his " 250 | #~ "channels" 251 | #~ msgstr "" 252 | 253 | #~ msgid "Join filter re-configured" 254 | #~ msgstr "" 255 | 256 | -------------------------------------------------------------------------------- /aiogram_bot/handlers/chat_settings.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from functools import partial 3 | 4 | from aiogram import types 5 | from aiogram.dispatcher.filters.filters import OrFilter 6 | from aiogram.utils.exceptions import MessageCantBeDeleted, MessageNotModified 7 | from loguru import logger 8 | 9 | from aiogram_bot.misc import bot, dp, i18n 10 | from aiogram_bot.models.chat import Chat 11 | from aiogram_bot.models.user import User 12 | from aiogram_bot.utils.chat_admin import get_chat_administrator 13 | from aiogram_bot.utils.chat_settings import ( 14 | cb_chat_settings, 15 | cb_user_settings, 16 | get_chat_settings_markup, 17 | get_user_settings_markup, 18 | PROPERTY_JOIN, 19 | PROPERTY_BAN_CHANNELS, 20 | PROPERTY_DEL_CHANNEL_MESSAGES, 21 | ) 22 | 23 | _ = i18n.gettext 24 | 25 | 26 | @dp.message_handler( 27 | chat_type=[types.ChatType.GROUP, types.ChatType.SUPERGROUP], 28 | commands=["settings"], 29 | user_can_change_info=True, 30 | ) 31 | @dp.message_handler(chat_type=types.ChatType.PRIVATE, commands=["settings"]) 32 | async def cmd_chat_settings(message: types.Message, chat: Chat, user: User): 33 | logger.info("User {user} wants to configure chat {chat}", user=user.id, chat=chat.id) 34 | with suppress(MessageCantBeDeleted): 35 | await message.delete() 36 | 37 | if message.chat.type in {types.ChatType.PRIVATE}: 38 | text, markup = get_user_settings_markup(chat, user) 39 | else: 40 | text, markup = get_chat_settings_markup(message.chat, chat) 41 | await bot.send_message(chat_id=user.id, text=text, reply_markup=markup) 42 | 43 | 44 | @dp.callback_query_handler(cb_chat_settings.filter(property="language", value="change")) 45 | @dp.callback_query_handler(cb_user_settings.filter(property="language", value="change")) 46 | async def cq_chat_settings_language(query: types.CallbackQuery, chat: Chat, callback_data: dict): 47 | logger.info( 48 | "User {user} wants to change language in chat {chat}", 49 | user=query.from_user.id, 50 | chat=chat.id, 51 | ) 52 | if callback_data["@"] == "chat": 53 | target_chat_id = int(callback_data["id"]) 54 | chat = await Chat.query.where(Chat.id == target_chat_id).gino.first() 55 | if not chat: 56 | return await query.answer(_("Invalid chat"), show_alert=True) 57 | callback_factory = partial(cb_chat_settings.new, id=chat.id) 58 | else: 59 | callback_factory = cb_user_settings.new 60 | 61 | markup = types.InlineKeyboardMarkup() 62 | 63 | for code, language in i18n.AVAILABLE_LANGUAGES.items(): 64 | markup.add( 65 | types.InlineKeyboardButton( 66 | language.label, callback_data=callback_factory(property="language", value=code) 67 | ) 68 | ) 69 | 70 | await query.answer(_("Choose chat language")) 71 | await query.message.edit_reply_markup(markup) 72 | 73 | 74 | @dp.callback_query_handler( 75 | OrFilter( 76 | *[ 77 | cb_settings.filter(property="language", value=code) 78 | for code in i18n.AVAILABLE_LANGUAGES 79 | for cb_settings in [cb_chat_settings, cb_user_settings] 80 | ] 81 | ) 82 | ) 83 | async def cq_chat_settings_choose_language( 84 | query: types.CallbackQuery, chat: Chat, user: User, callback_data: dict 85 | ): 86 | target_language = callback_data["value"] 87 | logger.info( 88 | "User {user} set language in chat {chat} to '{language}'", 89 | user=query.from_user.id, 90 | chat=chat.id, 91 | language=target_language, 92 | ) 93 | 94 | if callback_data["@"] == "chat": 95 | target_chat_id = int(callback_data["id"]) 96 | chat = await Chat.query.where(Chat.id == target_chat_id).gino.first() 97 | if not chat: 98 | return await query.answer(_("Invalid chat"), show_alert=True) 99 | 100 | member = await get_chat_administrator(chat.id, query.message.from_user.id) 101 | if not member or not member.is_chat_admin(): 102 | await query.answer(_("You cannot change settings of this chat!"), show_alert=True) 103 | return await query.message.delete() 104 | else: 105 | target_chat_id = None 106 | 107 | i18n.ctx_locale.set(target_language) 108 | if callback_data["@"] == "chat": 109 | text, markup = get_chat_settings_markup(await bot.get_chat(target_chat_id), chat) 110 | else: 111 | text, markup = get_user_settings_markup(chat, user) 112 | await chat.update(language=target_language).apply() 113 | await query.answer( 114 | _("Language changed to {new_language}").format( 115 | new_language=i18n.AVAILABLE_LANGUAGES[target_language].title 116 | ) 117 | ) 118 | await query.message.edit_text(text, reply_markup=markup) 119 | 120 | 121 | @dp.callback_query_handler(cb_user_settings.filter(property="do_not_disturb", value="switch")) 122 | async def cq_user_settings_do_not_disturb(query: types.CallbackQuery, user: User, chat: Chat): 123 | logger.info("User {user} switch DND mode", user=query.from_user.id) 124 | await query.answer(_("Do not disturb mode reconfigured")) 125 | await user.update(do_not_disturb=~User.do_not_disturb).apply() 126 | text, markup = get_user_settings_markup(chat, user) 127 | with suppress(MessageNotModified): 128 | await query.message.edit_text(text, reply_markup=markup) 129 | 130 | 131 | @dp.callback_query_handler(cb_chat_settings.filter(property="done", value="true")) 132 | @dp.callback_query_handler(cb_user_settings.filter(property="done", value="true")) 133 | async def cq_chat_settings_done(query: types.CallbackQuery, chat: Chat): 134 | logger.info( 135 | "User {user} close settings menu for chat {chat}", user=query.from_user.id, chat=chat.id 136 | ) 137 | await query.answer(_("Settings saved"), show_alert=True) 138 | await query.message.delete() 139 | 140 | 141 | @dp.callback_query_handler(cb_chat_settings.filter(value="switch")) 142 | async def cq_chat_settings_join_filter_switch(query: types.CallbackQuery, callback_data: dict): 143 | target_chat_id = int(callback_data["id"]) 144 | property_name = callback_data["property"] 145 | chat = await Chat.query.where(Chat.id == target_chat_id).gino.first() 146 | if not chat: 147 | return await query.answer(_("Invalid chat"), show_alert=True) 148 | 149 | member = await get_chat_administrator(chat.id, query.message.from_user.id) 150 | if not member or not member.is_chat_admin(): 151 | await query.answer(_("You cannot change settings of this chat!"), show_alert=True) 152 | return await query.message.delete() 153 | 154 | logger.info( 155 | "User {user} switch property {property} in chat {chat}", 156 | user=query.from_user.id, 157 | property=property_name, 158 | chat=chat.id, 159 | ) 160 | if property_name == PROPERTY_JOIN: 161 | await chat.update(join_filter=~Chat.join_filter).apply() 162 | elif property_name == PROPERTY_BAN_CHANNELS: 163 | await chat.update(ban_channels=~Chat.ban_channels).apply() 164 | elif property_name == PROPERTY_DEL_CHANNEL_MESSAGES: 165 | await chat.update(delete_channel_messages=~Chat.delete_channel_messages).apply() 166 | else: 167 | await query.answer(_("Invalid property"), cache_time=3) 168 | return False 169 | await query.answer(_("Settings updated"), cache_time=1) 170 | 171 | text, markup = get_chat_settings_markup(await bot.get_chat(chat.id), chat) 172 | with suppress(MessageNotModified): 173 | await query.message.edit_text(text, reply_markup=markup) 174 | -------------------------------------------------------------------------------- /aiogram_bot/handlers/simple_admin.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import suppress 3 | from typing import List, Optional 4 | 5 | from aiogram import types 6 | from aiogram.types import ContentType 7 | from aiogram.utils import exceptions 8 | from aiogram.utils.exceptions import BadRequest, Unauthorized 9 | from aiogram.utils.markdown import hlink, quote_html 10 | from babel.dates import format_timedelta 11 | from loguru import logger 12 | from magic_filter import F 13 | from sqlalchemy.dialects.postgresql import insert 14 | 15 | from aiogram_bot.misc import bot, dp, i18n 16 | from aiogram_bot.models.chat import Chat, ChatAllowedChannels 17 | from aiogram_bot.models.user import User 18 | from aiogram_bot.utils.timedelta import parse_timedelta_from_message 19 | 20 | _ = i18n.gettext 21 | 22 | 23 | @dp.message_handler( 24 | F.ilter(F.reply_to_message.sender_chat), 25 | commands=["ro", "ban"], 26 | commands_prefix="!", 27 | user_can_restrict_members=True, 28 | bot_can_restrict_members=True, 29 | # chat_property="restrict_commands", 30 | ) 31 | async def command_ban_sender_chat(message: types.Message, target: Optional[types.Chat] = None): 32 | if target is None: 33 | target = message.reply_to_message.sender_chat 34 | to_message = message.reply_to_message 35 | else: 36 | to_message = message 37 | try: # Apply restriction 38 | await message.chat.ban_sender_chat(sender_chat_id=target.id) 39 | await ChatAllowedChannels.delete.where( 40 | (ChatAllowedChannels.chat_id == message.chat.id) 41 | & (ChatAllowedChannels.channel_id == target.id) 42 | ).gino.scalar() 43 | logger.info( 44 | "Chat {chat} restricted by {admin}", 45 | chat=target.id, 46 | admin=message.from_user.id, 47 | ) 48 | except exceptions.BadRequest as e: 49 | logger.error("Failed to restrict chat member: {error!r}", error=e) 50 | return False 51 | await to_message.answer( 52 | _( 53 | "Channel {channel} was permanently banned " 54 | "and the channel owner will no longer be able to send messages here " 55 | "on behalf of any of his channels." 56 | ).format(channel=target.mention) 57 | ) 58 | return True 59 | 60 | 61 | @dp.message_handler( 62 | F.ilter(F.reply_to_message), 63 | commands=["ro"], 64 | commands_prefix="!", 65 | user_can_restrict_members=True, 66 | bot_can_restrict_members=True, 67 | chat_property="restrict_commands", 68 | ) 69 | async def cmd_ro(message: types.Message, chat: Chat): 70 | duration = await parse_timedelta_from_message(message) 71 | if not duration: 72 | return 73 | 74 | try: # Apply restriction 75 | await message.chat.restrict( 76 | message.reply_to_message.from_user.id, can_send_messages=False, until_date=duration 77 | ) 78 | logger.info( 79 | "User {user} restricted by {admin} for {duration}", 80 | user=message.reply_to_message.from_user.id, 81 | admin=message.from_user.id, 82 | duration=duration, 83 | ) 84 | except exceptions.BadRequest as e: 85 | logger.error("Failed to restrict chat member: {error!r}", error=e) 86 | return False 87 | 88 | await message.reply_to_message.answer( 89 | _("Read-only activated for user {user}. Duration: {duration}").format( 90 | user=message.reply_to_message.from_user.get_mention(), 91 | duration=format_timedelta( 92 | duration, locale=chat.language, granularity="seconds", format="short" 93 | ), 94 | ) 95 | ) 96 | return True 97 | 98 | 99 | @dp.message_handler( 100 | F.ilter(F.reply_to_message), 101 | commands=["ban"], 102 | commands_prefix="!", 103 | user_can_restrict_members=True, 104 | bot_can_restrict_members=True, 105 | chat_property="restrict_commands", 106 | ) 107 | async def cmd_ban(message: types.Message, chat: Chat): 108 | duration = await parse_timedelta_from_message(message) 109 | if not duration: 110 | return 111 | 112 | try: # Apply restriction 113 | await message.chat.kick(message.reply_to_message.from_user.id, until_date=duration) 114 | logger.info( 115 | "User {user} kicked by {admin} for {duration}", 116 | user=message.reply_to_message.from_user.id, 117 | admin=message.from_user.id, 118 | duration=duration, 119 | ) 120 | except exceptions.BadRequest as e: 121 | logger.error("Failed to kick chat member: {error!r}", error=e) 122 | return False 123 | 124 | await message.reply_to_message.answer( 125 | _("User {user} banned for {duration}").format( 126 | user=message.reply_to_message.from_user.get_mention(), 127 | duration=format_timedelta( 128 | duration, locale=chat.language, granularity="seconds", format="short" 129 | ), 130 | ) 131 | ) 132 | return True 133 | 134 | 135 | @dp.message_handler( 136 | chat_type=[types.ChatType.GROUP, types.ChatType.SUPERGROUP], 137 | text_contains="@admin", 138 | state="*", 139 | chat_property="report_to_admins", 140 | ) 141 | @dp.message_handler( 142 | chat_type=[types.ChatType.GROUP, types.ChatType.SUPERGROUP], 143 | commands=["report"], 144 | commands_prefix="!/", 145 | state="*", 146 | chat_property="report_to_admins", 147 | ) 148 | async def text_report_admins(message: types.Message): 149 | logger.info( 150 | "User {user} report message {message} in chat {chat} from user {from_user}", 151 | user=message.from_user.id, 152 | message=message.message_id, 153 | chat=message.chat.id, 154 | from_user=message.reply_to_message.from_user.id, 155 | ) 156 | if not message.reply_to_message or message.reply_to_message.content_type in {ContentType.FORUM_TOPIC_CREATED, 157 | ContentType.FORUM_TOPIC_REOPENED, 158 | ContentType.FORUM_TOPIC_CLOSED}: 159 | return await message.reply( 160 | _( 161 | "Please use this command is only in reply to message what do you want to report " 162 | "and this message will be reported to chat administrators." 163 | ) 164 | ) 165 | 166 | admins: List[types.ChatMember] = await message.chat.get_administrators() 167 | if message.chat.username: 168 | url = f"https://t.me/{message.chat.username}/{message.reply_to_message.message_id}" 169 | if message.is_topic_message: 170 | url += f'?topic={message.reply_to_message.message_thread_id}' 171 | chat_label = hlink(message.chat.title, url) 172 | else: 173 | chat_label = quote_html(repr(message.chat.title)) 174 | 175 | text = _("[ALERT] User {user} is reported message in chat {chat}.").format( 176 | user=message.from_user.get_mention(), 177 | chat=chat_label, 178 | ) 179 | 180 | admin_ids = [ 181 | admin.user.id for admin in admins if admin.is_chat_admin() and not admin.user.is_bot 182 | ] 183 | if admin_ids: 184 | for admin in await User.query.where( 185 | User.id.in_(admin_ids) & (User.do_not_disturb == False) # NOQA 186 | ).gino.all(): # NOQA 187 | with suppress(Unauthorized): 188 | await bot.send_message(admin.id, text) 189 | logger.info("Send alert message to admin {admin}", admin=admin.id) 190 | await asyncio.sleep(0.3) 191 | 192 | await message.reply_to_message.reply(_("This message is reported to chat administrators.")) 193 | 194 | 195 | @dp.message_handler( 196 | chat_type=[types.ChatType.GROUP, types.ChatType.SUPERGROUP], 197 | commands=["do_not_click", "leave"], 198 | bot_can_restrict_members=True, 199 | ) 200 | async def cmd_leave(message: types.Message): 201 | try: 202 | await message.chat.unban(user_id=message.from_user.id) 203 | msg = await message.answer( 204 | _("User {user} leave this chat...").format(user=message.from_user.get_mention()) 205 | ) 206 | except BadRequest: 207 | msg = None 208 | 209 | await asyncio.sleep(10) 210 | 211 | with suppress(BadRequest): 212 | await message.delete() 213 | if msg: 214 | await msg.delete() 215 | 216 | 217 | @dp.message_handler( 218 | F.ilter(F.reply_to_message.sender_chat), 219 | commands=["approve_channel"], 220 | chat_type=[types.ChatType.GROUP, types.ChatType.SUPERGROUP], 221 | user_can_promote_members=True, 222 | bot_can_restrict_members=True, 223 | ) 224 | async def command_allow_channel(message: types.Message): 225 | target = message.reply_to_message.sender_chat 226 | stmt = ( 227 | insert(ChatAllowedChannels) 228 | .values( 229 | chat_id=message.chat.id, 230 | channel_id=target.id, 231 | added_by=message.from_user.id, 232 | ) 233 | .on_conflict_do_nothing( 234 | index_elements=[ChatAllowedChannels.chat_id, ChatAllowedChannels.channel_id], 235 | ) 236 | ) 237 | await stmt.gino.scalar() 238 | await message.chat.unban_sender_chat(target.id) 239 | await message.answer( 240 | _("Channel {channel} allowed in this chat").format(channel=target.mention) 241 | ) 242 | -------------------------------------------------------------------------------- /locales/uk/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # Ukrainian translations for aiogram_bot. 2 | # Copyright (C) 2021 Illemius 3 | # This file is distributed under the same license as the aiogram_bot 4 | # project. 5 | # FIRST AUTHOR , 2021. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: aiogram_bot 0.1\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2021-12-09 23:51+0200\n" 12 | "PO-Revision-Date: 2021-12-09 23:53+0200\n" 13 | "Last-Translator: \n" 14 | "Language: uk\n" 15 | "Language-Team: uk \n" 16 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 " 17 | "&& (n%100<10 || n%100>=20) ? 1 : 2);\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=utf-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Generated-By: Babel 2.9.1\n" 22 | "X-Generator: Poedit 3.0\n" 23 | 24 | #: aiogram_bot/handlers/base.py:18 25 | msgid "" 26 | "Hello, {user}.\n" 27 | "Send /help if you want to read my commands list and also you can change " 28 | "language by sending /settings command.\n" 29 | "My source code: {source_url}" 30 | msgstr "" 31 | "Привіт, {user}.\n" 32 | "Надішли /help якщо хочеш побачити список моїх команд, або /settings якщо хочеш " 33 | "змінити мову.\n" 34 | "\n" 35 | "Мій код: {source_url}" 36 | 37 | #: aiogram_bot/handlers/base.py:36 38 | msgid "Here you can read the list of my commands:" 39 | msgstr "Тут описаний список моїх команд:" 40 | 41 | #: aiogram_bot/handlers/base.py:37 42 | msgid "{command} - Start conversation with bot" 43 | msgstr "{command} - Почати діалог з ботом" 44 | 45 | #: aiogram_bot/handlers/base.py:38 46 | msgid "{command} - Get this message" 47 | msgstr "{command} - Отримати дане повідомлення" 48 | 49 | #: aiogram_bot/handlers/base.py:39 50 | msgid "{command} - Chat or user settings" 51 | msgstr "{command} - Налаштування чату або профілю" 52 | 53 | #: aiogram_bot/handlers/base.py:40 54 | msgid "{command} - My version" 55 | msgstr "{command} - Моя версія" 56 | 57 | #: aiogram_bot/handlers/base.py:41 58 | msgid "{command} - Publish content to HasteBin" 59 | msgstr "{command} - Опублікувати на HasteBin" 60 | 61 | #: aiogram_bot/handlers/base.py:46 62 | msgid "In chats this commands list can be other" 63 | msgstr "В чатах список команд може відрізнятись" 64 | 65 | #: aiogram_bot/handlers/base.py:50 66 | msgid "Available only in groups:" 67 | msgstr "Доступно тільки в чатах:" 68 | 69 | #: aiogram_bot/handlers/base.py:51 70 | msgid "{command} - Report message to chat administrators" 71 | msgstr "{command} - Поскаржитись на повідомлення" 72 | 73 | #: aiogram_bot/handlers/base.py:54 74 | msgid "{command} - Set RO mode for user" 75 | msgstr "{command} - Заборонити користувачу писати в чат" 76 | 77 | #: aiogram_bot/handlers/base.py:55 78 | msgid "{command} - Ban user" 79 | msgstr "{command} - Заблокувати користувача в чаті" 80 | 81 | #: aiogram_bot/handlers/base.py:57 82 | msgid "In private chats this commands list can be other" 83 | msgstr "В приватних повідомленях цей список може відрізнятись" 84 | 85 | #: aiogram_bot/handlers/base.py:66 86 | msgid "" 87 | "My Engine:\n" 88 | "{aiogram}" 89 | msgstr "" 90 | "Моя версія:\n" 91 | "{aiogram}" 92 | 93 | #: aiogram_bot/handlers/chat_settings.py:56 94 | #: aiogram_bot/handlers/chat_settings.py:98 95 | #: aiogram_bot/handlers/chat_settings.py:147 96 | msgid "Invalid chat" 97 | msgstr "Невірний чат" 98 | 99 | #: aiogram_bot/handlers/chat_settings.py:70 100 | msgid "Choose chat language" 101 | msgstr "Обери мову для чату" 102 | 103 | #: aiogram_bot/handlers/chat_settings.py:102 104 | #: aiogram_bot/handlers/chat_settings.py:151 105 | msgid "You cannot change settings of this chat!" 106 | msgstr "Ти не можеш міняти налаштування цього чату!" 107 | 108 | #: aiogram_bot/handlers/chat_settings.py:114 109 | msgid "Language changed to {new_language}" 110 | msgstr "Мову змінено на {new_language}" 111 | 112 | #: aiogram_bot/handlers/chat_settings.py:124 113 | msgid "Do not disturb mode reconfigured" 114 | msgstr "Налаштування режиму \"Не турбувати\" змінено" 115 | 116 | #: aiogram_bot/handlers/chat_settings.py:137 117 | msgid "Settings saved" 118 | msgstr "Налаштування збережено" 119 | 120 | #: aiogram_bot/handlers/chat_settings.py:167 121 | msgid "Invalid property" 122 | msgstr "Невірний параметр" 123 | 124 | #: aiogram_bot/handlers/chat_settings.py:169 125 | #, fuzzy 126 | msgid "Settings updated" 127 | msgstr "Налаштування збережено" 128 | 129 | #: aiogram_bot/handlers/hastebin.py:30 130 | msgid "Content to move is too short!" 131 | msgstr "Повідомлення занадто коротке для перенесення!" 132 | 133 | #: aiogram_bot/handlers/hastebin.py:36 134 | msgid "" 135 | "Message originally posted by {author} was moved to {url} service\n" 136 | "Content size: {size} bytes" 137 | msgstr "" 138 | "Повідомлення від {author} переміщено на {url}\n" 139 | "Розмір: {size} байт" 140 | 141 | #: aiogram_bot/handlers/new_chat_members.py:72 142 | msgid "I'm bot" 143 | msgstr "Я кремлебот" 144 | 145 | #: aiogram_bot/handlers/new_chat_members.py:73 146 | msgid "I'm pet" 147 | msgstr "Я свинособака" 148 | 149 | #: aiogram_bot/handlers/new_chat_members.py:75 150 | msgid "I'm spammer" 151 | msgstr "Я полуниця" 152 | 153 | #: aiogram_bot/handlers/new_chat_members.py:78 154 | msgid "I'm scammer" 155 | msgstr "Я русский корабль" 156 | 157 | #: aiogram_bot/handlers/new_chat_members.py:84 158 | msgid "I'm human" 159 | msgstr "Я людина" 160 | 161 | #: aiogram_bot/handlers/new_chat_members.py:87 162 | msgid "" 163 | "{users}, Welcome to the chat. \n" 164 | "Please confirm that you are a human. User filter is enabled in this chat, so if " 165 | "you don't answer my question, I will be forced to remove you from this chat." 166 | msgstr "" 167 | "Вітаю, {users}.\n" 168 | "Будь ласка, підтверди що ти людина. В цьому чаті ввімкнуто фільтр нових " 169 | "користувачів, отже, якщо ти не дасиш відповідь на моє запитання, я буду " 170 | "змушений вигнати тебе з чату." 171 | 172 | #: aiogram_bot/handlers/new_chat_members.py:123 173 | msgid "This message is not for you!" 174 | msgstr "Це повідомлення не для тебе!" 175 | 176 | #: aiogram_bot/handlers/new_chat_members.py:126 177 | msgid "Good answer!" 178 | msgstr "Гарна відповідь!" 179 | 180 | #: aiogram_bot/handlers/new_chat_members.py:133 181 | msgid "Bad answer." 182 | msgstr "Погана відповідь." 183 | 184 | #: aiogram_bot/handlers/simple_admin.py:50 185 | msgid "" 186 | "Channel {channel} was permanently banned and the channel owner will no longer " 187 | "be able to send messages here on behalf of any of his channels." 188 | msgstr "" 189 | "Канал {channel} заблоковано, його власник більше не зможе писати від імені " 190 | "своїх каналів в цьому чаті." 191 | 192 | #: aiogram_bot/handlers/simple_admin.py:86 193 | msgid "Read-only activated for user {user}. Duration: {duration}" 194 | msgstr "Користувачу {user} заборонено писати в чаті на {duration}" 195 | 196 | #: aiogram_bot/handlers/simple_admin.py:121 197 | msgid "User {user} banned for {duration}" 198 | msgstr "Користувача {user} заблоковано на {duration}" 199 | 200 | #: aiogram_bot/handlers/simple_admin.py:150 201 | msgid "" 202 | "Please use this command is only in reply to message what do you want to report " 203 | "and this message will be reported to chat administrators." 204 | msgstr "" 205 | "Будь ласка, використовуй дану команду тільки у відповідь на інше повідомлення і " 206 | "я повідомлю про нього адміністрації чату." 207 | 208 | #: aiogram_bot/handlers/simple_admin.py:157 209 | msgid "[ALERT] User {user} is reported message in chat {chat}." 210 | msgstr "🔥 Користувач {user} кличе адміністрацію в чаті {chat}." 211 | 212 | #: aiogram_bot/handlers/simple_admin.py:179 213 | msgid "This message is reported to chat administrators." 214 | msgstr "Я повідомим адміністрацію чату про це повідомлення." 215 | 216 | #: aiogram_bot/handlers/simple_admin.py:191 217 | msgid "User {user} leave this chat..." 218 | msgstr "Користувач {user} покинув цей чат..." 219 | 220 | #: aiogram_bot/handlers/simple_admin.py:227 221 | msgid "Channel {channel} allowed in this chat" 222 | msgstr "Канал {channel} заблоковано у цьому чаті" 223 | 224 | #: aiogram_bot/handlers/superuser.py:25 225 | msgid "Successful changed is_superuser to {is_superuser} for user {user}" 226 | msgstr "Успішно встановлено is_superuser на {is_superuser} для користувача {user}" 227 | 228 | #: aiogram_bot/handlers/superuser.py:30 229 | msgid "Failed to set is_superuser to {is_superuser} for user {user}" 230 | msgstr "Не вдалось встановити is_superuser на {is_superuser} користувачу {user}" 231 | 232 | #: aiogram_bot/utils/chat_settings.py:29 233 | msgid "Settings for chat {chat_title}" 234 | msgstr "Налаштування чату {chat_title}" 235 | 236 | #: aiogram_bot/utils/chat_settings.py:34 237 | msgid "{status} Join filter" 238 | msgstr "{status} Фільтр входу" 239 | 240 | #: aiogram_bot/utils/chat_settings.py:44 241 | msgid "{status} Ban channels" 242 | msgstr "{status} Блокувати канали" 243 | 244 | #: aiogram_bot/utils/chat_settings.py:54 245 | msgid "{status} Delete channel messages" 246 | msgstr "{status} Видаляти повідомлення каналів" 247 | 248 | #: aiogram_bot/utils/chat_settings.py:64 aiogram_bot/utils/chat_settings.py:102 249 | msgid "{flag} Language" 250 | msgstr "{flag} Мова" 251 | 252 | #: aiogram_bot/utils/chat_settings.py:74 aiogram_bot/utils/chat_settings.py:110 253 | msgid "Done" 254 | msgstr "Завершити" 255 | 256 | #: aiogram_bot/utils/chat_settings.py:87 257 | msgid "Personal settings" 258 | msgstr "Персональні налаштування" 259 | 260 | #: aiogram_bot/utils/chat_settings.py:92 261 | msgid "{status} Do not disturb" 262 | msgstr "{status} Не турбувати" 263 | 264 | #~ msgid "Join filter re-configured" 265 | #~ msgstr "Фільтр входу користувачів переналаштовано" 266 | -------------------------------------------------------------------------------- /locales/ru/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # Russian translations for aiogram_bot. 2 | # Copyright (C) 2021 Illemius 3 | # This file is distributed under the same license as the aiogram_bot 4 | # project. 5 | # FIRST AUTHOR , 2021. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: aiogram_bot 0.1\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2021-12-09 23:51+0200\n" 12 | "PO-Revision-Date: 2021-12-09 23:53+0200\n" 13 | "Last-Translator: \n" 14 | "Language: ru\n" 15 | "Language-Team: ru \n" 16 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 17 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=utf-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Generated-By: Babel 2.9.1\n" 22 | "X-Generator: Poedit 3.0\n" 23 | 24 | #: aiogram_bot/handlers/base.py:18 25 | msgid "" 26 | "Hello, {user}.\n" 27 | "Send /help if you want to read my commands list and also you can change " 28 | "language by sending /settings command.\n" 29 | "My source code: {source_url}" 30 | msgstr "" 31 | "Привет, {user}.\n" 32 | "Отправь /help если хочешь прочитать список моих команд. Так же, если хочешь " 33 | "изменить язык - отправь команду /settings.\n" 34 | "\n" 35 | "Мои исходники: {source_url}" 36 | 37 | #: aiogram_bot/handlers/base.py:36 38 | msgid "Here you can read the list of my commands:" 39 | msgstr "Вот список моих команд:" 40 | 41 | #: aiogram_bot/handlers/base.py:37 42 | msgid "{command} - Start conversation with bot" 43 | msgstr "{command} - Начать диалог с ботом" 44 | 45 | #: aiogram_bot/handlers/base.py:38 46 | msgid "{command} - Get this message" 47 | msgstr "{command} - Получить данное сообщение" 48 | 49 | #: aiogram_bot/handlers/base.py:39 50 | msgid "{command} - Chat or user settings" 51 | msgstr "{command} - Настройки чата или персональные" 52 | 53 | #: aiogram_bot/handlers/base.py:40 54 | msgid "{command} - My version" 55 | msgstr "{command} - Версия" 56 | 57 | #: aiogram_bot/handlers/base.py:41 58 | msgid "{command} - Publish content to HasteBin" 59 | msgstr "{command} - Опубликовать на HasteBin" 60 | 61 | #: aiogram_bot/handlers/base.py:46 62 | msgid "In chats this commands list can be other" 63 | msgstr "В чатах этот список команд может отличатся" 64 | 65 | #: aiogram_bot/handlers/base.py:50 66 | msgid "Available only in groups:" 67 | msgstr "Следующие команды доступны только в чатах:" 68 | 69 | #: aiogram_bot/handlers/base.py:51 70 | msgid "{command} - Report message to chat administrators" 71 | msgstr "{command} - Пожаловаться на сообщение и позвать администрацию чата" 72 | 73 | #: aiogram_bot/handlers/base.py:54 74 | msgid "{command} - Set RO mode for user" 75 | msgstr "{command} - Отправить пользователя в RO" 76 | 77 | #: aiogram_bot/handlers/base.py:55 78 | msgid "{command} - Ban user" 79 | msgstr "{command} - Забанить пользователя" 80 | 81 | #: aiogram_bot/handlers/base.py:57 82 | msgid "In private chats this commands list can be other" 83 | msgstr "В приватных чатах этот список может отличатся" 84 | 85 | #: aiogram_bot/handlers/base.py:66 86 | msgid "" 87 | "My Engine:\n" 88 | "{aiogram}" 89 | msgstr "" 90 | "Моя версия:\n" 91 | "{aiogram}" 92 | 93 | #: aiogram_bot/handlers/chat_settings.py:56 94 | #: aiogram_bot/handlers/chat_settings.py:98 95 | #: aiogram_bot/handlers/chat_settings.py:147 96 | msgid "Invalid chat" 97 | msgstr "Неверный чат" 98 | 99 | #: aiogram_bot/handlers/chat_settings.py:70 100 | msgid "Choose chat language" 101 | msgstr "Выбери язык для чата" 102 | 103 | #: aiogram_bot/handlers/chat_settings.py:102 104 | #: aiogram_bot/handlers/chat_settings.py:151 105 | msgid "You cannot change settings of this chat!" 106 | msgstr "Ты не можешь изменять настройки этого чата!" 107 | 108 | #: aiogram_bot/handlers/chat_settings.py:114 109 | msgid "Language changed to {new_language}" 110 | msgstr "Язык изменен на {new_language}" 111 | 112 | #: aiogram_bot/handlers/chat_settings.py:124 113 | msgid "Do not disturb mode reconfigured" 114 | msgstr "Настройки режима \"Не беспокоить\" изменены" 115 | 116 | #: aiogram_bot/handlers/chat_settings.py:137 117 | msgid "Settings saved" 118 | msgstr "Настройки сохранены" 119 | 120 | #: aiogram_bot/handlers/chat_settings.py:167 121 | msgid "Invalid property" 122 | msgstr "Неверный параметр" 123 | 124 | #: aiogram_bot/handlers/chat_settings.py:169 125 | #, fuzzy 126 | msgid "Settings updated" 127 | msgstr "Настройки сохранены" 128 | 129 | #: aiogram_bot/handlers/hastebin.py:30 130 | msgid "Content to move is too short!" 131 | msgstr "Сообщение для переноса слишком короткое!" 132 | 133 | #: aiogram_bot/handlers/hastebin.py:36 134 | msgid "" 135 | "Message originally posted by {author} was moved to {url} service\n" 136 | "Content size: {size} bytes" 137 | msgstr "" 138 | "Сообщение от {author} перемещено на {url}\n" 139 | "Размер: {size} байт" 140 | 141 | #: aiogram_bot/handlers/new_chat_members.py:72 142 | msgid "I'm bot" 143 | msgstr "Я кремлебот" 144 | 145 | #: aiogram_bot/handlers/new_chat_members.py:73 146 | msgid "I'm pet" 147 | msgstr "Я животное" 148 | 149 | #: aiogram_bot/handlers/new_chat_members.py:75 150 | msgid "I'm spammer" 151 | msgstr "Я палуница" 152 | 153 | #: aiogram_bot/handlers/new_chat_members.py:78 154 | msgid "I'm scammer" 155 | msgstr "Я русский корабль" 156 | 157 | #: aiogram_bot/handlers/new_chat_members.py:84 158 | msgid "I'm human" 159 | msgstr "Я человек" 160 | 161 | #: aiogram_bot/handlers/new_chat_members.py:87 162 | msgid "" 163 | "{users}, Welcome to the chat. \n" 164 | "Please confirm that you are a human. User filter is enabled in this chat, so " 165 | "if you don't answer my question, I will be forced to remove you from this " 166 | "chat." 167 | msgstr "" 168 | "Привет, {users}!\n" 169 | "Пожалуйста подтверди, что ты человек. В этом чате включен фильтр новых " 170 | "пользователей, поэтому, если ты не ответишь на мой вопрос, я буду вынужден " 171 | "прогнать тебя из чата." 172 | 173 | #: aiogram_bot/handlers/new_chat_members.py:123 174 | msgid "This message is not for you!" 175 | msgstr "Это сообщение не для тебя!" 176 | 177 | #: aiogram_bot/handlers/new_chat_members.py:126 178 | msgid "Good answer!" 179 | msgstr "Хороший ответ!" 180 | 181 | #: aiogram_bot/handlers/new_chat_members.py:133 182 | msgid "Bad answer." 183 | msgstr "Плохой ответ." 184 | 185 | #: aiogram_bot/handlers/simple_admin.py:50 186 | msgid "" 187 | "Channel {channel} was permanently banned and the channel owner will no " 188 | "longer be able to send messages here on behalf of any of his channels." 189 | msgstr "" 190 | "Канал {channel} заблокирован, его владелец больше не сможет писать от имени " 191 | "любого из своих каналов в этом чате." 192 | 193 | #: aiogram_bot/handlers/simple_admin.py:86 194 | msgid "Read-only activated for user {user}. Duration: {duration}" 195 | msgstr "Пользователь {user} помещен в RO на {duration}" 196 | 197 | #: aiogram_bot/handlers/simple_admin.py:121 198 | msgid "User {user} banned for {duration}" 199 | msgstr "Пользователь {user} забанен на {duration}" 200 | 201 | #: aiogram_bot/handlers/simple_admin.py:150 202 | msgid "" 203 | "Please use this command is only in reply to message what do you want to " 204 | "report and this message will be reported to chat administrators." 205 | msgstr "" 206 | "Пожалуйста, используй эту команду только в ответ на другое сообщение и я " 207 | "сообщу про него администрации чата." 208 | 209 | #: aiogram_bot/handlers/simple_admin.py:157 210 | msgid "[ALERT] User {user} is reported message in chat {chat}." 211 | msgstr "🔥 Пользователь {user} сообщает про сообщение в чате {chat}." 212 | 213 | #: aiogram_bot/handlers/simple_admin.py:179 214 | msgid "This message is reported to chat administrators." 215 | msgstr "Я сообщил про это сообщение администрации чата." 216 | 217 | #: aiogram_bot/handlers/simple_admin.py:191 218 | msgid "User {user} leave this chat..." 219 | msgstr "Пользователь {user} покинул этот чат..." 220 | 221 | #: aiogram_bot/handlers/simple_admin.py:227 222 | msgid "Channel {channel} allowed in this chat" 223 | msgstr "Теперь канал {channel} разрешен в этом чате" 224 | 225 | #: aiogram_bot/handlers/superuser.py:25 226 | msgid "Successful changed is_superuser to {is_superuser} for user {user}" 227 | msgstr "" 228 | "Успешно обновлен флаг is_superuser на {is_superuser} для пользователя {user}" 229 | 230 | #: aiogram_bot/handlers/superuser.py:30 231 | msgid "Failed to set is_superuser to {is_superuser} for user {user}" 232 | msgstr "" 233 | "Не удалось изменить флаг is_superuser {is_superuser} для пользователя{user}" 234 | 235 | #: aiogram_bot/utils/chat_settings.py:29 236 | msgid "Settings for chat {chat_title}" 237 | msgstr "Настройки чата {chat_title}" 238 | 239 | #: aiogram_bot/utils/chat_settings.py:34 240 | msgid "{status} Join filter" 241 | msgstr "{status} Фильтр входа" 242 | 243 | #: aiogram_bot/utils/chat_settings.py:44 244 | msgid "{status} Ban channels" 245 | msgstr "{status} Банить каналы" 246 | 247 | #: aiogram_bot/utils/chat_settings.py:54 248 | msgid "{status} Delete channel messages" 249 | msgstr "{status} Удалять сообщения каналов" 250 | 251 | #: aiogram_bot/utils/chat_settings.py:64 aiogram_bot/utils/chat_settings.py:102 252 | msgid "{flag} Language" 253 | msgstr "{flag} Язык" 254 | 255 | #: aiogram_bot/utils/chat_settings.py:74 aiogram_bot/utils/chat_settings.py:110 256 | msgid "Done" 257 | msgstr "Завершить" 258 | 259 | #: aiogram_bot/utils/chat_settings.py:87 260 | msgid "Personal settings" 261 | msgstr "Персональные настройки" 262 | 263 | #: aiogram_bot/utils/chat_settings.py:92 264 | msgid "{status} Do not disturb" 265 | msgstr "{status} Не беспокоить" 266 | 267 | #~ msgid "Join filter re-configured" 268 | #~ msgstr "Фильтр входа в чат перенастроен" 269 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "8a2b5e4b1c58c7e33a2ff4f48d05e8f0ad425ddf09e46580edd81ceaeb1fecc8" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiocontextvars": { 20 | "hashes": [ 21 | "sha256:885daf8261818767d8f7cbd79f9d4482d118f024b6586ef6e67980236a27bfa3", 22 | "sha256:f027372dc48641f683c559f247bd84962becaacdc9ba711d583c3871fb5652aa" 23 | ], 24 | "markers": "python_version >= '3.5'", 25 | "version": "==0.2.2" 26 | }, 27 | "aiodns": { 28 | "hashes": [ 29 | "sha256:815fdef4607474295d68da46978a54481dd1e7be153c7d60f9e72773cd38d77d", 30 | "sha256:aaa5ac584f40fe778013df0aa6544bf157799bd3f608364b451840ed2c8688de" 31 | ], 32 | "index": "pypi", 33 | "version": "==2.0.0" 34 | }, 35 | "aiogram": { 36 | "hashes": [ 37 | "sha256:3bbe4237b8e11a347bbc47a07c1cf331ceceda2881317e8760d1e330af191dcb", 38 | "sha256:6ec9fe9a8fea1db1a3b515afd4d0ff582ccc771ab1be1d6e79fb84c94ee7f822" 39 | ], 40 | "index": "pypi", 41 | "version": "==2.9.2" 42 | }, 43 | "aiohttp": { 44 | "hashes": [ 45 | "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e", 46 | "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326", 47 | "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a", 48 | "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654", 49 | "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a", 50 | "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4", 51 | "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17", 52 | "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec", 53 | "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd", 54 | "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48", 55 | "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59", 56 | "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965" 57 | ], 58 | "index": "pypi", 59 | "version": "==3.6.2" 60 | }, 61 | "aiohttp-healthcheck": { 62 | "hashes": [ 63 | "sha256:f956c6163704f4c27562a554e801ca4c407c3362a4386f5990dad2f1d1bfd5c1" 64 | ], 65 | "index": "pypi", 66 | "version": "==1.3.1" 67 | }, 68 | "aioredis": { 69 | "hashes": [ 70 | "sha256:71302cebeb7add86f1fe660b469068760ca4364504e75ee83dd6f6b7118bfe28", 71 | "sha256:86da2748fb0652625a8346f413167f078ec72bdc76e217db7e605a059cd56e86" 72 | ], 73 | "index": "pypi", 74 | "version": "==1.3.0" 75 | }, 76 | "alembic": { 77 | "hashes": [ 78 | "sha256:9f907d7e8b286a1cfb22db9084f9ce4fde7ad7956bb496dc7c952e10ac90e36a" 79 | ], 80 | "index": "pypi", 81 | "version": "==1.2.1" 82 | }, 83 | "apscheduler": { 84 | "hashes": [ 85 | "sha256:529afb7909e08416132891188cbfea5351eb35e4a684b67e983d819e8d01a6b0", 86 | "sha256:cde18f6dbffa1b75aff67fd7fe423a3020cb0363f6c67bd45f24306d90898231" 87 | ], 88 | "index": "pypi", 89 | "version": "==3.6.1" 90 | }, 91 | "async-timeout": { 92 | "hashes": [ 93 | "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", 94 | "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" 95 | ], 96 | "markers": "python_full_version >= '3.5.3'", 97 | "version": "==3.0.1" 98 | }, 99 | "asyncpg": { 100 | "hashes": [ 101 | "sha256:0527cca24a1bb90b7872867518f25f51a431116075bebbe86149fef593630736", 102 | "sha256:16c2accafda9410fd7e8ee561c2bcd1d3141118be81dc9eccc4e671877fecb72", 103 | "sha256:35ce817f4a1cef966434e564b69e760d98f0fc2bac73c8786058bd6136ed304e", 104 | "sha256:426eb401b760abb0ce670d1d09201f1ae5a58355d272c413d1f7d7b6e354b00d", 105 | "sha256:45254bc128b175cec75c1cbc64de77bdf5593cbff4cdc58c4ca810878ddfe00b", 106 | "sha256:6a9d9824c29a72dc109b9f9e7916c36bbb6ed15e1f3154aa976ac776caf9ad88", 107 | "sha256:81431aac651d0386f483697d831cd9cb6efa816fd08dcedf0387602f52c1d4d0", 108 | "sha256:8992efcda3022e82966857989c999d5da0e9fc76394f36099700ce544ef39621", 109 | "sha256:8c5a9d6573940fe81de36af78459988b5672e427e95667323fe7dfd97f00cd09", 110 | "sha256:aab3a90dba30fe9017b5326727b505e18fe024c2e205655d52e85f9b24f5b2ab", 111 | "sha256:b253bbbe42eba8e00ad63939ff9d43a5f74e26a4e8f883de867920feb4a9e36a", 112 | "sha256:ccad6b5a59b1c27623c48a840d1f6024994551f27ca0d6c253f64004eaf8a3e6", 113 | "sha256:cebf06ac15626f3ccb6a537fe593460cdd581bf1aa33dbf3068a21dbef7e393a", 114 | "sha256:d3ac4751ae5f77fc646cfeb3f9180f2561eb0876a6b7619cb4e4e290e3432811", 115 | "sha256:e41c8ef26019c27f76cb280dfebad23f84af99ac846b19bcaea06f1b2a2f5fce", 116 | "sha256:f3e9863510b4e54ade1d20af5ef18901dea44e4fd7259db516b73810c0a1ad2e" 117 | ], 118 | "index": "pypi", 119 | "version": "==0.19.0" 120 | }, 121 | "attrs": { 122 | "hashes": [ 123 | "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", 124 | "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" 125 | ], 126 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 127 | "version": "==20.3.0" 128 | }, 129 | "babel": { 130 | "hashes": [ 131 | "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5", 132 | "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05" 133 | ], 134 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 135 | "version": "==2.9.0" 136 | }, 137 | "cchardet": { 138 | "hashes": [ 139 | "sha256:079aa02a14072874d943a671ba778a9def5b0e3cedc2ac9f59308526cfb31472", 140 | "sha256:3e048a21688dcb4c797f40c8deb3600887bcaf435620256fd8becd4252012750", 141 | "sha256:41fced7a6f05ef859fe3eac89fc2120aca3cbbfd2b6c803bed3ee4bf02956903", 142 | "sha256:440903d5dca3d326f4b841e7fa760b6af1be4f950ead1a6ff77b76eaa46f0cd3", 143 | "sha256:50170f346527c5df4d3cb94648ca187c666e61c0db6e510b984e867c44709d8b", 144 | "sha256:6c55a6e7bc7337671c9f1ad90746c0efb2b2979ff4305c7ca1d7d381f05174c1", 145 | "sha256:7f581ea172b252034f745dfd49733966b73b73907bdef0b47ad5f2008b797d54", 146 | "sha256:80f7b087198827e60c81574c321b12f89188eae626ae1567d66808928be42f88", 147 | "sha256:8ba753ff73ca2f3554999a0e027eab9450f6ffdb7e92e1b4e13b52be89995349", 148 | "sha256:9ad8f61d6d1ca37bd4b954ad92d461ea4f58d0dc413b0790a5abed7c09e54996", 149 | "sha256:a35bd23cedbaa87cc9300af1dd10bb03fda41894045fbca7bfdf1d350b813f25", 150 | "sha256:a8feb9a7def2310e18c27e485a21a38669abe8c2e36b93c6ce1a1363495d4cdf", 151 | "sha256:aa9dd4cee8a5210a6d0a7b263b98dc50637e00401fc4a5ad3ce2dbef54fdfa02", 152 | "sha256:ab9858a0673262e467619df91f425cfef0590dcf5deef5c0c7945e9dc4dbd7d8", 153 | "sha256:b09a488bbb35be95f82845e3c4312be9025e8377975b027eee67e0b39445e070", 154 | "sha256:b2893d558761b3534cddf5a49ba8d77df3d8f964d7b14680b925f4a85fc13476", 155 | "sha256:b5a8f9b229a30cd2432572d15e169483bc47c24418772ff58d0585050631c2fd", 156 | "sha256:bded54eeccd5f810bc69e076b3d9a35819a92e5e0559ad274b9ae9061b1b881d", 157 | "sha256:cbc206061e69561af6e4cba11f99abd928346c6b5bcdc83eb32ae40e9fc23a5f", 158 | "sha256:cc9745e0400da4cfb49f075e7819f22473b66443f953427058fee2c7b9547cc0", 159 | "sha256:db30bf3825702c07fc55a290d41663fd8151f870642a15667bbabf81fff21e0b", 160 | "sha256:eeeb1b95bb5851dda93ee522860a0e6066d47921cb1d540cb778346e37e5a524", 161 | "sha256:f1c3919fb71ac5da3aeee42c5b731c99dcd2beed71db7fdc28ca993c173f0402" 162 | ], 163 | "index": "pypi", 164 | "version": "==2.1.4" 165 | }, 166 | "certifi": { 167 | "hashes": [ 168 | "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", 169 | "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" 170 | ], 171 | "version": "==2020.12.5" 172 | }, 173 | "cffi": { 174 | "hashes": [ 175 | "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", 176 | "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", 177 | "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", 178 | "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", 179 | "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", 180 | "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", 181 | "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", 182 | "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", 183 | "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", 184 | "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", 185 | "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", 186 | "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", 187 | "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", 188 | "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", 189 | "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", 190 | "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", 191 | "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", 192 | "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", 193 | "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", 194 | "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", 195 | "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", 196 | "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", 197 | "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", 198 | "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", 199 | "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", 200 | "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", 201 | "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", 202 | "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", 203 | "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", 204 | "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", 205 | "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", 206 | "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", 207 | "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", 208 | "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", 209 | "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", 210 | "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", 211 | "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" 212 | ], 213 | "version": "==1.14.5" 214 | }, 215 | "chardet": { 216 | "hashes": [ 217 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 218 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 219 | ], 220 | "version": "==3.0.4" 221 | }, 222 | "click": { 223 | "hashes": [ 224 | "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", 225 | "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" 226 | ], 227 | "index": "pypi", 228 | "version": "==7.0" 229 | }, 230 | "envparse": { 231 | "hashes": [ 232 | "sha256:4f3b9a27bb55d27f124eb4adf006fec05e4588891c9a054a183a112645056eb7" 233 | ], 234 | "index": "pypi", 235 | "version": "==0.2.0" 236 | }, 237 | "gino": { 238 | "hashes": [ 239 | "sha256:187492619c347df41fdbc876b058b85e77f7c50739d552ad720ff439d3d83753", 240 | "sha256:3aebbe8776efefa49b878f00b48d6ad483b0f4ebcd8630641ea7c3c9f63bb260" 241 | ], 242 | "index": "pypi", 243 | "version": "==0.8.3" 244 | }, 245 | "hiredis": { 246 | "hashes": [ 247 | "sha256:04026461eae67fdefa1949b7332e488224eac9e8f2b5c58c98b54d29af22093e", 248 | "sha256:04927a4c651a0e9ec11c68e4427d917e44ff101f761cd3b5bc76f86aaa431d27", 249 | "sha256:07bbf9bdcb82239f319b1f09e8ef4bdfaec50ed7d7ea51a56438f39193271163", 250 | "sha256:09004096e953d7ebd508cded79f6b21e05dff5d7361771f59269425108e703bc", 251 | "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26", 252 | "sha256:0b39ec237459922c6544d071cdcf92cbb5bc6685a30e7c6d985d8a3e3a75326e", 253 | "sha256:0d5109337e1db373a892fdcf78eb145ffb6bbd66bb51989ec36117b9f7f9b579", 254 | "sha256:0f41827028901814c709e744060843c77e78a3aca1e0d6875d2562372fcb405a", 255 | "sha256:11d119507bb54e81f375e638225a2c057dda748f2b1deef05c2b1a5d42686048", 256 | "sha256:1233e303645f468e399ec906b6b48ab7cd8391aae2d08daadbb5cad6ace4bd87", 257 | "sha256:139705ce59d94eef2ceae9fd2ad58710b02aee91e7fa0ccb485665ca0ecbec63", 258 | "sha256:1f03d4dadd595f7a69a75709bc81902673fa31964c75f93af74feac2f134cc54", 259 | "sha256:240ce6dc19835971f38caf94b5738092cb1e641f8150a9ef9251b7825506cb05", 260 | "sha256:294a6697dfa41a8cba4c365dd3715abc54d29a86a40ec6405d677ca853307cfb", 261 | "sha256:3d55e36715ff06cdc0ab62f9591607c4324297b6b6ce5b58cb9928b3defe30ea", 262 | "sha256:3dddf681284fe16d047d3ad37415b2e9ccdc6c8986c8062dbe51ab9a358b50a5", 263 | "sha256:3f5f7e3a4ab824e3de1e1700f05ad76ee465f5f11f5db61c4b297ec29e692b2e", 264 | "sha256:508999bec4422e646b05c95c598b64bdbef1edf0d2b715450a078ba21b385bcc", 265 | "sha256:5d2a48c80cf5a338d58aae3c16872f4d452345e18350143b3bf7216d33ba7b99", 266 | "sha256:5dc7a94bb11096bc4bffd41a3c4f2b958257085c01522aa81140c68b8bf1630a", 267 | "sha256:65d653df249a2f95673976e4e9dd7ce10de61cfc6e64fa7eeaa6891a9559c581", 268 | "sha256:7492af15f71f75ee93d2a618ca53fea8be85e7b625e323315169977fae752426", 269 | "sha256:7f0055f1809b911ab347a25d786deff5e10e9cf083c3c3fd2dd04e8612e8d9db", 270 | "sha256:807b3096205c7cec861c8803a6738e33ed86c9aae76cac0e19454245a6bbbc0a", 271 | "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a", 272 | "sha256:87c7c10d186f1743a8fd6a971ab6525d60abd5d5d200f31e073cd5e94d7e7a9d", 273 | "sha256:8b42c0dc927b8d7c0eb59f97e6e34408e53bc489f9f90e66e568f329bff3e443", 274 | "sha256:a00514362df15af041cc06e97aebabf2895e0a7c42c83c21894be12b84402d79", 275 | "sha256:a39efc3ade8c1fb27c097fd112baf09d7fd70b8cb10ef1de4da6efbe066d381d", 276 | "sha256:a4ee8000454ad4486fb9f28b0cab7fa1cd796fc36d639882d0b34109b5b3aec9", 277 | "sha256:a7928283143a401e72a4fad43ecc85b35c27ae699cf5d54d39e1e72d97460e1d", 278 | "sha256:adf4dd19d8875ac147bf926c727215a0faf21490b22c053db464e0bf0deb0485", 279 | "sha256:ae8427a5e9062ba66fc2c62fb19a72276cf12c780e8db2b0956ea909c48acff5", 280 | "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048", 281 | "sha256:b84f29971f0ad4adaee391c6364e6f780d5aae7e9226d41964b26b49376071d0", 282 | "sha256:c39c46d9e44447181cd502a35aad2bb178dbf1b1f86cf4db639d7b9614f837c6", 283 | "sha256:cb2126603091902767d96bcb74093bd8b14982f41809f85c9b96e519c7e1dc41", 284 | "sha256:dcef843f8de4e2ff5e35e96ec2a4abbdf403bd0f732ead127bd27e51f38ac298", 285 | "sha256:e3447d9e074abf0e3cd85aef8131e01ab93f9f0e86654db7ac8a3f73c63706ce", 286 | "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0", 287 | "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a" 288 | ], 289 | "markers": "python_version >= '3.6'", 290 | "version": "==2.0.0" 291 | }, 292 | "idna": { 293 | "hashes": [ 294 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 295 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 296 | ], 297 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 298 | "version": "==2.10" 299 | }, 300 | "loguru": { 301 | "hashes": [ 302 | "sha256:b6fad0d7aed357b5c147edcc6982606b933754338950b72d8123f48c150c5a4f", 303 | "sha256:e3138bfdee5f57481a2a6e078714be20f8c71ab1ff3f07f8fb1cfa25191fed2a" 304 | ], 305 | "index": "pypi", 306 | "version": "==0.3.2" 307 | }, 308 | "mako": { 309 | "hashes": [ 310 | "sha256:17831f0b7087c313c0ffae2bcbbd3c1d5ba9eeac9c38f2eb7b50e8c99fe9d5ab", 311 | "sha256:aea166356da44b9b830c8023cd9b557fa856bd8b4035d6de771ca027dfc5cc6e" 312 | ], 313 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 314 | "version": "==1.1.4" 315 | }, 316 | "markupsafe": { 317 | "hashes": [ 318 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 319 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 320 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 321 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 322 | "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", 323 | "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", 324 | "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", 325 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 326 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 327 | "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", 328 | "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", 329 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 330 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 331 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 332 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 333 | "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", 334 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 335 | "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", 336 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 337 | "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", 338 | "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", 339 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 340 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 341 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 342 | "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", 343 | "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", 344 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 345 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 346 | "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", 347 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 348 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 349 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 350 | "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", 351 | "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", 352 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 353 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 354 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 355 | "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7", 356 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 357 | "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8", 358 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 359 | "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193", 360 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 361 | "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b", 362 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 363 | "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", 364 | "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5", 365 | "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c", 366 | "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032", 367 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", 368 | "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", 369 | "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" 370 | ], 371 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 372 | "version": "==1.1.1" 373 | }, 374 | "multidict": { 375 | "hashes": [ 376 | "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a", 377 | "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000", 378 | "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2", 379 | "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507", 380 | "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5", 381 | "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7", 382 | "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d", 383 | "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463", 384 | "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19", 385 | "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3", 386 | "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b", 387 | "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c", 388 | "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87", 389 | "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7", 390 | "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430", 391 | "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", 392 | "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" 393 | ], 394 | "markers": "python_version >= '3.5'", 395 | "version": "==4.7.6" 396 | }, 397 | "psycopg2-binary": { 398 | "hashes": [ 399 | "sha256:040234f8a4a8dfd692662a8308d78f63f31a97e1c42d2480e5e6810c48966a29", 400 | "sha256:086f7e89ec85a6704db51f68f0dcae432eff9300809723a6e8782c41c2f48e03", 401 | "sha256:18ca813fdb17bc1db73fe61b196b05dd1ca2165b884dd5ec5568877cabf9b039", 402 | "sha256:19dc39616850342a2a6db70559af55b22955f86667b5f652f40c0e99253d9881", 403 | "sha256:2166e770cb98f02ed5ee2b0b569d40db26788e0bf2ec3ae1a0d864ea6f1d8309", 404 | "sha256:3a2522b1d9178575acee4adf8fd9f979f9c0449b00b4164bb63c3475ea6528ed", 405 | "sha256:3aa773580f85a28ffdf6f862e59cb5a3cc7ef6885121f2de3fca8d6ada4dbf3b", 406 | "sha256:3b5deaa3ee7180585a296af33e14c9b18c218d148e735c7accf78130765a47e3", 407 | "sha256:407af6d7e46593415f216c7f56ba087a9a42bd6dc2ecb86028760aa45b802bd7", 408 | "sha256:4c3c09fb674401f630626310bcaf6cd6285daf0d5e4c26d6e55ca26a2734e39b", 409 | "sha256:4c6717962247445b4f9e21c962ea61d2e884fc17df5ddf5e35863b016f8a1f03", 410 | "sha256:50446fae5681fc99f87e505d4e77c9407e683ab60c555ec302f9ac9bffa61103", 411 | "sha256:5057669b6a66aa9ca118a2a860159f0ee3acf837eda937bdd2a64f3431361a2d", 412 | "sha256:5dd90c5438b4f935c9d01fcbad3620253da89d19c1f5fca9158646407ed7df35", 413 | "sha256:659c815b5b8e2a55193ede2795c1e2349b8011497310bb936da7d4745652823b", 414 | "sha256:69b13fdf12878b10dc6003acc8d0abf3ad93e79813fd5f3812497c1c9fb9be49", 415 | "sha256:7a1cb80e35e1ccea3e11a48afe65d38744a0e0bde88795cc56a4d05b6e4f9d70", 416 | "sha256:7e6e3c52e6732c219c07bd97fff6c088f8df4dae3b79752ee3a817e6f32e177e", 417 | "sha256:7f42a8490c4fe854325504ce7a6e4796b207960dabb2cbafe3c3959cb00d1d7e", 418 | "sha256:84156313f258eafff716b2961644a4483a9be44a5d43551d554844d15d4d224e", 419 | "sha256:8578d6b8192e4c805e85f187bc530d0f52ba86c39172e61cd51f68fddd648103", 420 | "sha256:890167d5091279a27e2505ff0e1fb273f8c48c41d35c5b92adbf4af80e6b2ed6", 421 | "sha256:98e10634792ac0e9e7a92a76b4991b44c2325d3e7798270a808407355e7bb0a1", 422 | "sha256:9aadff9032e967865f9778485571e93908d27dab21d0fdfdec0ca779bb6f8ad9", 423 | "sha256:9f24f383a298a0c0f9b3113b982e21751a8ecde6615494a3f1470eb4a9d70e9e", 424 | "sha256:a73021b44813b5c84eda4a3af5826dd72356a900bac9bd9dd1f0f81ee1c22c2f", 425 | "sha256:afd96845e12638d2c44d213d4810a08f4dc4a563f9a98204b7428e567014b1cd", 426 | "sha256:b73ddf033d8cd4cc9dfed6324b1ad2a89ba52c410ef6877998422fcb9c23e3a8", 427 | "sha256:b8f490f5fad1767a1331df1259763b3bad7d7af12a75b950c2843ba319b2415f", 428 | "sha256:dbc5cd56fff1a6152ca59445178652756f4e509f672e49ccdf3d79c1043113a4", 429 | "sha256:eac8a3499754790187bb00574ab980df13e754777d346f85e0ff6df929bcd964", 430 | "sha256:eaed1c65f461a959284649e37b5051224f4db6ebdc84e40b5e65f2986f101a08" 431 | ], 432 | "index": "pypi", 433 | "version": "==2.8.4" 434 | }, 435 | "pycares": { 436 | "hashes": [ 437 | "sha256:050f00b39ed77ea8a4e555f09417d4b1a6b5baa24bb9531a3e15d003d2319b3f", 438 | "sha256:0a24d2e580a8eb567140d7b69f12cb7de90c836bd7b6488ec69394d308605ac3", 439 | "sha256:0c5bd1f6f885a219d5e972788d6eef7b8043b55c3375a845e5399638436e0bba", 440 | "sha256:11c628402cc8fc8ef461076d4e47f88afc1f8609989ebbff0dbffcd54c97239f", 441 | "sha256:18dfd4fd300f570d6c4536c1d987b7b7673b2a9d14346592c5d6ed716df0d104", 442 | "sha256:1917b82494907a4a342db420bc4dd5bac355a5fa3984c35ba9bf51422b020b48", 443 | "sha256:1b90fa00a89564df059fb18e796458864cc4e00cb55e364dbf921997266b7c55", 444 | "sha256:1d8d177c40567de78108a7835170f570ab04f09084bfd32df9919c0eaec47aa1", 445 | "sha256:236286f81664658b32c141c8e79d20afc3d54f6e2e49dfc8b702026be7265855", 446 | "sha256:2e4f74677542737fb5af4ea9a2e415ec5ab31aa67e7b8c3c969fdb15c069f679", 447 | "sha256:48a7750f04e69e1f304f4332b755728067e7c4b1abe2760bba1cacd9ff7a847a", 448 | "sha256:7d86e62b700b21401ffe7fd1bbfe91e08489416fecae99c6570ab023c6896022", 449 | "sha256:7e2d7effd08d2e5a3cb95d98a7286ebab71ab2fbce84fa93cc2dd56caf7240dd", 450 | "sha256:81edb016d9e43dde7473bc3999c29cdfee3a6b67308fed1ea21049f458e83ae0", 451 | "sha256:96c90e11b4a4c7c0b8ff5aaaae969c5035493136586043ff301979aae0623941", 452 | "sha256:9a0a1845f8cb2e62332bca0aaa9ad5494603ac43fb60d510a61d5b5b170d7216", 453 | "sha256:a05bbfdfd41f8410a905a818f329afe7510cbd9ee65c60f8860a72b6c64ce5dc", 454 | "sha256:a5089fd660f0b0d228b14cdaa110d0d311edfa5a63f800618dbf1321dcaef66b", 455 | "sha256:c457a709e6f2befea7e2996c991eda6d79705dd075f6521593ba6ebc1485b811", 456 | "sha256:c5cb72644b04e5e5abfb1e10a0e7eb75da6684ea0e60871652f348e412cf3b11", 457 | "sha256:cce46dd4717debfd2aab79d6d7f0cbdf6b1e982dc4d9bebad81658d59ede07c2", 458 | "sha256:cfdd1f90bcf373b00f4b2c55ea47868616fe2f779f792fc913fa82a3d64ffe43", 459 | "sha256:d88a279cbc5af613f73e86e19b3f63850f7a2e2736e249c51995dedcc830b1bb", 460 | "sha256:eba9a9227438da5e78fc8eee32f32eb35d9a50cf0a0bd937eb6275c7cc3015fe", 461 | "sha256:eee7b6a5f5b5af050cb7d66ab28179287b416f06d15a8974ac831437fec51336", 462 | "sha256:f41ac1c858687e53242828c9f59c2e7b0b95dbcd5bdd09c7e5d3c48b0f89a25a", 463 | "sha256:f8deaefefc3a589058df1b177275f79233e8b0eeee6734cf4336d80164ecd022", 464 | "sha256:fa78e919f3bd7d6d075db262aa41079b4c02da315c6043c6f43881e2ebcdd623", 465 | "sha256:fadb97d2e02dabdc15a0091591a972a938850d79ddde23d385d813c1731983f0" 466 | ], 467 | "version": "==3.1.1" 468 | }, 469 | "pycparser": { 470 | "hashes": [ 471 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 472 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 473 | ], 474 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 475 | "version": "==2.20" 476 | }, 477 | "python-dateutil": { 478 | "hashes": [ 479 | "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", 480 | "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" 481 | ], 482 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 483 | "version": "==2.8.1" 484 | }, 485 | "python-editor": { 486 | "hashes": [ 487 | "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", 488 | "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", 489 | "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", 490 | "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", 491 | "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" 492 | ], 493 | "version": "==1.0.4" 494 | }, 495 | "pytz": { 496 | "hashes": [ 497 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 498 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 499 | ], 500 | "version": "==2021.1" 501 | }, 502 | "redis": { 503 | "hashes": [ 504 | "sha256:3613daad9ce5951e426f460deddd5caf469e08a3af633e9578fc77d362becf62", 505 | "sha256:8d0fc278d3f5e1249967cba2eb4a5632d19e45ce5c09442b8422d15ee2c22cc2" 506 | ], 507 | "index": "pypi", 508 | "version": "==3.3.11" 509 | }, 510 | "requests": { 511 | "hashes": [ 512 | "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", 513 | "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" 514 | ], 515 | "index": "pypi", 516 | "version": "==2.25.1" 517 | }, 518 | "six": { 519 | "hashes": [ 520 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 521 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 522 | ], 523 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 524 | "version": "==1.15.0" 525 | }, 526 | "sqlalchemy": { 527 | "hashes": [ 528 | "sha256:0f0768b5db594517e1f5e1572c73d14cf295140756431270d89496dc13d5e46c" 529 | ], 530 | "index": "pypi", 531 | "version": "==1.3.10" 532 | }, 533 | "sqlalchemy-utils": { 534 | "hashes": [ 535 | "sha256:6689b29d7951c5c7c4d79fa6b8c95f9ff9ec708b07aa53f82060599bd14dcc88" 536 | ], 537 | "index": "pypi", 538 | "version": "==0.34.2" 539 | }, 540 | "tenacity": { 541 | "hashes": [ 542 | "sha256:6a7511a59145c2e319b7d04ddd93c12d48cc3d3c8fa42c2846d33a620ee91f57", 543 | "sha256:a4eb168dbf55ed2cae27e7c6b2bd48ab54dabaf294177d998330cf59f294c112" 544 | ], 545 | "index": "pypi", 546 | "version": "==5.1.1" 547 | }, 548 | "tzlocal": { 549 | "hashes": [ 550 | "sha256:643c97c5294aedc737780a49d9df30889321cbe1204eac2c2ec6134035a92e44", 551 | "sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4" 552 | ], 553 | "version": "==2.1" 554 | }, 555 | "urllib3": { 556 | "hashes": [ 557 | "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", 558 | "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" 559 | ], 560 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 561 | "version": "==1.26.4" 562 | }, 563 | "uvloop": { 564 | "hashes": [ 565 | "sha256:114543c84e95df1b4ff546e6e3a27521580466a30127f12172a3278172ad68bc", 566 | "sha256:19fa1d56c91341318ac5d417e7b61c56e9a41183946cc70c411341173de02c69", 567 | "sha256:2bb0624a8a70834e54dde8feed62ed63b50bad7a1265c40d6403a2ac447bce01", 568 | "sha256:42eda9f525a208fbc4f7cecd00fa15c57cc57646c76632b3ba2fe005004f051d", 569 | "sha256:44cac8575bf168601424302045234d74e3561fbdbac39b2b54cc1d1d00b70760", 570 | "sha256:6de130d0cb78985a5d080e323b86c5ecaf3af82f4890492c05981707852f983c", 571 | "sha256:7ae39b11a5f4cec1432d706c21ecc62f9e04d116883178b09671aa29c46f7a47", 572 | "sha256:90e56f17755e41b425ad19a08c41dc358fa7bf1226c0f8e54d4d02d556f7af7c", 573 | "sha256:b45218c99795803fb8bdbc9435ff7f54e3a591b44cd4c121b02fa83affb61c7c", 574 | "sha256:e5e5f855c9bf483ee6cd1eb9a179b740de80cb0ae2988e3fa22309b78e2ea0e7" 575 | ], 576 | "index": "pypi", 577 | "version": "==0.15.2" 578 | }, 579 | "yarl": { 580 | "hashes": [ 581 | "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e", 582 | "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434", 583 | "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366", 584 | "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3", 585 | "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec", 586 | "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959", 587 | "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e", 588 | "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c", 589 | "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6", 590 | "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a", 591 | "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6", 592 | "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424", 593 | "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e", 594 | "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f", 595 | "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50", 596 | "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2", 597 | "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc", 598 | "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4", 599 | "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970", 600 | "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10", 601 | "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0", 602 | "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406", 603 | "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896", 604 | "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643", 605 | "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721", 606 | "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478", 607 | "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724", 608 | "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e", 609 | "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8", 610 | "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96", 611 | "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25", 612 | "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76", 613 | "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2", 614 | "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2", 615 | "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c", 616 | "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", 617 | "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" 618 | ], 619 | "markers": "python_version >= '3.6'", 620 | "version": "==1.6.3" 621 | } 622 | }, 623 | "develop": { 624 | "aiohttp-autoreload": { 625 | "hashes": [ 626 | "sha256:ac4153d42c05324cbfb8790762afe73edc87cdb9205b5e9e14127dcad9c8df4c" 627 | ], 628 | "index": "pypi", 629 | "version": "==0.0.1" 630 | }, 631 | "appdirs": { 632 | "hashes": [ 633 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", 634 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" 635 | ], 636 | "version": "==1.4.4" 637 | }, 638 | "attrs": { 639 | "hashes": [ 640 | "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", 641 | "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" 642 | ], 643 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 644 | "version": "==20.3.0" 645 | }, 646 | "backcall": { 647 | "hashes": [ 648 | "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", 649 | "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255" 650 | ], 651 | "version": "==0.2.0" 652 | }, 653 | "black": { 654 | "hashes": [ 655 | "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf", 656 | "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c" 657 | ], 658 | "index": "pypi", 659 | "version": "==19.3b0" 660 | }, 661 | "click": { 662 | "hashes": [ 663 | "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", 664 | "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" 665 | ], 666 | "index": "pypi", 667 | "version": "==7.0" 668 | }, 669 | "decorator": { 670 | "hashes": [ 671 | "sha256:d9f2d2863183a3c0df05f4b786f2e6b8752c093b3547a558f287bf3022fd2bf4", 672 | "sha256:f2e71efb39412bfd23d878e896a51b07744f2e2250b2e87d158e76828c5ae202" 673 | ], 674 | "markers": "python_version >= '3.5'", 675 | "version": "==5.0.6" 676 | }, 677 | "entrypoints": { 678 | "hashes": [ 679 | "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", 680 | "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" 681 | ], 682 | "markers": "python_version >= '2.7'", 683 | "version": "==0.3" 684 | }, 685 | "flake8": { 686 | "hashes": [ 687 | "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", 688 | "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696" 689 | ], 690 | "index": "pypi", 691 | "version": "==3.7.8" 692 | }, 693 | "ipython": { 694 | "hashes": [ 695 | "sha256:c4ab005921641e40a68e405e286e7a1fcc464497e14d81b6914b4fd95e5dee9b", 696 | "sha256:dd76831f065f17bddd7eaa5c781f5ea32de5ef217592cf019e34043b56895aa1" 697 | ], 698 | "index": "pypi", 699 | "version": "==7.8.0" 700 | }, 701 | "ipython-genutils": { 702 | "hashes": [ 703 | "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", 704 | "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8" 705 | ], 706 | "version": "==0.2.0" 707 | }, 708 | "isort": { 709 | "hashes": [ 710 | "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", 711 | "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" 712 | ], 713 | "index": "pypi", 714 | "version": "==4.3.21" 715 | }, 716 | "jedi": { 717 | "hashes": [ 718 | "sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93", 719 | "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707" 720 | ], 721 | "markers": "python_version >= '3.6'", 722 | "version": "==0.18.0" 723 | }, 724 | "mccabe": { 725 | "hashes": [ 726 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 727 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 728 | ], 729 | "version": "==0.6.1" 730 | }, 731 | "parso": { 732 | "hashes": [ 733 | "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398", 734 | "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22" 735 | ], 736 | "markers": "python_version >= '3.6'", 737 | "version": "==0.8.2" 738 | }, 739 | "pexpect": { 740 | "hashes": [ 741 | "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", 742 | "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" 743 | ], 744 | "markers": "sys_platform != 'win32'", 745 | "version": "==4.8.0" 746 | }, 747 | "pickleshare": { 748 | "hashes": [ 749 | "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", 750 | "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56" 751 | ], 752 | "version": "==0.7.5" 753 | }, 754 | "prompt-toolkit": { 755 | "hashes": [ 756 | "sha256:46642344ce457641f28fc9d1c9ca939b63dadf8df128b86f1b9860e59c73a5e4", 757 | "sha256:e7f8af9e3d70f514373bf41aa51bc33af12a6db3f71461ea47fea985defb2c31", 758 | "sha256:f15af68f66e664eaa559d4ac8a928111eebd5feda0c11738b5998045224829db" 759 | ], 760 | "version": "==2.0.10" 761 | }, 762 | "ptyprocess": { 763 | "hashes": [ 764 | "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", 765 | "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" 766 | ], 767 | "version": "==0.7.0" 768 | }, 769 | "pycodestyle": { 770 | "hashes": [ 771 | "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", 772 | "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" 773 | ], 774 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 775 | "version": "==2.5.0" 776 | }, 777 | "pyflakes": { 778 | "hashes": [ 779 | "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", 780 | "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" 781 | ], 782 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 783 | "version": "==2.1.1" 784 | }, 785 | "pygments": { 786 | "hashes": [ 787 | "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94", 788 | "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8" 789 | ], 790 | "markers": "python_version >= '3.5'", 791 | "version": "==2.8.1" 792 | }, 793 | "six": { 794 | "hashes": [ 795 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 796 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 797 | ], 798 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 799 | "version": "==1.15.0" 800 | }, 801 | "toml": { 802 | "hashes": [ 803 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 804 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 805 | ], 806 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 807 | "version": "==0.10.2" 808 | }, 809 | "traitlets": { 810 | "hashes": [ 811 | "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396", 812 | "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426" 813 | ], 814 | "markers": "python_version >= '3.7'", 815 | "version": "==5.0.5" 816 | }, 817 | "wcwidth": { 818 | "hashes": [ 819 | "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", 820 | "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" 821 | ], 822 | "version": "==0.2.5" 823 | } 824 | } 825 | } 826 | --------------------------------------------------------------------------------