├── broadcast ├── __init__.py ├── data.py └── broadcast.py ├── zmanim_bot ├── __init__.py ├── admin │ ├── __init__.py │ ├── states.py │ └── report_management.py ├── repository │ ├── __init__.py │ ├── bot_repository.py │ ├── models.py │ └── _storage.py ├── service │ ├── __init__.py │ ├── payments_service.py │ ├── converter_service.py │ ├── festivals_service.py │ ├── zmanim_service.py │ └── settings_service.py ├── handlers │ ├── utils │ │ ├── __init__.py │ │ ├── warnings.py │ │ └── redirects.py │ ├── incorrect_text_handler.py │ ├── reset_handler.py │ ├── payments.py │ ├── converter.py │ ├── menus.py │ ├── festivals.py │ ├── admin.py │ ├── main.py │ ├── errors.py │ ├── settings.py │ ├── forms.py │ ├── geolocation.py │ └── __init__.py ├── integrations │ ├── __init__.py │ ├── open_topo_data_client.py │ ├── geo_client.py │ ├── zmanim_api_client.py │ └── zmanim_models.py ├── processors │ ├── text │ │ ├── __init__.py │ │ ├── text_processor.py │ │ └── composer.py │ ├── image │ │ ├── __init__.py │ │ ├── res │ │ │ ├── fonts │ │ │ │ ├── gothic.TTF │ │ │ │ ├── fontello.ttf │ │ │ │ ├── gothic-bold.TTF │ │ │ │ ├── gothic_old.TTF │ │ │ │ ├── gothic-bold_old.TTF │ │ │ │ └── VarelaRound-Regular.ttf │ │ │ └── backgrounds │ │ │ │ ├── fast.png │ │ │ │ ├── pesah.png │ │ │ │ ├── purim.png │ │ │ │ ├── chanuka.png │ │ │ │ ├── daf_yomi.png │ │ │ │ ├── shabbos.png │ │ │ │ ├── shavuot.png │ │ │ │ ├── succos.png │ │ │ │ ├── zmanim.png │ │ │ │ ├── lagbaomer.png │ │ │ │ ├── tubishvat.png │ │ │ │ ├── yom_kippur.png │ │ │ │ ├── zmanim_bot.ai │ │ │ │ ├── rosh_hashana.png │ │ │ │ ├── rosh_hodesh.png │ │ │ │ ├── israel_holidays.png │ │ │ │ ├── shmini_atzeret.png │ │ │ │ └── shabbos_attention.png │ │ ├── image_processor.py │ │ └── renderer.py │ ├── __init__.py │ ├── text_utils.py │ └── base.py ├── texts │ ├── plural │ │ ├── __init__.py │ │ └── units.py │ ├── __init__.py │ ├── single │ │ ├── __init__.py │ │ ├── helpers.py │ │ ├── zmanim.py │ │ ├── headers.py │ │ ├── buttons.py │ │ ├── messages.py │ │ └── names.py │ ├── translators.py │ └── commands.py ├── keyboards │ ├── __init__.py │ ├── menus.py │ └── inline.py ├── res │ └── on_sucess_donate.jpg ├── middlewares │ ├── __init__.py │ ├── sentry_context_middleware.py │ ├── chat_type_middleware.py │ ├── black_list_middleware.py │ └── i18n.py ├── states.py ├── misc.py ├── utils.py ├── main.py ├── exceptions.py ├── helpers.py └── config.py ├── .dockerignore ├── locales ├── he │ └── LC_MESSAGES │ │ └── zmanim_bot.mo └── ru │ └── LC_MESSAGES │ └── zmanim_bot.mo ├── Dockerfile ├── .gitignore ├── .github └── workflows │ ├── ci_beta.yaml │ └── ci_prod.yaml └── pyproject.toml /broadcast/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zmanim_bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zmanim_bot/admin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zmanim_bot/repository/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zmanim_bot/service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zmanim_bot/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zmanim_bot/processors/text/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zmanim_bot/texts/plural/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | Pipfile.lock 3 | -------------------------------------------------------------------------------- /zmanim_bot/processors/image/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zmanim_bot/texts/__init__.py: -------------------------------------------------------------------------------- 1 | from . import plural, single 2 | -------------------------------------------------------------------------------- /zmanim_bot/keyboards/__init__.py: -------------------------------------------------------------------------------- 1 | from . import inline, menus 2 | -------------------------------------------------------------------------------- /zmanim_bot/texts/single/__init__.py: -------------------------------------------------------------------------------- 1 | from . import buttons, headers, helpers, messages, names, zmanim 2 | -------------------------------------------------------------------------------- /locales/he/LC_MESSAGES/zmanim_bot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/locales/he/LC_MESSAGES/zmanim_bot.mo -------------------------------------------------------------------------------- /locales/ru/LC_MESSAGES/zmanim_bot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/locales/ru/LC_MESSAGES/zmanim_bot.mo -------------------------------------------------------------------------------- /zmanim_bot/res/on_sucess_donate.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/res/on_sucess_donate.jpg -------------------------------------------------------------------------------- /zmanim_bot/texts/translators.py: -------------------------------------------------------------------------------- 1 | fake_gettext = lambda word: word 2 | fake_gettext_plural = lambda word_s, word_p: (word_s, word_p) 3 | -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/fonts/gothic.TTF: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/fonts/gothic.TTF -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/fonts/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/fonts/fontello.ttf -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/fast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/fast.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/pesah.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/pesah.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/purim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/purim.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/fonts/gothic-bold.TTF: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/fonts/gothic-bold.TTF -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/fonts/gothic_old.TTF: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/fonts/gothic_old.TTF -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/chanuka.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/chanuka.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/daf_yomi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/daf_yomi.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/shabbos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/shabbos.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/shavuot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/shavuot.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/succos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/succos.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/zmanim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/zmanim.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/lagbaomer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/lagbaomer.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/tubishvat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/tubishvat.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/yom_kippur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/yom_kippur.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/zmanim_bot.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/zmanim_bot.ai -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/fonts/gothic-bold_old.TTF: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/fonts/gothic-bold_old.TTF -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/rosh_hashana.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/rosh_hashana.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/rosh_hodesh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/rosh_hodesh.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/fonts/VarelaRound-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/fonts/VarelaRound-Regular.ttf -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/israel_holidays.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/israel_holidays.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/shmini_atzeret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/shmini_atzeret.png -------------------------------------------------------------------------------- /zmanim_bot/processors/image/res/backgrounds/shabbos_attention.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benyaming/zmanim_bot/HEAD/zmanim_bot/processors/image/res/backgrounds/shabbos_attention.png -------------------------------------------------------------------------------- /zmanim_bot/admin/states.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.filters.state import State, StatesGroup 2 | 3 | 4 | class AdminReportResponse(StatesGroup): 5 | waiting_for_response_text = State() 6 | waiting_for_payload = State() 7 | -------------------------------------------------------------------------------- /broadcast/data.py: -------------------------------------------------------------------------------- 1 | ru_message = '''тестовая рассылка''' 2 | 3 | en_message = '''broadcast test''' 4 | 5 | ru_button_text = 'Поддержка zmanim_bot' 6 | en_button_text = 'Donate to zmanim_bot' 7 | 8 | link = 'https://telegra.ph/Donate-to-Zmanim-bot-03-31' 9 | -------------------------------------------------------------------------------- /zmanim_bot/processors/__init__.py: -------------------------------------------------------------------------------- 1 | from zmanim_bot.processors.base import BaseProcessor 2 | 3 | from .image.image_processor import ImageProcessor 4 | from .text.text_processor import TextProcessor 5 | 6 | PROCESSORS = { 7 | 'image': ImageProcessor, 8 | 'text': TextProcessor 9 | } 10 | -------------------------------------------------------------------------------- /zmanim_bot/texts/plural/units.py: -------------------------------------------------------------------------------- 1 | from ..translators import fake_gettext_plural as __ 2 | 3 | # Time units 4 | tu_year = __('year', 'years') 5 | tu_month = __('month', 'months') 6 | tu_day = __('day', 'days') 7 | tu_hour = __('hour', 'hours') 8 | tu_minute = __('minute', 'minutes') 9 | tu_part = __('part', 'parts') 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | RUN apt update 4 | RUN apt install -y libraqm-dev 5 | 6 | RUN pip install pdm 7 | WORKDIR /home/app 8 | COPY . . 9 | WORKDIR /home/app/zmanim_bot 10 | RUN pdm install 11 | ENV PYTHONPATH=/home/app 12 | ENV DOCKER_MODE=true 13 | EXPOSE 8000 14 | CMD ["pdm", "run", "python", "main.py"] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | \.idea/ 3 | 4 | zmanim/__pycache__/ 5 | 6 | zmanim/res/__pycache__/ 7 | 8 | zmanim/res/image/__pycache__/ 9 | 10 | zmanim/res/image/tmp\.py 11 | 12 | zmanim/res/localizations/__pycache__/ 13 | 14 | __pycache__/ 15 | 16 | \.env 17 | /broadcast/ 18 | /broadcast/** 19 | broadcast 20 | broadcast/* 21 | Pipfile.lock 22 | /broadcast/* 23 | .pdm-python 24 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/incorrect_text_handler.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import Message 2 | from aiogram_metrics import track 3 | 4 | from zmanim_bot.texts.single.messages import incorrect_text 5 | from zmanim_bot.utils import chat_action 6 | 7 | 8 | @chat_action('text') 9 | @track('Incorrect text') 10 | async def handle_incorrect_text(msg: Message): 11 | await msg.reply(incorrect_text) 12 | 13 | -------------------------------------------------------------------------------- /zmanim_bot/texts/single/helpers.py: -------------------------------------------------------------------------------- 1 | from zmanim_bot.middlewares.i18n import lazy_gettext as _ 2 | 3 | and_word = _('and') 4 | 5 | cl_error_warning = _('It is impossible to compute Zmanim for\nyour location. Most likely it is because of\n' 6 | 'Midnight Sun or polar night.') 7 | cl_late_warning = _('Notice! it is recommended to clarify time\nof taking Shabbat in your community.') 8 | 9 | cl_offset = _('before sunset') 10 | -------------------------------------------------------------------------------- /zmanim_bot/texts/commands.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import BotCommand 2 | 3 | 4 | commands = [ 5 | BotCommand('start', 'Start/Начать'), 6 | # BotCommand('help', 'Help/Помощь'), 7 | BotCommand('language', 'Language/Язык'), 8 | BotCommand('location', 'Location/Локация'), 9 | BotCommand('settings', 'Settings/Настройки'), 10 | BotCommand('report', 'Report/Сообщить о проблеме'), 11 | BotCommand('donate', 'Donate/Поддержать') 12 | ] 13 | -------------------------------------------------------------------------------- /zmanim_bot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from zmanim_bot.misc import dp 2 | from .black_list_middleware import BlackListMiddleware 3 | from .chat_type_middleware import ChatTypeMiddleware 4 | from .i18n import i18n_ 5 | from .sentry_context_middleware import SentryContextMiddleware 6 | 7 | 8 | def setup_middlewares(): 9 | dp.middleware.setup(ChatTypeMiddleware()) 10 | dp.middleware.setup(BlackListMiddleware()) 11 | dp.middleware.setup(SentryContextMiddleware()) 12 | dp.middleware.setup(i18n_) 13 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/reset_handler.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher import FSMContext 2 | 3 | from zmanim_bot.handlers.utils.redirects import redirect_to_main_menu 4 | from zmanim_bot.utils import chat_action 5 | 6 | 7 | @chat_action('text') 8 | async def handle_start(_, state: FSMContext): 9 | await state.finish() 10 | await redirect_to_main_menu() 11 | 12 | 13 | @chat_action('text') 14 | async def handle_back(_, state: FSMContext): 15 | await state.finish() 16 | await redirect_to_main_menu() 17 | -------------------------------------------------------------------------------- /zmanim_bot/middlewares/sentry_context_middleware.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | from aiogram.dispatcher.middlewares import BaseMiddleware 3 | from aiogram.types import Update 4 | 5 | 6 | class SentryContextMiddleware(BaseMiddleware): 7 | 8 | @staticmethod 9 | async def on_pre_process_update(update: Update, _): 10 | if (not update.message) and (not update.callback_query): 11 | return 12 | 13 | sentry_sdk.set_user({ 14 | 'id': (update.message or update.callback_query).from_user.id, 15 | 'update': update.to_python() 16 | }) 17 | -------------------------------------------------------------------------------- /zmanim_bot/states.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.filters.state import State, StatesGroup 2 | 3 | 4 | class ConverterGregorianDateState(StatesGroup): 5 | waiting_for_gregorian_date = State() 6 | 7 | 8 | class ConverterJewishDateState(StatesGroup): 9 | waiting_for_jewish_date = State() 10 | 11 | 12 | class ZmanimGregorianDateState(StatesGroup): 13 | waiting_for_gregorian_date = State() 14 | 15 | 16 | class FeedbackState(StatesGroup): 17 | waiting_for_feedback_text = State() 18 | waiting_for_payload = State() 19 | 20 | 21 | class LocationNameState(StatesGroup): 22 | waiting_for_location_name_state = State() 23 | -------------------------------------------------------------------------------- /zmanim_bot/integrations/open_topo_data_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import Bot 4 | 5 | from zmanim_bot.config import config 6 | 7 | OPEN_TOPO_DATA_URL = 'https://api.opentopodata.org/v1/' 8 | 9 | async def get_elevation(lat: float, lng: float) -> int: 10 | url = f'{OPEN_TOPO_DATA_URL}{config.OPEN_TOPO_DATA_DB}' 11 | params = { 12 | 'locations': f'{lat},{lng}' 13 | } 14 | session = await Bot.get_current().get_session() 15 | resp = await session.get(url, params=params) 16 | data = await resp.json() 17 | 18 | try: 19 | elevation = int(data['results'][0]['elevation']) 20 | except Exception as e: 21 | logging.exception(e) 22 | elevation = 0 23 | 24 | return elevation 25 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/utils/warnings.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import User 2 | 3 | import zmanim_bot.keyboards.menus 4 | from zmanim_bot.misc import bot 5 | from zmanim_bot.texts.single import messages 6 | 7 | __all__ = [ 8 | 'incorrect_greg_date_warning', 9 | 'incorrect_jew_date_warning' 10 | ] 11 | 12 | 13 | async def incorrect_greg_date_warning(): 14 | user_id = User.get_current().id 15 | kb = zmanim_bot.keyboards.menus.get_cancel_keyboard() 16 | await bot.send_message(user_id, messages.incorrect_greg_date, reply_markup=kb) 17 | 18 | 19 | async def incorrect_jew_date_warning(): 20 | user_id = User.get_current().id 21 | kb = zmanim_bot.keyboards.menus.get_cancel_keyboard() 22 | await bot.send_message(user_id, messages.incorrect_jew_date, reply_markup=kb) 23 | -------------------------------------------------------------------------------- /zmanim_bot/misc.py: -------------------------------------------------------------------------------- 1 | import betterlogging as bl 2 | from aiogram import Bot, Dispatcher, types 3 | from aiogram.contrib.fsm_storage.redis import RedisStorage2 4 | from motor.core import AgnosticCollection 5 | from motor.motor_asyncio import AsyncIOMotorClient 6 | from odmantic import AIOEngine 7 | 8 | from .config import config 9 | 10 | bl.basic_colorized_config(level=bl.INFO) 11 | logger = bl.getLogger('zmanim_bot') 12 | 13 | storage = RedisStorage2(host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB) 14 | bot = Bot(config.BOT_TOKEN, parse_mode=types.ParseMode.HTML) 15 | dp = Dispatcher(bot, storage=storage) 16 | loop = bot.loop 17 | 18 | 19 | motor_client = AsyncIOMotorClient(config.DB_URL) 20 | collection: AgnosticCollection = motor_client[config.DB_NAME][config.DB_COLLECTION_NAME] 21 | db_engine = AIOEngine(motor_client, database=config.DB_NAME) 22 | 23 | -------------------------------------------------------------------------------- /zmanim_bot/service/payments_service.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime as dt 2 | 3 | from aiogram.types import LabeledPrice, User 4 | 5 | from zmanim_bot.config import config 6 | from zmanim_bot.misc import bot 7 | from zmanim_bot.texts.single import messages 8 | 9 | 10 | async def init_donate(amount: int): 11 | user = User.get_current() 12 | invoice_title = messages.donate_invoice_title.value.format(amount) 13 | price = int(amount) * 100 14 | 15 | await bot.send_invoice( 16 | user.id, 17 | title=invoice_title, 18 | description=messages.donate_invoice_description.value.format(amount), 19 | payload=f'donate:{amount}:usd:{user.id}:{dt.now().isoformat()}', 20 | provider_token=config.PAYMENTS_PROVIDER_TOKEN, 21 | currency='usd', 22 | prices=[LabeledPrice(invoice_title, price)], 23 | provider_data=['qweqweqwe'] 24 | ) 25 | -------------------------------------------------------------------------------- /.github/workflows/ci_beta.yaml: -------------------------------------------------------------------------------- 1 | name: Zmanim bot beta CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - beta 7 | # pull_request: 8 | # branches: 9 | # - master 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Docker login 17 | run: docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASSWORD }} 18 | - name: Build 19 | run: docker build -t zmanim-bot -f Dockerfile . 20 | - name: Tags 21 | run: | 22 | docker tag zmanim-bot ${{ secrets.DOCKER_USER }}/zmanim-bot:${{ github.sha }} 23 | docker tag zmanim-bot ${{ secrets.DOCKER_USER }}/zmanim-bot:beta 24 | - name: Push 25 | run: | 26 | docker push ${{ secrets.DOCKER_USER }}/zmanim-bot:${{ github.sha }} 27 | docker push ${{ secrets.DOCKER_USER }}/zmanim-bot:beta 28 | -------------------------------------------------------------------------------- /.github/workflows/ci_prod.yaml: -------------------------------------------------------------------------------- 1 | name: Zmanim bot prod CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | # pull_request: 8 | # branches: 9 | # - master 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Docker login 17 | run: docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASSWORD }} 18 | - name: Build 19 | run: docker build -t zmanim-bot -f Dockerfile . 20 | - name: Tags 21 | run: | 22 | docker tag zmanim-bot ${{ secrets.DOCKER_USER }}/zmanim-bot:${{ github.sha }} 23 | docker tag zmanim-bot ${{ secrets.DOCKER_USER }}/zmanim-bot:latest 24 | - name: Push 25 | run: | 26 | docker push ${{ secrets.DOCKER_USER }}/zmanim-bot:${{ github.sha }} 27 | docker push ${{ secrets.DOCKER_USER }}/zmanim-bot:latest 28 | -------------------------------------------------------------------------------- /zmanim_bot/middlewares/chat_type_middleware.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.middlewares import BaseMiddleware 2 | from aiogram.types import Message, ChatMemberUpdated 3 | 4 | from zmanim_bot.exceptions import UnsupportedChatTypeException 5 | 6 | error = 'This bot supports only private chats! Please, remove the bot from the group!' 7 | 8 | 9 | class ChatTypeMiddleware(BaseMiddleware): 10 | 11 | @staticmethod 12 | async def on_pre_process_my_chat_member(update: ChatMemberUpdated, *_): 13 | if update.new_chat_member.status in ('member', 'restricted'): 14 | await update.bot.send_message(update.chat.id, error) 15 | raise UnsupportedChatTypeException() 16 | 17 | @staticmethod 18 | async def on_process_message(msg: Message, *_): 19 | if msg.chat.type in ('group', 'supergroup') and not msg.left_chat_member: 20 | await msg.reply(error) 21 | raise UnsupportedChatTypeException() 22 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/payments.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import CallbackQuery, PreCheckoutQuery, Message, InputFile 2 | from aiogram_metrics import track 3 | 4 | from zmanim_bot.helpers import CallbackPrefixes 5 | from zmanim_bot.misc import bot 6 | from zmanim_bot.service import payments_service 7 | from zmanim_bot.texts.single import messages 8 | from zmanim_bot.utils import chat_action 9 | 10 | 11 | @chat_action('text') 12 | @track('Init donate') 13 | async def handle_donate(call: CallbackQuery): 14 | amount = int(call.data.split(CallbackPrefixes.donate)[1]) 15 | await call.answer() 16 | await payments_service.init_donate(amount) 17 | 18 | 19 | @track('Pre-checkout') 20 | async def handle_pre_checkout(query: PreCheckoutQuery): 21 | await bot.answer_pre_checkout_query(query.id, ok=True) 22 | 23 | 24 | async def on_success_payment(msg: Message): 25 | await bot.send_photo(msg.from_user.id, InputFile('./res/on_sucess_donate.jpg'), caption=messages.donate_thanks) 26 | -------------------------------------------------------------------------------- /zmanim_bot/middlewares/black_list_middleware.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | from aiogram.dispatcher.middlewares import BaseMiddleware 3 | 4 | from zmanim_bot.exceptions import AccessDeniedException 5 | 6 | 7 | class BlackListMiddleware(BaseMiddleware): 8 | 9 | async def trigger(self, action, args): 10 | if action not in ('pre_process_message', 'pre_process_callback_query'): 11 | return 12 | 13 | match args[0]: 14 | case types.Message() as msg: 15 | chat_type = msg.chat.type 16 | case types.CallbackQuery() as call: 17 | chat_type = call.message.chat 18 | case _: 19 | return 20 | 21 | if chat_type != 'private': 22 | return 23 | 24 | from zmanim_bot.repository.bot_repository import get_or_create_user 25 | 26 | user = await get_or_create_user() 27 | if user.meta.is_banned_by_admin: 28 | raise AccessDeniedException() 29 | 30 | -------------------------------------------------------------------------------- /zmanim_bot/texts/single/zmanim.py: -------------------------------------------------------------------------------- 1 | from zmanim_bot.middlewares.i18n import lazy_gettext as _ 2 | 3 | sunrise = _('Sunrise') 4 | alos = _('Alot ha-shakhar') 5 | sof_zman_tefila_gra = _('Sof zman tfila [AGRO]') 6 | sof_zman_tefila_ma = _('Sof zman tfila [MA]') 7 | misheyakir_10_2 = _('Misheyakir (zman tallit and tfilin)') 8 | sof_zman_shema_gra = _('Sof zman Shemah [AGRO]') 9 | sof_zman_shema_ma = _('Sof zman Shemah [MA]') 10 | chatzos = _('Chatzot') 11 | mincha_ketana = _('Mincha ktanah') 12 | mincha_gedola = _('Mincha gdolah') 13 | plag_mincha = _('Plag mincha') 14 | sunset = _('Shkia (sunset)') 15 | tzeis_8_5_degrees = _('Tzeit ha-kochavim [8.5 degrees]') 16 | tzeis_72_minutes = _('Tzeit ha-kochavim [72 minutes]') 17 | tzeis_42_minutes = _('Tzeit ha-kochavim [42 minutes]') 18 | tzeis_5_95_degrees = _('Tzeit ha-kochavim [5.95 degrees]') 19 | chatzot_laila = _('Chatzot Laila (midnight)') 20 | astronomical_hour_ma = _('Astronomical hour [MA]') 21 | astronomical_hour_gra = _('Astronomical hour [AGRO]') 22 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/converter.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import Message 2 | from aiogram_metrics import track 3 | 4 | from zmanim_bot.service import converter_service 5 | from zmanim_bot.states import ConverterGregorianDateState 6 | from zmanim_bot.utils import chat_action 7 | 8 | 9 | @chat_action('text') 10 | # @track('Entry to converter_api') 11 | async def handle_converter_entry(msg: Message): 12 | resp, kb = converter_service.get_converter_entry_menu() 13 | await msg.reply(resp, reply_markup=kb) 14 | 15 | 16 | @chat_action('text') 17 | @track('Converter - gregorian -> jewish') 18 | async def start_greg_to_jew_converter(msg: Message): 19 | await ConverterGregorianDateState().waiting_for_gregorian_date.set() 20 | resp, kb = await converter_service.init_greg_to_jew() 21 | await msg.reply(resp, reply_markup=kb) 22 | 23 | 24 | @chat_action('text') 25 | @track('Converter - jewish -> gregorian') 26 | async def start_jew_to_greg_converter(msg: Message): 27 | resp, kb = await converter_service.init_jew_to_greg() 28 | await msg.reply(resp, reply_markup=kb) 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | [project] 3 | name = "" 4 | version = "" 5 | description = "" 6 | authors = [ 7 | {name = "Benyamin Ginzburg", email = "benyomin.94@gmail.com"}, 8 | ] 9 | dependencies = [ 10 | "pillow", 11 | "aiogram==2.24", 12 | "zmanim", 13 | "pydantic[dotenv]==1.10.9", 14 | "redis==4.4.1", 15 | "betterlogging", 16 | "odmantic==0.9.2", 17 | "motor", 18 | "sentry-sdk==1.13.0", 19 | "aiogram-metrics==1.0.4", 20 | "setuptools", 21 | "urllib3>=2.2.1", 22 | ] 23 | requires-python = "==3.10.7" 24 | license = {text = "-"} 25 | 26 | [tool.pdm.scripts] 27 | babel-extract = """pybabel extract 28 | #zmanim_bot/texts/plural/units.py 29 | zmanim_bot/texts/single/buttons.py 30 | zmanim_bot/texts/single/headers.py 31 | zmanim_bot/texts/single/helpers.py 32 | zmanim_bot/texts/single/messages.py 33 | zmanim_bot/texts/single/names.py 34 | zmanim_bot/texts/single/zmanim.py 35 | -o locales/zmanim_bot.pot -k __:1,2 --add-comments=NOTE""" 36 | babel-init = "pybabel init -i locales/zmanim_bot.pot -d locales -D zmanim_bot -l " 37 | babel-compile = "pybabel compile -d locales -D zmanim_bot" 38 | -------------------------------------------------------------------------------- /zmanim_bot/texts/single/headers.py: -------------------------------------------------------------------------------- 1 | from zmanim_bot.middlewares.i18n import lazy_gettext as _ 2 | 3 | date = _('Date') 4 | 5 | daf_masehet = _('Masechet') 6 | daf_page = _('Sheet') 7 | 8 | rh_duration = _('Number of days') 9 | rh_month = _('Мonth') 10 | rh_molad = _('Molad') 11 | 12 | parsha = _('Parshat ha-shavuah') 13 | cl = _('Candle Lighting') 14 | havdala = _('Havdala') 15 | 16 | fast_start = _('Fast begins') 17 | fast_end = _('Fast ends (tzeit ha-kochavim)') 18 | fast_chatzot = _('Chatzot') 19 | fast_moved = _('Attention! Fast has been moved!') 20 | fast_not_moved = _('Fast hasn’t been moved.') 21 | fast_end_5_95_dgr = _('5.95 degrees, derabanan') 22 | fast_end_8_5_dgr = _('8.5 degrees, lehumra') 23 | fast_end_42_min = _('42 minutes') 24 | 25 | hoshana_raba = _('Hoshana Rabbah') 26 | 27 | israel_holidays = { 28 | 'yom_hashoah': _('Yom ha-Shoah'), 29 | 'yom_hazikaron': _('Yom ha-Zikaron'), 30 | 'yom_haatzmaut': _('Yom ha-Atzmaut'), 31 | 'yom_yerushalaim': _('Yom Yerushalaim'), 32 | } 33 | 34 | pesach_end_eating_chametz = _('Eating chametz until') 35 | pesach_end_burning_chametz = _('Burning chametz until') 36 | -------------------------------------------------------------------------------- /zmanim_bot/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | 4 | from aiogram.types import ChatActions 5 | from pymongo import IndexModel 6 | 7 | from zmanim_bot.misc import collection 8 | from zmanim_bot.repository.bot_repository import get_or_set_processor_type 9 | 10 | 11 | def chat_action(action: str = None): 12 | def decorator(func): 13 | 14 | @functools.wraps(func) 15 | async def wrapper(*args, **kwargs): 16 | chat_actions = { 17 | 'image': ChatActions.upload_photo, 18 | 'text': ChatActions.typing 19 | } 20 | processor_type = action or await get_or_set_processor_type() 21 | action_func = chat_actions.get(action or processor_type, ChatActions.typing) 22 | await action_func() 23 | 24 | spec = inspect.getfullargspec(func) 25 | kwargs = {k: v for k, v in kwargs.items() if k in spec.args} 26 | 27 | return await func(*args, **kwargs) 28 | 29 | return wrapper 30 | return decorator 31 | 32 | 33 | async def ensure_mongo_index(): 34 | index = IndexModel('user_id', unique=True) 35 | await collection.create_indexes([index]) 36 | -------------------------------------------------------------------------------- /zmanim_bot/main.py: -------------------------------------------------------------------------------- 1 | import aiogram_metrics 2 | import sentry_sdk 3 | from aiogram.utils.executor import start_polling, start_webhook 4 | from sentry_sdk.integrations.aiohttp import AioHttpIntegration 5 | 6 | from zmanim_bot.config import config 7 | from zmanim_bot.handlers import register_handlers 8 | from zmanim_bot.middlewares import setup_middlewares 9 | from zmanim_bot.misc import dp, logger, bot 10 | from zmanim_bot.texts.commands import commands 11 | from zmanim_bot.utils import ensure_mongo_index 12 | 13 | sentry_sdk.init(dsn=config.SENTRY_KEY, integrations=[AioHttpIntegration()]) 14 | 15 | 16 | async def on_start(_): 17 | setup_middlewares() 18 | register_handlers() 19 | 20 | await bot.set_my_commands(commands) 21 | 22 | await ensure_mongo_index() 23 | 24 | if config.METRICS_DSN: 25 | await aiogram_metrics.register(config.METRICS_DSN, config.METRICS_TABLE_NAME) 26 | 27 | logger.info('Starting zmanim bot...') 28 | 29 | 30 | async def on_close(_): 31 | await aiogram_metrics.close() 32 | 33 | 34 | if __name__ == '__main__': 35 | if config.IS_PROD: 36 | start_webhook(dp, config.WEBHOOK_PATH, on_startup=on_start) 37 | else: 38 | start_polling(dp, on_startup=on_start, skip_updates=True) 39 | -------------------------------------------------------------------------------- /zmanim_bot/exceptions.py: -------------------------------------------------------------------------------- 1 | class ZmanimBotBaseException(Exception): 2 | ... 3 | 4 | 5 | class NoLanguageException(ZmanimBotBaseException): 6 | ... 7 | 8 | 9 | class NoLocationException(ZmanimBotBaseException): 10 | ... 11 | 12 | 13 | class IncorrectLocationException(ZmanimBotBaseException): 14 | ... 15 | 16 | 17 | class IncorrectTextException(ZmanimBotBaseException): 18 | ... 19 | 20 | 21 | class IncorrectGregorianDateException(ZmanimBotBaseException): 22 | ... 23 | 24 | 25 | class IncorrectJewishDateException(ZmanimBotBaseException): 26 | ... 27 | 28 | 29 | class NonUniqueLocationException(ZmanimBotBaseException): 30 | ... 31 | 32 | 33 | class NonUniqueLocationNameException(ZmanimBotBaseException): 34 | ... 35 | 36 | 37 | class MaxLocationLimitException(ZmanimBotBaseException): 38 | ... 39 | 40 | 41 | class ActiveLocationException(ZmanimBotBaseException): 42 | ... 43 | 44 | 45 | class PolarCoordinatesException(ZmanimBotBaseException): 46 | ... 47 | 48 | 49 | class UnknownProcessorException(ZmanimBotBaseException): 50 | ... 51 | 52 | 53 | class AccessDeniedException(ZmanimBotBaseException): 54 | ... 55 | 56 | 57 | class UnsupportedChatTypeException(ZmanimBotBaseException): 58 | ... 59 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/menus.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import Message 2 | from aiogram_metrics import track 3 | 4 | from zmanim_bot.keyboards import menus 5 | from zmanim_bot.keyboards.inline import DONATE_KB 6 | from zmanim_bot.texts.single import messages 7 | from zmanim_bot.utils import chat_action 8 | 9 | 10 | @chat_action('text') 11 | @track('Holidays menu') 12 | async def handle_holidays_menu(msg: Message): 13 | kb = menus.get_holidays_menu() 14 | await msg.reply(messages.select, reply_markup=kb) 15 | 16 | 17 | @chat_action('text') 18 | # @track('More holidays menu') 19 | async def handle_more_holidays_menu(msg: Message): 20 | kb = menus.get_more_holidays_menu() 21 | await msg.reply(messages.select, reply_markup=kb) 22 | 23 | 24 | @chat_action('text') 25 | # @track('Fasts menu') 26 | async def handle_fasts_menu(msg: Message): 27 | kb = menus.get_fast_menu() 28 | await msg.reply(messages.select, reply_markup=kb) 29 | 30 | 31 | @chat_action('text') 32 | # @track('Settings menu') 33 | async def handle_settings_menu(msg: Message): 34 | kb = menus.get_settings_menu() 35 | await msg.reply(messages.init_settings, reply_markup=kb) 36 | 37 | 38 | @chat_action('text') 39 | @track('Donate button') 40 | async def handle_donate(msg: Message): 41 | await msg.reply(messages.donate_init, reply_markup=DONATE_KB) 42 | -------------------------------------------------------------------------------- /zmanim_bot/middlewares/i18n.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Tuple 3 | 4 | from aiogram import types 5 | from aiogram.contrib.middlewares.i18n import I18nMiddleware 6 | from aiogram.types import Message 7 | 8 | from zmanim_bot.config import config 9 | 10 | LOCALES_DIR = Path(__file__).parent.parent.parent / 'locales' 11 | 12 | 13 | class I18N(I18nMiddleware): 14 | async def get_user_locale(self, action: str, args: Tuple[Any]) -> str: 15 | if isinstance(args[0], Message) and args[0].chat.type == 'channel': 16 | return '' 17 | 18 | if len(args) > 0 and isinstance(args[0], Message) and args[0].text in config.LANGUAGE_LIST: 19 | return args[0].text 20 | 21 | match args[0]: 22 | case types.Message() as msg if msg.chat.type != 'private': 23 | return '' 24 | case types.CallbackQuery() as call if call.message.chat.type != 'private': 25 | return '' 26 | case _: 27 | from zmanim_bot.repository.bot_repository import get_or_set_lang 28 | return await get_or_set_lang() 29 | 30 | def is_rtl(self) -> bool: 31 | return self.ctx_locale.get() == 'he' 32 | 33 | 34 | i18n_ = I18N(config.I18N_DOMAIN, LOCALES_DIR) 35 | gettext = i18n_.gettext 36 | lazy_gettext = i18n_.lazy_gettext 37 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/utils/redirects.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import User 2 | 3 | from zmanim_bot.keyboards import menus, inline 4 | from zmanim_bot.misc import bot 5 | from zmanim_bot.texts.single import messages 6 | 7 | __all__ = [ 8 | 'redirect_to_main_menu', 9 | 'redirect_to_settings_menu', 10 | 'redirect_to_request_location', 11 | 'redirect_to_request_language', 12 | ] 13 | 14 | 15 | async def redirect_to_main_menu(text: str = None): 16 | user_id = User.get_current().id 17 | kb = menus.get_main_menu() 18 | await bot.send_message(user_id, text or messages.init_main_menu, reply_markup=kb) 19 | 20 | 21 | async def redirect_to_settings_menu(text: str = None): 22 | user_id = User.get_current().id 23 | kb = menus.get_settings_menu() 24 | await bot.send_message(user_id, text or messages.init_main_menu, reply_markup=kb) 25 | 26 | 27 | async def redirect_to_request_location(): 28 | user_id = User.get_current().id 29 | kb = inline.LOCATION_SEARCH_KB 30 | await bot.send_message(user_id, messages.request_location_on_init, reply_markup=kb) 31 | 32 | 33 | async def redirect_to_request_language(): 34 | user_id = User.get_current().id 35 | kb = menus.get_lang_menu() 36 | resp = messages.request_language.value 37 | if resp == 'request language': 38 | resp = 'Select your language:' 39 | await bot.send_message(user_id, resp, reply_markup=kb) 40 | -------------------------------------------------------------------------------- /zmanim_bot/helpers.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import Tuple 3 | 4 | from zmanim_bot.exceptions import IncorrectGregorianDateException, IncorrectLocationException 5 | 6 | LOCATION_PATTERN = r'^-?\d{1,2}\.{1}\d+, {0,1}-?\d{1,3}\.{1}\d+$' 7 | LANGUAGE_SHORTCUTS = { 8 | 'English': 'en', 9 | 'Русский': 'ru' 10 | } 11 | CALL_ANSWER_OK = '✅' 12 | 13 | CL_OFFET_OPTIONS = [10, 15, 18, 20, 22, 30, 40] 14 | HAVDALA_OPINION_OPTIONS = [ 15 | 'tzeis_5_95_degrees', 16 | 'tzeis_8_5_degrees', 17 | 'tzeis_42_minutes', 18 | 'tzeis_72_minutes', 19 | ] 20 | 21 | 22 | class CallbackPrefixes: 23 | cl = 'cl:' 24 | zmanim = 'zmanim_api:' 25 | havdala = 'havdala:' 26 | report = 'report:' 27 | zmanim_by_date = 'zbd:' 28 | omer = 'omer:' 29 | format = 'format:' 30 | location_activate = 'loc_a:' 31 | location_rename = 'loc_r:' 32 | location_delete = 'loc_d:' 33 | location_namage = 'loc_m:' 34 | location_add = 'loc_add:' 35 | location_menu_back = 'loc_back' 36 | donate = 'donate:' 37 | 38 | update_zmanim = 'uz:' 39 | update_shabbat = 'us:' 40 | update_fast = 'uf:' 41 | update_yom_tov = 'uy:' 42 | 43 | 44 | def parse_coordinates(coordinates: str) -> Tuple[float, float]: 45 | try: 46 | lat, lng = map(float, coordinates.split(',')) 47 | except ValueError: 48 | raise IncorrectLocationException('Incorrect location format!') 49 | 50 | if lng > 180.0 or lng < -180.0 or lat > 90.0 or lat < -90.0: 51 | raise IncorrectLocationException('Incorrect location value!') 52 | 53 | return lat, lng 54 | 55 | 56 | def check_date(date_: str): 57 | try: 58 | date.fromisoformat(date_) 59 | except ValueError: 60 | raise IncorrectGregorianDateException 61 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/festivals.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher import FSMContext 2 | from aiogram.types import Message, CallbackQuery 3 | from aiogram_metrics import track 4 | 5 | from zmanim_bot.helpers import CallbackPrefixes 6 | from zmanim_bot.service import festivals_service 7 | from zmanim_bot.utils import chat_action 8 | 9 | 10 | @chat_action() 11 | async def handle_fast(msg: Message, state: FSMContext): 12 | await state.update_data({'current_fast': msg.text}) 13 | await festivals_service.get_generic_fast(msg.text) 14 | 15 | 16 | async def handle_fast_update(call: CallbackQuery, state: FSMContext): 17 | coordinates = call.data.split(CallbackPrefixes.update_fast)[1] 18 | lat, lng = map(float, coordinates.split(',')) 19 | await state.update_data({'current_location': [lat, lng]}) 20 | 21 | current_fast_name = (await state.get_data()).get('current_fast') 22 | if not current_fast_name: 23 | return await call.answer('Current fast is not set') 24 | 25 | await festivals_service.update_generic_fast(current_fast_name, lat, lng) 26 | await call.answer() 27 | 28 | 29 | @chat_action() 30 | async def handle_yom_tov(msg: Message, state: FSMContext): 31 | await state.update_data({'current_yom_tov': msg.text}) 32 | await festivals_service.get_generic_yomtov(msg.text) 33 | 34 | 35 | async def handle_yom_tov_update(call: CallbackQuery, state: FSMContext): 36 | coordinates = call.data.split(CallbackPrefixes.update_yom_tov)[1] 37 | lat, lng = map(float, coordinates.split(',')) 38 | await state.update_data({'current_location': [lat, lng]}) 39 | 40 | current_yom_tov_name = (await state.get_data()).get('current_yom_tov') 41 | if not current_yom_tov_name: 42 | return await call.answer('Current yom tov is not set') 43 | 44 | await festivals_service.update_generic_yom_tov(current_yom_tov_name, lat, lng) 45 | await call.answer() 46 | 47 | 48 | @chat_action() 49 | @track('Holiday') 50 | async def handle_holiday(msg: Message): 51 | await festivals_service.get_generic_holiday(msg.text) 52 | -------------------------------------------------------------------------------- /zmanim_bot/integrations/geo_client.py: -------------------------------------------------------------------------------- 1 | from hashlib import md5 2 | from typing import List 3 | 4 | from aiogram.types import InlineQueryResultLocation 5 | 6 | from zmanim_bot.config import config 7 | from zmanim_bot.misc import bot 8 | 9 | 10 | async def get_location_name(lat: float, lng: float, locality: str, *, no_trim: bool = False) -> str: 11 | params = { 12 | 'latitude': lat, 13 | 'longitude': lng, 14 | 'localityLanguage': locality 15 | } 16 | async with (await bot.get_session()).get(config.GEO_API_URL, params=params) as resp: 17 | raw_resp: dict = await resp.json() 18 | city = raw_resp.get('city') 19 | locality = raw_resp.get('locality') 20 | 21 | if city and locality and city != locality: 22 | name = f'{city}, {locality}' 23 | if not no_trim: 24 | name = name[:30] 25 | 26 | elif city or locality: 27 | name = (city or locality) 28 | if not no_trim: 29 | name = name[:30] 30 | 31 | else: 32 | name = f'{lat:.3f}, {lng:.3f}' 33 | return name 34 | 35 | 36 | async def find_places_by_query(query: str, language: str) -> List[InlineQueryResultLocation]: 37 | url = f'https://api.mapbox.com/geocoding/v5/mapbox.places/{query}.json?' 38 | params = { 39 | 'access_token': config.MAPBOX_API_KEY, 40 | 'language': language, 41 | 'types': 'place,locality,neighborhood,address' # ,poi 42 | } 43 | results = [] 44 | 45 | async with (await bot.get_session()).get(url, params=params) as resp: 46 | raw_resp: dict = await resp.json() 47 | 48 | for feature in raw_resp.get('features', []): 49 | lng, lat = feature.get('center', (None, None)) 50 | title: str = feature.get('place_name') 51 | 52 | if not all((lat, lng, title)): 53 | continue 54 | 55 | place_id = md5(title.encode()).hexdigest() 56 | results.append(InlineQueryResultLocation(id=place_id, latitude=lat, longitude=lng, title=title)) 57 | 58 | return results 59 | -------------------------------------------------------------------------------- /zmanim_bot/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings, Field, validator 2 | 3 | 4 | class Config(BaseSettings): 5 | 6 | class Config: 7 | env_file = '.env' 8 | env_file_encoding = 'utf-8' 9 | 10 | I18N_DOMAIN: str = Field('zmanim_bot') 11 | BOT_TOKEN: str = Field(env='BOT_TOKEN') 12 | 13 | IS_PROD: bool = Field(False, env='IS_PROD') 14 | WEBHOOK_PATH: str = Field('/zmanim_bot', env='WEBHOOK_PATH') 15 | 16 | LANGUAGE_LIST: list[str] = Field(['English', 'Русский', 'עברית']) 17 | LANGUAGE_SHORTCUTS: dict[str, str] = Field({ 18 | 'English': 'en', 19 | 'Русский': 'ru', 20 | 'עברית': 'he' 21 | }) 22 | 23 | DB_URL: str = Field('localhost', env='DB_URL') 24 | DB_NAME: str = Field(env='DB_NAME') 25 | DB_COLLECTION_NAME: str = Field(env='DB_COLLECTION_NAME') 26 | 27 | REDIS_HOST: str = Field(env='REDIS_HOST') 28 | REDIS_PORT: int = Field(env='REDIS_PORT') 29 | REDIS_DB: int = Field(env='REDIS_DB') 30 | 31 | ZMANIM_API_URL: str = Field(env='ZMANIM_API_URL') 32 | GEO_API_URL: str = Field(env='GEO_API_URL') 33 | MAPBOX_API_KEY: str = Field(env='MAPBOX_API_KEY') 34 | 35 | REPORT_ADMIN_LIST: list[int] = Field(env='REPORT_ADMIN_LIST') 36 | 37 | LOCATION_NUMBER_LIMIT: int = Field(5, env='LOCATION_NUMBER_LIMIT') 38 | SENTRY_KEY: str | None = Field(env='SENTRY_PUBLIC_KEY') 39 | 40 | METRICS_DSN: str | None = Field(env='METRICS_DSN') 41 | METRICS_TABLE_NAME: str | None = Field(env='METRICS_TABLE_NAME') 42 | 43 | PAYMENTS_PROVIDER_TOKEN: str = Field(env='PAYMENTS_PROVIDER_TOKEN') 44 | DONATE_OPTIONS: list[int] = Field([2, 5, 10, 25, 50]) 45 | 46 | OPEN_TOPO_DATA_DB: str = Field(..., env='OPEN_TOPO_DATA_DB') 47 | 48 | @validator('REPORT_ADMIN_LIST', pre=True) 49 | def parse_list(cls, report_admin_list): 50 | if isinstance(report_admin_list, int): 51 | return [report_admin_list] 52 | if isinstance(report_admin_list, str): 53 | return [int(i.strip()) for i in report_admin_list.split(',')] 54 | return report_admin_list 55 | 56 | 57 | config = Config() 58 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/admin.py: -------------------------------------------------------------------------------- 1 | from asyncio.tasks import create_task 2 | 3 | from aiogram.dispatcher import FSMContext 4 | from aiogram.types import CallbackQuery, ContentType, Message 5 | 6 | from zmanim_bot.admin.report_management import send_response_to_user 7 | from zmanim_bot.admin.states import AdminReportResponse 8 | from zmanim_bot.handlers.utils.redirects import redirect_to_main_menu 9 | from zmanim_bot.keyboards.menus import get_cancel_keyboard, get_report_keyboard 10 | from zmanim_bot.misc import bot 11 | from zmanim_bot.texts.single import messages 12 | 13 | 14 | async def handle_report(call: CallbackQuery, state: FSMContext): 15 | _, msg_id, user_id = call.data.split(':') 16 | report_data = { 17 | 'message_id': msg_id, 18 | 'user_id': user_id, 19 | 'media_ids': [] 20 | } 21 | 22 | await AdminReportResponse().waiting_for_response_text.set() 23 | await state.set_data(report_data) 24 | 25 | resp = 'Write your response:' 26 | kb = get_cancel_keyboard() 27 | 28 | await bot.send_message(call.from_user.id, resp, reply_markup=kb) 29 | await call.answer() 30 | 31 | 32 | async def handle_report_response(msg: Message, state: FSMContext): 33 | response_data = await state.get_data() 34 | response_data['response'] = msg.text 35 | await state.set_data(response_data) 36 | await AdminReportResponse.next() 37 | 38 | resp = 'Attach media (scrinshots or video)' 39 | kb = get_report_keyboard() 40 | await msg.reply(resp, reply_markup=kb) 41 | 42 | 43 | async def handle_done_report(_, state: FSMContext): 44 | response = await state.get_data() 45 | await state.finish() 46 | await redirect_to_main_menu('Succesfully sent') 47 | create_task(send_response_to_user(response)) 48 | 49 | 50 | async def handle_report_payload(msg: Message, state: FSMContext): 51 | if msg.content_type not in (ContentType.PHOTO, ContentType.VIDEO): 52 | return await msg.reply(messages.reports_incorrect_media_type) 53 | 54 | response = await state.get_data() 55 | 56 | if msg.photo: 57 | response['media_ids'].append((msg.photo[-1].file_id, 'photo')) 58 | if msg.video: 59 | response['media_ids'].append((msg.video.file_id, 'video')) 60 | 61 | await state.set_data(response) 62 | await msg.reply(messages.reports_media_received) 63 | 64 | -------------------------------------------------------------------------------- /zmanim_bot/texts/single/buttons.py: -------------------------------------------------------------------------------- 1 | from zmanim_bot.middlewares.i18n import lazy_gettext as _ 2 | 3 | # Service 4 | cancel = _('Cancel') 5 | back = _('Back') 6 | done = _('Done') 7 | 8 | # location 9 | geobutton = _('Send location') 10 | search_location = _('Find location by name') 11 | manage_locations = _('Manage saved locations') 12 | add_location = _('Add new location') 13 | 14 | 15 | # Main menu 16 | mm_zmanim = _('Zmanim') 17 | mm_shabbat = _('Shabbat') 18 | mm_holidays = _('Holidays') 19 | mm_daf_yomi = _('Daf yomi') 20 | mm_rh = _('Rosh chodesh') 21 | mm_fasts = _('Fast days') 22 | mm_zmanim_by_date = _('Zmanim by the date') 23 | mm_converter = _('Date converter') 24 | mm_help = _('Help') 25 | mm_settings = _('Settings') 26 | mm_donate = _('Donate') 27 | 28 | 29 | # Help menu 30 | hm_faq = _('F.A.Q.') 31 | hm_report = _('Report a problem') 32 | 33 | 34 | # Settings menu 35 | sm_zmanim = _('Select zmanim') 36 | sm_candle = _('Candle lighting') 37 | sm_havdala = _('Havdala') 38 | sm_lang = _('Language') 39 | sm_location = _('Location') 40 | sm_omer = _('Omer count') 41 | sm_format = _('Format') 42 | sm_format_text_option = _('Text') 43 | sm_format_image_option = _('Picture') 44 | 45 | processor_types = { 46 | 'image': sm_format_image_option, 47 | 'text': sm_format_text_option, 48 | } 49 | 50 | settings_enabled = _('enabled') 51 | settings_disabled = _('disabled') 52 | 53 | # Holidays 54 | hom_more = _('More...') 55 | hom_main = _('Main holidays') 56 | 57 | hom_rosh_hashana = _('Rosh ha-Shanah') 58 | hom_yom_kippur = _('Yom Kippur') 59 | hom_succot = _('Succot') 60 | hom_shmini_atzeret = _('Shmini Atzeres') 61 | hom_chanukah = _('Chanukah') 62 | hom_purim = _('Purim') 63 | hom_pesach = _('Pesach') 64 | hom_shavuot = _('Shavuot') 65 | hom_tu_bishvat = _('Tu bi-Shvat') 66 | hom_lag_baomer = _('Lag ba-Omer') 67 | hom_israel = _('Israel holidays') 68 | 69 | 70 | # Fasts 71 | fm_gedaliah = _('Fast of Gedaliah') 72 | fm_tevet = _('10th of Tevet') 73 | fm_esther = _('Fast of Ester') 74 | fm_tammuz = _('17th of Tammuz') 75 | fm_av = _('9th of Av') 76 | 77 | YOMTOVS = [hom_rosh_hashana, hom_yom_kippur, hom_succot, hom_shmini_atzeret, hom_pesach, hom_shavuot] 78 | HOLIDAYS = [hom_chanukah, hom_purim, hom_tu_bishvat, hom_lag_baomer, hom_israel] 79 | FASTS = [fm_gedaliah, fm_tevet, fm_esther, fm_tammuz, fm_av] 80 | 81 | 82 | # converter 83 | conv_greg_to_jew = _('Gregorian ➡ Jewish') 84 | conv_jew_to_greg = _('Jewish ➡ Gregorian') 85 | 86 | # NOTE prefix for button "Zmanim for {date}" 87 | zmanim_for_date_prefix = _('Zmanim for') 88 | -------------------------------------------------------------------------------- /zmanim_bot/service/converter_service.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import Tuple 3 | 4 | from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup 5 | from zmanim.hebrew_calendar.jewish_calendar import JewishCalendar 6 | 7 | from zmanim_bot import keyboards 8 | from zmanim_bot.exceptions import (IncorrectGregorianDateException, IncorrectJewishDateException) 9 | from zmanim_bot.keyboards.inline import get_zmanim_by_date_buttons 10 | from zmanim_bot.states import (ConverterGregorianDateState, ConverterJewishDateState) 11 | from zmanim_bot.texts.single import messages 12 | from zmanim_bot.texts.single.names import (JEWISH_MONTHS_GENITIVE, MONTH_NAMES_GENETIVE, WEEKDAYS) 13 | 14 | 15 | def get_converter_entry_menu() -> Tuple[str, ReplyKeyboardMarkup]: 16 | kb = keyboards.menus.get_converter_menu() 17 | return messages.init_converter, kb 18 | 19 | 20 | async def init_greg_to_jew() -> Tuple[str, ReplyKeyboardMarkup]: 21 | await ConverterGregorianDateState().waiting_for_gregorian_date.set() 22 | kb = keyboards.menus.get_cancel_keyboard() 23 | return messages.greg_date_request, kb 24 | 25 | 26 | async def init_jew_to_greg() -> Tuple[str, ReplyKeyboardMarkup]: 27 | await ConverterJewishDateState().waiting_for_jewish_date.set() 28 | kb = keyboards.menus.get_cancel_keyboard() 29 | return messages.jew_date_request, kb 30 | 31 | 32 | def convert_heb_to_greg(date_: str) -> Tuple[str, InlineKeyboardMarkup]: 33 | try: 34 | year, month, day = map(int, date_.split('-')) 35 | except ValueError: 36 | raise IncorrectJewishDateException 37 | 38 | try: 39 | calendar = JewishCalendar(year, month, day) 40 | except ValueError: 41 | raise IncorrectJewishDateException 42 | gr_date = calendar.gregorian_date 43 | resp = f'{gr_date.day} {MONTH_NAMES_GENETIVE[gr_date.month]} {gr_date.year}, {WEEKDAYS[gr_date.weekday()]}' 44 | 45 | kb = get_zmanim_by_date_buttons([gr_date]) 46 | return resp, kb 47 | 48 | 49 | def convert_greg_to_heb(date_: str) -> Tuple[str, InlineKeyboardMarkup]: 50 | try: 51 | pydate = date.fromisoformat(date_) 52 | except ValueError: 53 | raise IncorrectGregorianDateException 54 | 55 | calendar = JewishCalendar.from_date(pydate) 56 | jewish_date = f'{calendar.jewish_day} {JEWISH_MONTHS_GENITIVE[calendar.jewish_month_name()]} {calendar.jewish_year},' \ 57 | f' {WEEKDAYS[calendar.gregorian_date.weekday()]}' 58 | kb = get_zmanim_by_date_buttons([pydate]) 59 | return jewish_date, kb 60 | 61 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/main.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher import FSMContext 2 | from aiogram.types import CallbackQuery, Message, ChatActions 3 | from aiogram_metrics import track 4 | 5 | from zmanim_bot.helpers import CallbackPrefixes 6 | from zmanim_bot.keyboards.menus import get_cancel_keyboard 7 | from zmanim_bot.repository.bot_repository import get_or_set_processor_type 8 | from zmanim_bot.service import zmanim_service 9 | from zmanim_bot.texts.single import messages 10 | from zmanim_bot.utils import chat_action 11 | 12 | 13 | @track('Zmanim') 14 | async def handle_zmanim(_, state: FSMContext): 15 | chat_actions = { # TODO refactor `@chat_action` to work with `@track` 16 | 'image': ChatActions.upload_photo, 17 | 'text': ChatActions.typing 18 | } 19 | processor_type = await get_or_set_processor_type() 20 | await chat_actions.get(processor_type, 'text')() 21 | 22 | await zmanim_service.send_zmanim(state=state) 23 | 24 | 25 | @track('Zmanim geo-variant') 26 | async def handle_update_zmanim(call: CallbackQuery): 27 | await call.answer() 28 | coordinates = call.data.split(CallbackPrefixes.update_zmanim)[1] 29 | 30 | if ':' in coordinates: 31 | coordinates, date = coordinates.split(':') 32 | else: 33 | date = None 34 | 35 | lat, lng = map(float, coordinates.split(',')) 36 | await zmanim_service.update_zmanim(lat, lng, date) 37 | 38 | 39 | @chat_action() 40 | async def handle_zmanim_by_date_callback(call: CallbackQuery, state: FSMContext): 41 | await zmanim_service.send_zmanim(call=call, state=state) 42 | await call.answer() 43 | 44 | 45 | @chat_action() 46 | @track('Zmanim by date') 47 | async def handle_zmanim_by_date(msg: Message): 48 | await zmanim_service.init_zmanim_by_date() 49 | await msg.reply(messages.greg_date_request, reply_markup=get_cancel_keyboard()) 50 | 51 | 52 | @chat_action() 53 | @track('Shabbat') 54 | async def handle_shabbat(_): 55 | await zmanim_service.get_shabbat() 56 | 57 | 58 | @track('Shabbat geo-variant') 59 | async def handle_update_shabbat(call: CallbackQuery, state: FSMContext): 60 | coordinates = call.data.split(CallbackPrefixes.update_shabbat)[1] 61 | lat, lng = map(float, coordinates.split(',')) 62 | await zmanim_service.update_shabbat(lat, lng, state) 63 | await call.answer() 64 | 65 | 66 | @chat_action() 67 | @track('Daf yomi') 68 | async def handle_daf_yomi(_): 69 | await zmanim_service.get_daf_yomi() 70 | 71 | 72 | @chat_action() 73 | @track('Rosh chodesh') 74 | async def handle_rosh_chodesh(_): 75 | await zmanim_service.get_rosh_chodesh() 76 | -------------------------------------------------------------------------------- /zmanim_bot/processors/text_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import date, time, datetime as dt 2 | from typing import List, Union 3 | 4 | from zmanim.hebrew_calendar.jewish_date import JewishDate 5 | 6 | from zmanim_bot.integrations.zmanim_models import AsurBeMelachaDay 7 | from zmanim_bot.texts.single import names 8 | from zmanim_bot.texts.single.names import JEWISH_MONTHS_GENITIVE 9 | 10 | 11 | def humanize_date(date_range: List[Union[date, AsurBeMelachaDay]], 12 | weekday_on_new_line: bool = False) -> str: 13 | """ 14 | Use this function for humanize date or date range 15 | Examples: 16 | 2020-01-01 -> 1 January 2020, Wednesday 17 | 2020-01-01, 2020-01-08 -> 1—7 January 2020, Wednesday-Tuesday 18 | 2020-01-31, 2020-02-1 -> 31 January—1 February 2020, Friday-Saturday 19 | 2019-12-25, 2020-01-03 -> 25 December 2019—3 January 2020, Wednesday-Friday 20 | """ 21 | weekday_sep = '\n' if weekday_on_new_line else ' ' 22 | months = names.MONTH_NAMES_GENETIVE 23 | weekdays = names.WEEKDAYS 24 | 25 | d1 = date_range[0] 26 | d2 = date_range[1] if (len(date_range) > 1) and (date_range[0] != date_range[1]) else None 27 | 28 | if isinstance(d1, AsurBeMelachaDay): 29 | d1 = d1.date 30 | if isinstance(d2, AsurBeMelachaDay): 31 | d2 = d2.date 32 | 33 | if not d2: 34 | d = d1 35 | resp = f'{d.day} {months[d.month]} {d.year},{weekday_sep}{weekdays[d.weekday()]}' 36 | 37 | elif d1.year != d2.year: 38 | resp = f'{d1.day} {months[d1.month]} {d1.year} — ' \ 39 | f'{d2.day} {months[d2.month]} {d2.year}, ' \ 40 | f'{weekdays[d1.weekday()]}-{weekdays[d2.weekday()]}' 41 | 42 | elif d1.month != d2.month: 43 | resp = f'{d1.day} {months[d1.month]} — {d2.day} {months[d2.month]} {d1.year},{weekday_sep}' \ 44 | f'{weekdays[d1.weekday()]}-{weekdays[d2.weekday()]}' 45 | 46 | else: 47 | resp = f'{d1.day}-{d2.day} {months[d1.month]} {d1.year},{weekday_sep}' \ 48 | f'{weekdays[d1.weekday()]}-{weekdays[d2.weekday()]}' 49 | 50 | return resp 51 | 52 | 53 | def humanize_time(time_or_dt: Union[time, dt]) -> str: 54 | """ Use this function for convert time to hh:mm """ 55 | if isinstance(time_or_dt, time): 56 | return time_or_dt.isoformat(timespec='minutes') 57 | if isinstance(time_or_dt, dt): 58 | return time_or_dt.time().isoformat(timespec='minutes') 59 | 60 | 61 | def parse_jewish_date(date_str: str) -> str: 62 | year, month, day = date_str.split('-') 63 | return f'{day} {JEWISH_MONTHS_GENITIVE.get(list(JewishDate.MONTHS)[int(month) - 1].name)} {year}' 64 | -------------------------------------------------------------------------------- /zmanim_bot/processors/text/text_processor.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from aiogram import Bot 4 | from aiogram.types import InlineKeyboardMarkup, User, CallbackQuery, Message 5 | 6 | from zmanim_bot.integrations.zmanim_models import DafYomi, RoshChodesh, IsraelHolidays, Holiday, \ 7 | Fast, YomTov, Shabbat, Zmanim 8 | from zmanim_bot.keyboards import inline 9 | from zmanim_bot.processors import BaseProcessor 10 | from zmanim_bot.processors.text import composer 11 | 12 | 13 | class TextProcessor(BaseProcessor[str]): 14 | 15 | _type = 'text' 16 | 17 | async def _send(self, text: str, *kb_list: InlineKeyboardMarkup): 18 | bot = Bot.get_current() 19 | user = User.get_current() 20 | call = CallbackQuery.get_current() 21 | message = Message.get_current() 22 | kb_list = [kb for kb in kb_list if kb] # clean from None 23 | 24 | if call: 25 | reply_to = call.message.message_id 26 | elif message: 27 | reply_to = message and message.message_id 28 | else: 29 | reply_to = None 30 | 31 | if len(kb_list) == 0: 32 | kb = None 33 | elif len(kb_list) == 1: 34 | kb = kb_list[0] 35 | else: 36 | kb = inline.merge_inline_keyboards(kb_list[0], kb_list[1]) 37 | 38 | await bot.send_message(user.id, text, reply_markup=kb, reply_to_message_id=reply_to) 39 | 40 | async def _update(self, text: str, *kb_list: InlineKeyboardMarkup): 41 | bot = Bot.get_current() 42 | user = User.get_current() 43 | call = CallbackQuery.get_current() 44 | kb_list = [kb for kb in kb_list if kb] # clean from None 45 | 46 | if len(kb_list) == 0: 47 | kb = None 48 | elif len(kb_list) == 1: 49 | kb = kb_list[0] 50 | else: 51 | kb = inline.merge_inline_keyboards(kb_list[0], kb_list[1]) 52 | 53 | await bot.edit_message_text(text, user.id, call.message.message_id, reply_markup=kb) 54 | 55 | def _get_zmanim(self, data: Zmanim) -> str: 56 | return composer.compose_zmanim(data, self._location_name) 57 | 58 | def _get_shabbat(self, data: Shabbat) -> Tuple[str, InlineKeyboardMarkup]: 59 | return composer.compose_shabbat(data, self._location_name) 60 | 61 | def _get_yom_tov(self, data: YomTov) -> Tuple[str, InlineKeyboardMarkup]: 62 | return composer.compose_yom_tov(data, self._location_name) 63 | 64 | def _get_fast(self, data: Fast) -> Tuple[str, InlineKeyboardMarkup]: 65 | return composer.compose_fast(data, self._location_name) 66 | 67 | def _get_holiday(self, data: Holiday) -> str: 68 | return composer.compose_holiday(data) 69 | 70 | def _get_israel_holidays(self, data: IsraelHolidays) -> str: 71 | return composer.compose_israel_holidays(data) 72 | 73 | def _get_rosh_chodesh(self, data: RoshChodesh) -> str: 74 | return composer.compose_rosh_chodesh(data) 75 | 76 | def _get_daf_yomi(self, data: DafYomi) -> str: 77 | return composer.compose_daf_yomi(data) 78 | -------------------------------------------------------------------------------- /zmanim_bot/repository/bot_repository.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional, Tuple 2 | 3 | from aiogram.types import User 4 | 5 | from ._storage import * 6 | from .models import Location 7 | from .models import User as BotUser 8 | 9 | __all__ = [ 10 | 'Location', 11 | 'get_or_create_user', 12 | 'get_or_set_zmanim', 13 | 'get_or_set_cl', 14 | 'get_or_set_lang', 15 | 'get_or_set_havdala', 16 | 'get_or_set_location', 17 | 'set_location_name', 18 | 'activate_location', 19 | 'delete_location', 20 | 'get_or_set_processor_type', 21 | 'get_or_set_omer_flag', 22 | ] 23 | 24 | 25 | async def get_or_create_user() -> BotUser: 26 | user = User.get_current() 27 | return await _get_or_create_user(user) 28 | 29 | 30 | async def get_or_set_lang(lang: str = None) -> Optional[str]: 31 | user = User.get_current() 32 | return await get_lang(user) if not lang else await set_lang(user, lang) 33 | 34 | 35 | async def get_or_set_location(location: Tuple[float, float] = None) -> Location: 36 | user = User.get_current() 37 | return await get_location(user) if not location else await set_location(user, location) 38 | 39 | 40 | async def set_location_name(new_name: str, old_name: str) -> List[Location]: 41 | user = User.get_current() 42 | return await do_set_location_name(user, new_name=new_name, old_name=old_name) 43 | 44 | 45 | async def activate_location(name: str) -> List[Location]: 46 | user = User.get_current() 47 | return await do_activate_location(user, name) 48 | 49 | 50 | async def delete_location(name: str) -> List[Location]: 51 | user = User.get_current() 52 | return await do_delete_location(user, name) 53 | 54 | 55 | async def get_or_set_cl(cl: int = None) -> Optional[int]: 56 | user = User.get_current() 57 | return await get_cl_offset(user) if not cl else await set_cl(user, cl) 58 | 59 | 60 | async def get_or_set_zmanim(zmanim: dict = None) -> Optional[dict]: 61 | user = User.get_current() 62 | return await get_zmanim(user) if not zmanim else await set_zmanim(user, zmanim) 63 | 64 | 65 | async def get_or_set_havdala(havdala: str = None) -> Optional[str]: 66 | user = User.get_current() 67 | return await get_havdala(user) if not havdala else await set_havdala(user, havdala) 68 | 69 | 70 | async def get_or_set_processor_type(processor_type: Optional[str] = None) -> Optional[str]: 71 | user = User.get_current() 72 | return await get_processor_type(user) if not processor_type \ 73 | else await set_processor_type(user, processor_type) 74 | 75 | 76 | async def get_or_set_omer_flag( 77 | omer_flag: Optional[bool] = None, 78 | zmanim: Optional[Any] = None # todo circullar import problem, refactor needed 79 | ) -> Optional[bool]: 80 | user = User.get_current() 81 | if omer_flag is not None: 82 | omer_time = None 83 | if omer_flag: 84 | omer_time = zmanim and zmanim.tzeis_8_5_degrees.isoformat() 85 | 86 | return await set_omer_flag(user, omer_flag, omer_time) 87 | return await get_omer_flag(user) 88 | -------------------------------------------------------------------------------- /broadcast/broadcast.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Tuple 4 | 5 | from aiogram import Bot, Dispatcher, types 6 | from aiogram.types import InlineKeyboardMarkup 7 | from aiogram.utils import exceptions, executor 8 | from pymongo import MongoClient 9 | 10 | import data 11 | 12 | logging.basicConfig(level=logging.INFO) 13 | log = logging.getLogger('broadcast') 14 | 15 | bot = Bot(token=config.BOT_TOKEN, parse_mode=types.ParseMode.HTML) 16 | dp = Dispatcher(bot) 17 | 18 | texts = { 19 | 'ru': data.ru_message, 20 | 'en': data.en_message 21 | } 22 | button_texts = { 23 | 'ru': data.ru_button_text, 24 | 'en': data.en_button_text 25 | } 26 | 27 | 28 | def get_users() -> Tuple[int, str]: 29 | client = MongoClient(config.DB_URL) 30 | collection = client[config.DB_NAME][config.DB_COLLECTION_NAME] 31 | docs = list(collection.find()) 32 | users = map(lambda doc: (doc['user_id'], doc['language']), docs) 33 | yield from users 34 | 35 | 36 | async def send_message( 37 | user_id: int, 38 | text: str, 39 | kb: InlineKeyboardMarkup = None, 40 | disable_notification: bool = False 41 | ) -> bool: 42 | try: 43 | await bot.send_message(user_id, text, disable_notification=disable_notification, reply_markup=None) 44 | except exceptions.BotBlocked: 45 | log.error(f"Target [ID:{user_id}]: blocked by user") 46 | except exceptions.ChatNotFound: 47 | log.error(f"Target [ID:{user_id}]: invalid user ID") 48 | except exceptions.RetryAfter as e: 49 | log.error(f"Target [ID:{user_id}]: Flood limit is exceeded. Sleep {e.timeout} seconds.") 50 | await asyncio.sleep(e.timeout) 51 | return await send_message(user_id, text) # Recursive call 52 | except exceptions.UserDeactivated: 53 | log.error(f"Target [ID:{user_id}]: user is deactivated") 54 | except exceptions.TelegramAPIError: 55 | log.exception(f"Target [ID:{user_id}]: failed") 56 | else: 57 | log.info(f"Target [ID:{user_id}]: success") 58 | return True 59 | return False 60 | 61 | 62 | async def broadcaster() -> int: 63 | """ 64 | Simple broadcaster 65 | :return: Count of messages 66 | """ 67 | count = 0 68 | try: 69 | for user_id, lang in get_users(): 70 | text = texts.get(lang) 71 | if text is None: 72 | log.error(f'There is no known language for user {user_id}') 73 | continue 74 | 75 | # kb = InlineKeyboardMarkup() 76 | # kb.row(InlineKeyboardButton(text=button_donate_texts[lang], url=data.donate_link)) 77 | # kb.row(InlineKeyboardButton(text=button_channel_texts[lang], url=data.channel_link)) 78 | if await send_message(user_id, text, kb=...): 79 | count += 1 80 | await asyncio.sleep(.05) # 20 messages per second (Limit: 30 messages per second) 81 | finally: 82 | log.info(f"{count} messages successful sent.") 83 | 84 | return count 85 | 86 | 87 | if __name__ == '__main__': 88 | # Execute broadcaster 89 | executor.start(dp, broadcaster()) 90 | -------------------------------------------------------------------------------- /zmanim_bot/admin/report_management.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Union 3 | 4 | from aiogram import types 5 | 6 | from zmanim_bot.repository.bot_repository import get_or_set_location 7 | from ..config import config 8 | from ..exceptions import NoLocationException 9 | from ..helpers import CallbackPrefixes 10 | from ..misc import bot 11 | 12 | 13 | async def _compose_report_text(report: dict) -> str: 14 | try: 15 | location = await get_or_set_location() 16 | except NoLocationException: 17 | location = None 18 | 19 | report_data = { 20 | 'text': report['message'], 21 | 'meta': { 22 | 'user_id': types.User.get_current().id, 23 | 'user_location': location 24 | } 25 | } 26 | report_text = json.dumps(report_data, indent=2, ensure_ascii=False) 27 | text = f'NEW REPORT!\n\n{report_text}' 28 | return text 29 | 30 | 31 | async def _compose_media(report: dict) -> Union[types.MediaGroup, types.InputMediaPhoto]: 32 | media_ids = report['media_ids'] 33 | 34 | media_photos = [] 35 | for file_id, file_type in media_ids: 36 | media_types = { 37 | 'photo': types.InputMediaPhoto, 38 | 'video': types.InputMediaVideo 39 | } 40 | file = await bot.get_file(file_id) 41 | url = bot.get_file_url(file.file_path) 42 | media_photos.append(media_types[file_type](types.InputFile.from_url(url))) 43 | 44 | if len(media_photos) == 1: 45 | return media_photos[0] 46 | return types.MediaGroup(media_photos) 47 | 48 | 49 | async def send_report_to_admins(report: dict): 50 | button_data = f'{CallbackPrefixes.report}{report["message_id"]}:{report["user_id"]}' 51 | button = types.InlineKeyboardMarkup().row( 52 | types.InlineKeyboardButton('Reply to report', callback_data=button_data) 53 | ) 54 | report_text = await _compose_report_text(report) 55 | report_media = await _compose_media(report) if report['media_ids'] else None 56 | 57 | for admin_id in config.REPORT_ADMIN_LIST: 58 | if report_media: 59 | if isinstance(report_media, types.MediaGroup): 60 | msg = await bot.send_media_group(admin_id, report_media) 61 | else: 62 | msg = await bot.send_photo(admin_id, report_media.file) 63 | 64 | resp_id = msg[0].message_id if isinstance(msg, list) else msg.message_id 65 | await bot.send_message(admin_id, report_text, reply_markup=button, reply_to_message_id=resp_id) 66 | else: 67 | await bot.send_message(admin_id, report_text, reply_markup=button) 68 | 69 | 70 | async def send_response_to_user(response: dict): 71 | response_text = response['response'] 72 | response_media = await _compose_media(response) if response['media_ids'] else None 73 | 74 | msg = await bot.send_message( 75 | response['user_id'], 76 | response_text, 77 | reply_to_message_id=response['message_id'] 78 | ) 79 | if response_media: 80 | if isinstance(response_media, types.MediaGroup): 81 | await bot.send_media_group(response['user_id'], response_media, reply_to_message_id=msg.message_id) 82 | else: 83 | await bot.send_photo(response['user_id'], response_media.file, reply_to_message_id=msg.message_id) 84 | -------------------------------------------------------------------------------- /zmanim_bot/processors/image/image_processor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from io import BytesIO 4 | from typing import Tuple 5 | 6 | from aiogram import Bot 7 | from aiogram.types import InlineKeyboardMarkup, Message, User, CallbackQuery, InputMedia 8 | 9 | from zmanim_bot.integrations import zmanim_models 10 | from zmanim_bot.keyboards import inline 11 | from zmanim_bot.processors.base import BaseProcessor 12 | from zmanim_bot.processors.image import renderer 13 | 14 | 15 | class ImageProcessor(BaseProcessor[BytesIO]): 16 | 17 | _type = 'image' 18 | 19 | async def _send(self, image: BytesIO, *kb_list: InlineKeyboardMarkup): 20 | bot = Bot.get_current() 21 | user = User.get_current() 22 | call = CallbackQuery.get_current() 23 | message = Message.get_current() 24 | kb_list = [kb for kb in kb_list if kb] # clean from None 25 | 26 | if call: 27 | reply_to = call.message.message_id 28 | elif message: 29 | reply_to = message and message.message_id 30 | else: 31 | reply_to = None 32 | 33 | if len(kb_list) == 0: 34 | kb = None 35 | elif len(kb_list) == 1: 36 | kb = kb_list[0] 37 | else: 38 | kb = inline.merge_inline_keyboards(kb_list[0], kb_list[1]) 39 | 40 | await bot.send_photo(user.id, image, reply_markup=kb, reply_to_message_id=reply_to) 41 | 42 | async def _update(self, image: BytesIO, *kb_list: InlineKeyboardMarkup): 43 | bot = Bot.get_current() 44 | user = User.get_current() 45 | call = CallbackQuery.get_current() 46 | kb_list = [kb for kb in kb_list if kb] # clean from None 47 | 48 | media = InputMedia(media=image) 49 | 50 | if len(kb_list) == 0: 51 | kb = None 52 | elif len(kb_list) == 1: 53 | kb = kb_list[0] 54 | else: 55 | kb = inline.merge_inline_keyboards(kb_list[0], kb_list[1]) 56 | 57 | await bot.edit_message_media(media, user.id, call.message.message_id, reply_markup=kb) 58 | 59 | def _get_zmanim(self, data: zmanim_models.Zmanim) -> BytesIO: 60 | return renderer.ZmanimImage(data, self._location_name).get_image() 61 | 62 | def _get_shabbat(self, data: zmanim_models.Shabbat) -> Tuple[BytesIO, InlineKeyboardMarkup]: 63 | return renderer.ShabbatImage(data, self._location_name).get_image() 64 | 65 | def _get_yom_tov(self, data: zmanim_models.YomTov) -> Tuple[BytesIO, InlineKeyboardMarkup]: 66 | return renderer.YomTovImage(data, self._location_name).get_image() 67 | 68 | def _get_fast(self, data: zmanim_models.Fast) -> Tuple[BytesIO, InlineKeyboardMarkup]: 69 | return renderer.FastImage(data, self._location_name).get_image() 70 | 71 | def _get_holiday(self, data: zmanim_models.Holiday) -> BytesIO: 72 | return renderer.HolidayImage(data).get_image() 73 | 74 | def _get_israel_holidays(self, data: zmanim_models.IsraelHolidays) -> BytesIO: 75 | return renderer.IsraelHolidaysImage(data).get_image() 76 | 77 | def _get_rosh_chodesh(self, data: zmanim_models.RoshChodesh) -> BytesIO: 78 | return renderer.RoshChodeshImage(data).get_image() 79 | 80 | def _get_daf_yomi(self, data: zmanim_models.DafYomi) -> BytesIO: 81 | return renderer.DafYomImage(data).get_image() 82 | -------------------------------------------------------------------------------- /zmanim_bot/processors/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import Tuple, Generic, TypeVar 5 | 6 | from aiogram.types import InlineKeyboardMarkup 7 | 8 | from zmanim_bot.integrations.zmanim_models import (Zmanim, Shabbat, YomTov, Fast, IsraelHolidays, 9 | RoshChodesh, DafYomi, 10 | Holiday) 11 | 12 | T = TypeVar('T') 13 | 14 | 15 | class BaseProcessor(ABC, Generic[T]): 16 | _location_name: str 17 | 18 | def __init__(self, location_name: str): 19 | self._location_name = location_name 20 | 21 | @abstractmethod 22 | async def _send(self, data: T, *kb: InlineKeyboardMarkup): 23 | pass 24 | 25 | @abstractmethod 26 | async def _update(self, data: T, *kb: InlineKeyboardMarkup): 27 | pass 28 | 29 | async def send_zmanim(self, data: Zmanim, kb: InlineKeyboardMarkup): 30 | await self._send(self._get_zmanim(data), kb) 31 | 32 | async def send_shabbat(self, data: Shabbat, kb: InlineKeyboardMarkup): 33 | await self._send(*self._get_shabbat(data), kb) 34 | 35 | async def send_yom_tov(self, data: YomTov, kb: InlineKeyboardMarkup): 36 | await self._send(*self._get_yom_tov(data), kb) 37 | 38 | async def send_fast(self, data: Fast, kb: InlineKeyboardMarkup): 39 | await self._send(*self._get_fast(data), kb) 40 | 41 | async def send_holiday(self, data: Holiday): 42 | await self._send(self._get_holiday(data)) 43 | 44 | async def send_israel_holidays(self, data: IsraelHolidays): 45 | await self._send(self._get_israel_holidays(data)) 46 | 47 | async def send_rosh_chodesh(self, data: RoshChodesh): 48 | await self._send(self._get_rosh_chodesh(data)) 49 | 50 | async def send_daf_yomi(self, data: DafYomi): 51 | await self._send(self._get_daf_yomi(data)) 52 | 53 | async def update_zmanim(self, data: Zmanim, kb: InlineKeyboardMarkup): 54 | await self._update(self._get_zmanim(data), kb) 55 | 56 | async def update_shabbat(self, data: Shabbat, kb: InlineKeyboardMarkup): 57 | await self._update(*self._get_shabbat(data), kb) 58 | 59 | async def update_yom_tov(self, data: YomTov, kb: InlineKeyboardMarkup): 60 | await self._update(*self._get_yom_tov(data), kb) 61 | 62 | async def update_fast(self, data: Fast, kb: InlineKeyboardMarkup): 63 | await self._update(*self._get_fast(data), kb) 64 | 65 | @abstractmethod 66 | def _get_zmanim(self, data: Zmanim) -> T: 67 | pass 68 | 69 | @abstractmethod 70 | def _get_shabbat(self, data: Shabbat) -> Tuple[T, InlineKeyboardMarkup]: 71 | pass 72 | 73 | @abstractmethod 74 | def _get_yom_tov(self, data: YomTov) -> Tuple[T, InlineKeyboardMarkup]: 75 | pass 76 | 77 | @abstractmethod 78 | def _get_fast(self, data: Fast) -> Tuple[T, InlineKeyboardMarkup]: 79 | pass 80 | 81 | @abstractmethod 82 | def _get_holiday(self, data: Holiday) -> T: 83 | pass 84 | 85 | @abstractmethod 86 | def _get_israel_holidays(self, data: IsraelHolidays) -> T: 87 | pass 88 | 89 | @abstractmethod 90 | def _get_rosh_chodesh(self, data: RoshChodesh) -> T: 91 | pass 92 | 93 | @abstractmethod 94 | def _get_daf_yomi(self, data: DafYomi) -> T: 95 | pass 96 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/errors.py: -------------------------------------------------------------------------------- 1 | import aiogram_metrics 2 | import sentry_sdk 3 | from aiogram import Bot 4 | from aiogram.types import User, Message, CallbackQuery 5 | from aiogram.utils.exceptions import CantInitiateConversation 6 | 7 | from zmanim_bot.exceptions import * 8 | from zmanim_bot.handlers.utils.redirects import * 9 | from zmanim_bot.handlers.utils.warnings import * 10 | from zmanim_bot.misc import logger 11 | from zmanim_bot.texts.single import messages 12 | from zmanim_bot.texts.single.helpers import cl_error_warning 13 | from zmanim_bot.texts.single.messages import error_occured 14 | 15 | 16 | async def no_location_exception_handler(*_): 17 | await redirect_to_request_location() 18 | return True 19 | 20 | 21 | async def incorrect_location_exception_handler(*_): 22 | user = User.get_current() 23 | bot = Bot.get_current() 24 | await bot.send_message(user.id, messages.incorrect_locations_received) 25 | return True 26 | 27 | 28 | async def non_unique_location_exception_handler(*_): 29 | user = User.get_current() 30 | bot = Bot.get_current() 31 | await bot.send_message(user.id, messages.location_already_exists) 32 | return True 33 | 34 | 35 | async def non_unique_location_name_exception_handler(*_): 36 | user = User.get_current() 37 | bot = Bot.get_current() 38 | await bot.send_message(user.id, messages.location_name_already_exists) 39 | return True 40 | 41 | 42 | async def location_limit_exception_handler(*_): 43 | user = User.get_current() 44 | bot = Bot.get_current() 45 | await bot.send_message(user.id, messages.too_many_locations_error) 46 | return True 47 | 48 | 49 | async def no_language_exception_handler(*_): 50 | await redirect_to_request_language() 51 | 52 | 53 | async def gregorian_date_exception_handler(*_): 54 | await incorrect_greg_date_warning() 55 | return True 56 | 57 | 58 | async def jewish_date_exception_handler(*_): 59 | await incorrect_jew_date_warning() 60 | return True 61 | 62 | 63 | async def polar_coordinates_exception_handler(*_): 64 | await Message.get_current().reply(cl_error_warning.value.replace('\n', ' ')) 65 | return True 66 | 67 | 68 | async def unknown_processor_exception_handler(_, e: UnknownProcessorException): 69 | await Message.get_current().reply(error_occured) 70 | raise e 71 | 72 | 73 | async def access_denied_exception_handler(*_): 74 | user = User.get_current() 75 | msg = Message.get_current() 76 | call = CallbackQuery.get_current() 77 | 78 | try: 79 | if msg: 80 | await msg.reply('Access was denied by admin.') 81 | elif call: 82 | await call.answer('Access was denied by admin.') 83 | else: 84 | await Bot.get_current().send_message(user.id, 'Access was denied by admin.') 85 | except CantInitiateConversation: 86 | pass 87 | 88 | aiogram_metrics.manual_track('Access denied') 89 | return True 90 | 91 | 92 | async def empty_exception_handler(*_): 93 | return True 94 | 95 | 96 | async def main_errors_handler(_, e: Exception): 97 | if isinstance(e, ZmanimBotBaseException): 98 | return True 99 | user = User.get_current() 100 | bot = Bot.get_current() 101 | await bot.send_message(user.id, error_occured) 102 | 103 | logger.exception(e) 104 | 105 | sentry_sdk.capture_exception(e) 106 | return True 107 | -------------------------------------------------------------------------------- /zmanim_bot/repository/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime as dt 2 | from typing import List, Optional, Tuple 3 | 4 | from odmantic import EmbeddedModel, Field, Model 5 | 6 | from zmanim_bot.config import config 7 | from zmanim_bot.exceptions import NoLocationException, UnknownProcessorException 8 | from zmanim_bot.processors import PROCESSORS 9 | from zmanim_bot.processors.base import BaseProcessor 10 | 11 | HAVDALA_OPINIONS = ['tzeis_5_95_degrees', 'tzeis_8_5_degrees', 'tzeis_42_minutes', 'tzeis_72_minutes'] 12 | 13 | 14 | class UserInfo(EmbeddedModel): 15 | first_name: Optional[str] = None 16 | last_name: Optional[str] = None 17 | username: Optional[str] = None 18 | 19 | 20 | class UserMeta(EmbeddedModel): 21 | last_seen_at: dt = Field(default_factory=dt.now) 22 | is_banned_by_admin: bool = False 23 | is_user_blocked_bot: bool = False 24 | 25 | 26 | class Location(EmbeddedModel): 27 | lat: float 28 | lng: float 29 | name: str 30 | is_active: bool 31 | elevation: int | None = 0 32 | 33 | @property 34 | def coordinates(self) -> Tuple[float, float, int]: 35 | elevation = self.elevation if self.elevation >= 0 else 0 36 | return self.lat, self.lng, elevation 37 | 38 | 39 | class OmerSettings(EmbeddedModel): 40 | is_enabled: bool = False 41 | is_sent_today: Optional[bool] 42 | notification_time: Optional[str] 43 | 44 | 45 | class ZmanimSettings(EmbeddedModel): 46 | alos: bool = True 47 | misheyakir_10_2: bool = True 48 | sunrise: bool = True 49 | sof_zman_shema_ma: bool = False 50 | sof_zman_shema_gra: bool = True 51 | sof_zman_tefila_ma: bool = False 52 | sof_zman_tefila_gra: bool = True 53 | chatzos: bool = True 54 | mincha_gedola: bool = True 55 | mincha_ketana: bool = False 56 | plag_mincha: bool = False 57 | sunset: bool = True 58 | tzeis_5_95_degrees: bool = False 59 | tzeis_8_5_degrees: bool = True 60 | tzeis_42_minutes: bool = False 61 | tzeis_72_minutes: bool = False 62 | chatzot_laila: bool = False 63 | astronomical_hour_ma: bool = False 64 | astronomical_hour_gra: bool = False 65 | 66 | 67 | class User(Model): 68 | user_id: int 69 | personal_info: UserInfo = Field(default_factory=UserInfo) 70 | 71 | language: Optional[str] = None 72 | location_list: List[Location] = Field(default_factory=list) 73 | cl_offset: int = 18 74 | havdala_opinion: str = 'tzeis_8_5_degrees' 75 | zmanim_settings: ZmanimSettings = Field(default_factory=ZmanimSettings) 76 | processor_type: str = 'image' 77 | omer: OmerSettings = Field(default_factory=OmerSettings) 78 | 79 | meta: UserMeta = Field(default_factory=UserMeta) 80 | 81 | class Config: 82 | collection = config.DB_COLLECTION_NAME 83 | parse_doc_with_default_factories = True 84 | 85 | @property 86 | def location(self) -> Location: 87 | loc = list(filter(lambda l: l.is_active, self.location_list)) 88 | if not loc: 89 | raise NoLocationException 90 | return loc[0] 91 | 92 | def get_location_by_coords(self, lat: float, lng: float) -> Location: 93 | resp = list(filter(lambda loc: loc.lat == lat and loc.lng == lng, self.location_list)) 94 | if not resp: 95 | raise NoLocationException 96 | return resp[0] 97 | 98 | def get_processor(self, location: Optional[Location] = None) -> BaseProcessor: 99 | try: 100 | return PROCESSORS[self.processor_type]((location and location.name) or self.location.name) 101 | except KeyError: 102 | raise UnknownProcessorException() 103 | 104 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/settings.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import CallbackQuery, Message 2 | from aiogram.utils.exceptions import MessageNotModified 3 | from aiogram_metrics import track 4 | 5 | from zmanim_bot.handlers.utils.redirects import (redirect_to_main_menu, 6 | redirect_to_request_language) 7 | from zmanim_bot.helpers import (CALL_ANSWER_OK) 8 | from zmanim_bot.misc import bot 9 | from zmanim_bot.service import settings_service 10 | from zmanim_bot.utils import chat_action 11 | 12 | 13 | @chat_action('text') 14 | @track('Candle lighting selection') 15 | async def settings_menu_cl(msg: Message): 16 | resp, kb = await settings_service.get_current_cl() 17 | await msg.reply(resp, reply_markup=kb) 18 | 19 | 20 | async def set_cl(call: CallbackQuery): 21 | await call.answer(CALL_ANSWER_OK) 22 | 23 | kb = await settings_service.set_cl(call.data) 24 | try: 25 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 26 | except MessageNotModified: 27 | pass 28 | 29 | 30 | @chat_action('text') 31 | @track('Havdala selection') 32 | async def settings_menu_havdala(msg: Message): 33 | resp, kb = await settings_service.get_current_havdala() 34 | await msg.reply(resp, reply_markup=kb) 35 | 36 | 37 | async def set_havdala(call: CallbackQuery): 38 | await call.answer(CALL_ANSWER_OK) 39 | 40 | kb = await settings_service.set_havdala(call.data) 41 | 42 | try: 43 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 44 | except MessageNotModified: 45 | pass 46 | 47 | 48 | @track('Zmanim selection') 49 | async def settings_menu_zmanim(msg: Message): 50 | resp, kb = await settings_service.get_current_zmanim() 51 | await msg.reply(resp, reply_markup=kb) 52 | 53 | 54 | async def set_zmanim(call: CallbackQuery): 55 | await call.answer(CALL_ANSWER_OK) 56 | 57 | kb = await settings_service.set_zmanim(call.data) 58 | try: 59 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 60 | except MessageNotModified: 61 | pass 62 | 63 | 64 | @chat_action('text') 65 | @track('Omer Settings') 66 | async def handle_omer_settings(msg: Message): 67 | resp, kb = await settings_service.get_current_omer() 68 | await msg.reply(resp, reply_markup=kb) 69 | 70 | 71 | async def set_omer(call: CallbackQuery): 72 | await call.answer(CALL_ANSWER_OK) 73 | 74 | kb = await settings_service.set_omer(call.data) 75 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 76 | 77 | 78 | @chat_action('text') 79 | @track('Format settings') 80 | async def handle_format_settings(msg: Message): 81 | resp, kb = await settings_service.get_current_format() 82 | await msg.reply(resp, reply_markup=kb) 83 | 84 | 85 | async def set_format(call: CallbackQuery): 86 | await call.answer(CALL_ANSWER_OK) 87 | 88 | kb = await settings_service.set_format(call.data) 89 | 90 | try: 91 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 92 | except MessageNotModified: 93 | pass 94 | 95 | 96 | @chat_action('text') 97 | @track('Language command') 98 | async def handle_language_request(_: Message): 99 | await redirect_to_request_language() 100 | 101 | 102 | @chat_action('text') 103 | @track('Language selected') 104 | async def set_language(msg: Message): 105 | await settings_service.set_language(msg.text) 106 | return await redirect_to_main_menu() 107 | 108 | 109 | @chat_action('text') 110 | @track('Init report') 111 | async def help_menu_report(msg: Message): 112 | resp, kb = await settings_service.init_report() 113 | await msg.reply(resp, reply_markup=kb) 114 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/forms.py: -------------------------------------------------------------------------------- 1 | from asyncio import create_task 2 | 3 | from aiogram.dispatcher import FSMContext 4 | from aiogram.types import ContentType, Message 5 | 6 | from zmanim_bot.admin.report_management import send_report_to_admins 7 | from zmanim_bot.handlers.utils.redirects import (redirect_to_main_menu, redirect_to_settings_menu) 8 | from zmanim_bot.helpers import check_date 9 | from zmanim_bot.keyboards.menus import get_report_keyboard 10 | from zmanim_bot.misc import bot 11 | from zmanim_bot.service import converter_service, settings_service, zmanim_service 12 | from zmanim_bot.states import FeedbackState 13 | from zmanim_bot.texts.single import messages, buttons 14 | from zmanim_bot.utils import chat_action 15 | 16 | 17 | # REPORTS 18 | 19 | @chat_action('text') 20 | async def handle_report(msg: Message, state: FSMContext): 21 | report = { 22 | 'message': msg.text, 23 | 'message_id': msg.message_id, 24 | 'user_id': msg.from_user.id, 25 | 'media_ids': [] 26 | } 27 | await state.set_data(report) 28 | await FeedbackState.next() 29 | 30 | kb = get_report_keyboard() 31 | await bot.send_message(msg.chat.id, messages.reports_text_received, reply_markup=kb) 32 | 33 | 34 | @chat_action('text') 35 | async def handle_done_report(_, state: FSMContext): 36 | report = await state.get_data() 37 | await state.finish() 38 | await redirect_to_main_menu(messages.reports_created) 39 | create_task(send_report_to_admins(report)) 40 | 41 | 42 | @chat_action('text') 43 | async def handle_report_payload(msg: Message, state: FSMContext): 44 | if msg.content_type != ContentType.PHOTO: 45 | return await msg.reply(messages.reports_incorrect_media_type) 46 | 47 | report = await state.get_data() 48 | report['media_ids'].append((msg.photo[-1].file_id, 'photo')) 49 | await state.set_data(report) 50 | 51 | await msg.reply(messages.reports_media_received) 52 | 53 | 54 | # CONVERTER # 55 | 56 | @chat_action('text') 57 | async def handle_converter_gregorian_date(msg: Message, state: FSMContext): 58 | resp, kb = converter_service.convert_greg_to_heb(msg.text) 59 | await state.finish() 60 | await msg.reply(resp, reply_markup=kb) 61 | await redirect_to_main_menu() 62 | 63 | 64 | @chat_action('text') 65 | async def handle_converter_jewish_date(msg: Message, state: FSMContext): 66 | resp, kb = converter_service.convert_heb_to_greg(msg.text) 67 | await state.finish() 68 | await msg.reply(resp, reply_markup=kb) 69 | await redirect_to_main_menu() 70 | 71 | 72 | # ZMANIM # 73 | 74 | @chat_action('text') 75 | async def handle_zmanim_gregorian_date(msg: Message, state: FSMContext): 76 | check_date(msg.text) 77 | await zmanim_service.send_zmanim(date=msg.text, state=state) 78 | await state.finish() 79 | await redirect_to_main_menu() 80 | 81 | 82 | # LOCATIONS # 83 | 84 | @chat_action('text') 85 | async def handle_location_name(msg: Message, state: FSMContext): 86 | state_data = await state.get_data() 87 | 88 | old_name = state_data.get('location_name') 89 | redirect_target = state_data.get('redirect_target', 'main') 90 | redirect_message = state_data.get('redirect_message') 91 | origin_message_id = state_data.get('origin_message_id') 92 | targets = { 93 | 'main': redirect_to_main_menu, 94 | 'settings': redirect_to_settings_menu 95 | } 96 | redirect = targets[redirect_target] 97 | 98 | if msg.text == buttons.done.value: 99 | await state.finish() 100 | return await redirect(redirect_message) 101 | 102 | location_kb = await settings_service.update_location_name(new_name=msg.text, old_name=old_name) 103 | 104 | if origin_message_id: 105 | await bot.edit_message_reply_markup(msg.from_user.id, origin_message_id, reply_markup=location_kb) 106 | 107 | await state.finish() 108 | await redirect(redirect_message) 109 | -------------------------------------------------------------------------------- /zmanim_bot/service/festivals_service.py: -------------------------------------------------------------------------------- 1 | from aiogram_metrics import track 2 | 3 | from zmanim_bot.helpers import CallbackPrefixes 4 | from zmanim_bot.integrations import zmanim_api_client 5 | from zmanim_bot.keyboards import inline 6 | from zmanim_bot.repository import bot_repository 7 | from zmanim_bot.texts.single import buttons 8 | 9 | 10 | def _get_festival_name(input_str: str) -> str: 11 | festival_shortcuts = { 12 | buttons.hom_rosh_hashana.value: 'rosh_hashana', 13 | buttons.hom_yom_kippur.value: 'yom_kippur', 14 | buttons.hom_succot.value: 'succot', 15 | buttons.hom_shmini_atzeret.value: 'shmini_atzeres', 16 | buttons.hom_chanukah.value: 'chanukah', 17 | buttons.hom_purim.value: 'purim', 18 | buttons.hom_pesach.value: 'pesach', 19 | buttons.hom_shavuot.value: 'shavuot', 20 | buttons.hom_tu_bishvat.value: 'tu_bi_shvat', 21 | buttons.hom_lag_baomer.value: 'lag_baomer', 22 | buttons.fm_gedaliah.value: 'fast_gedalia', 23 | buttons.fm_tevet.value: 'fast_10_teves', 24 | buttons.fm_esther.value: 'fast_esther', 25 | buttons.fm_tammuz.value: 'fast_17_tammuz', 26 | buttons.fm_av.value: 'fast_9_av', 27 | } 28 | return festival_shortcuts[input_str] 29 | 30 | 31 | @track('Fast') 32 | async def get_generic_fast(fast_name: str): 33 | user = await bot_repository.get_or_create_user() 34 | data = await zmanim_api_client.get_generic_fast( 35 | name=_get_festival_name(fast_name), 36 | location=user.location.coordinates, 37 | havdala_opinion=user.havdala_opinion 38 | ) 39 | kb = inline.get_location_variants_menu(user.location_list, user.location, CallbackPrefixes.update_fast) 40 | await user.get_processor().send_fast(data, kb) 41 | 42 | 43 | @track('Fast geo-variant') 44 | async def update_generic_fast(fast_name: str, lat: float, lng: float): 45 | user = await bot_repository.get_or_create_user() 46 | location = user.get_location_by_coords(lat, lng) 47 | data = await zmanim_api_client.get_generic_fast( 48 | name=_get_festival_name(fast_name), 49 | location=location.coordinates, 50 | havdala_opinion=user.havdala_opinion 51 | ) 52 | kb = inline.get_location_variants_menu(user.location_list, location, CallbackPrefixes.update_fast) 53 | await user.get_processor(location).update_fast(data, kb) 54 | 55 | 56 | @track('Yom tov') 57 | async def get_generic_yomtov(yomtov_name: str): 58 | user = await bot_repository.get_or_create_user() 59 | data = await zmanim_api_client.get_generic_yomtov( 60 | name=_get_festival_name(yomtov_name), 61 | location=user.location.coordinates, 62 | cl_offset=user.cl_offset, 63 | havdala_opinion=user.havdala_opinion 64 | ) 65 | kb = inline.get_location_variants_menu(user.location_list, user.location, CallbackPrefixes.update_yom_tov) 66 | await user.get_processor().send_yom_tov(data, kb) 67 | 68 | 69 | @track('Yom tov geo-variant') 70 | async def update_generic_yom_tov(yom_tov_name: str, lat: float, lng: float): 71 | user = await bot_repository.get_or_create_user() 72 | location = user.get_location_by_coords(lat, lng) 73 | data = await zmanim_api_client.get_generic_yomtov( 74 | name=_get_festival_name(yom_tov_name), 75 | location=location.coordinates, 76 | cl_offset=user.cl_offset, 77 | havdala_opinion=user.havdala_opinion 78 | ) 79 | kb = inline.get_location_variants_menu(user.location_list, location, CallbackPrefixes.update_yom_tov) 80 | await user.get_processor(location).update_yom_tov(data, kb) 81 | 82 | 83 | async def get_generic_holiday(holiday_name: str): 84 | user = await bot_repository.get_or_create_user() 85 | if holiday_name == buttons.hom_israel: 86 | data = await zmanim_api_client.get_israel_holidays() 87 | await user.get_processor().send_israel_holidays(data) 88 | else: 89 | data = await zmanim_api_client.get_generic_holiday(_get_festival_name(holiday_name)) 90 | await user.get_processor().send_holiday(data) 91 | -------------------------------------------------------------------------------- /zmanim_bot/keyboards/menus.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import KeyboardButton, ReplyKeyboardMarkup 2 | 3 | from zmanim_bot.config import config 4 | from zmanim_bot.middlewares.i18n import i18n_ 5 | from zmanim_bot.texts.single import buttons 6 | 7 | 8 | def get_lang_menu() -> ReplyKeyboardMarkup: 9 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 10 | kb.add(*config.LANGUAGE_LIST) 11 | return kb 12 | 13 | 14 | def get_main_menu() -> ReplyKeyboardMarkup: 15 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 16 | kb.add(buttons.mm_zmanim.value, buttons.mm_shabbat.value, buttons.mm_holidays.value) 17 | kb.add(buttons.mm_rh.value, buttons.mm_daf_yomi.value, buttons.mm_fasts.value) 18 | kb.add(buttons.mm_zmanim_by_date.value, buttons.mm_converter.value) 19 | kb.add(buttons.hm_report.value, buttons.mm_donate.value, buttons.mm_settings.value) 20 | 21 | if i18n_.is_rtl(): 22 | for row in kb.keyboard: 23 | row.reverse() 24 | return kb 25 | 26 | 27 | # def get_help_menu() -> ReplyKeyboardMarkup: 28 | # kb = ReplyKeyboardMarkup(resize_keyboard=True) 29 | # kb.row(buttons.hm_faq.value, buttons.hm_report.value) 30 | # kb.row(buttons.back.value) 31 | # return kb 32 | 33 | 34 | def get_settings_menu() -> ReplyKeyboardMarkup: 35 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 36 | kb.row(buttons.sm_zmanim.value, buttons.sm_candle.value, buttons.sm_havdala.value) 37 | kb.row(buttons.sm_format.value, buttons.sm_lang.value, buttons.sm_omer.value) 38 | kb.row(buttons.sm_location.value, buttons.back.value) 39 | 40 | if i18n_.is_rtl(): 41 | for row in kb.keyboard: 42 | row.reverse() 43 | 44 | return kb 45 | 46 | 47 | def get_holidays_menu() -> ReplyKeyboardMarkup: 48 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 49 | kb.row(buttons.hom_rosh_hashana.value, buttons.hom_yom_kippur.value, buttons.hom_succot.value) 50 | kb.row(buttons.hom_shmini_atzeret.value, buttons.hom_chanukah.value, buttons.hom_purim.value) 51 | kb.row(buttons.hom_pesach.value, buttons.hom_shavuot.value, buttons.hom_more.value) 52 | kb.row(buttons.back.value) 53 | 54 | if i18n_.is_rtl(): 55 | for row in kb.keyboard: 56 | row.reverse() 57 | 58 | return kb 59 | 60 | 61 | def get_more_holidays_menu() -> ReplyKeyboardMarkup: 62 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 63 | kb.row(buttons.hom_tu_bishvat.value, buttons.hom_lag_baomer.value, buttons.hom_israel.value) 64 | kb.row(buttons.mm_holidays.value, buttons.back.value) 65 | 66 | if i18n_.is_rtl(): 67 | for row in kb.keyboard: 68 | row.reverse() 69 | 70 | return kb 71 | 72 | 73 | def get_fast_menu() -> ReplyKeyboardMarkup: 74 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 75 | kb.row(buttons.fm_gedaliah.value, buttons.fm_tevet.value, buttons.fm_esther.value) 76 | kb.row(buttons.fm_tammuz.value, buttons.fm_av.value) 77 | kb.row(buttons.back.value) 78 | 79 | if i18n_.is_rtl(): 80 | for row in kb.keyboard: 81 | row.reverse() 82 | 83 | return kb 84 | 85 | 86 | def get_converter_menu() -> ReplyKeyboardMarkup: 87 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 88 | kb.row(buttons.conv_greg_to_jew.value, buttons.conv_jew_to_greg.value) 89 | kb.row(buttons.back.value) 90 | return kb 91 | 92 | 93 | def get_geobutton(with_back: bool = False) -> ReplyKeyboardMarkup: 94 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 95 | geobutton = KeyboardButton(text=buttons.geobutton.value, request_location=True) 96 | kb.row(geobutton) if not with_back else kb.row(buttons.back.value, geobutton) 97 | return kb 98 | 99 | 100 | def get_cancel_keyboard() -> ReplyKeyboardMarkup: 101 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 102 | kb.row(buttons.cancel.value) 103 | return kb 104 | 105 | 106 | def get_done_keyboard() -> ReplyKeyboardMarkup: 107 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 108 | kb.row(buttons.done.value) 109 | return kb 110 | 111 | 112 | def get_report_keyboard() -> ReplyKeyboardMarkup: 113 | kb = ReplyKeyboardMarkup(resize_keyboard=True) 114 | kb.row(buttons.cancel.value, buttons.done.value) 115 | return kb 116 | -------------------------------------------------------------------------------- /zmanim_bot/service/zmanim_service.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from aiogram.dispatcher import FSMContext 4 | from aiogram.types import CallbackQuery 5 | 6 | from zmanim_bot.helpers import CallbackPrefixes 7 | from zmanim_bot.integrations import zmanim_api_client 8 | from zmanim_bot.integrations.zmanim_models import Zmanim 9 | from zmanim_bot.keyboards import inline 10 | from zmanim_bot.misc import bot 11 | from zmanim_bot.repository import bot_repository 12 | from zmanim_bot.states import ZmanimGregorianDateState 13 | 14 | 15 | async def get_zmanim(must_have_8_5: bool = False) -> Zmanim: 16 | user = await bot_repository.get_or_create_user() 17 | user_zmanim_settings = user.zmanim_settings 18 | 19 | if must_have_8_5: 20 | user_zmanim_settings.tzeis_8_5_degrees = True 21 | 22 | data = await zmanim_api_client.get_zmanim( 23 | user.location.coordinates, 24 | user_zmanim_settings.dict() 25 | ) 26 | return data 27 | 28 | 29 | async def send_zmanim(*, state: FSMContext, date: str = None, call: CallbackQuery = None): 30 | if call: 31 | date = call.data.split(CallbackPrefixes.zmanim_by_date)[1] 32 | user = await bot_repository.get_or_create_user() 33 | 34 | state_data = await state.get_data() 35 | if state_data.get('current_location'): 36 | location = user.get_location_by_coords(*state_data['current_location']) 37 | else: 38 | location = user.location 39 | 40 | data = await zmanim_api_client.get_zmanim( 41 | location.coordinates, 42 | user.zmanim_settings.dict(), 43 | date_=date 44 | ) 45 | kb = inline.get_location_variants_menu( 46 | user.location_list, 47 | location, 48 | CallbackPrefixes.update_zmanim, 49 | date_=date 50 | ) 51 | await user.get_processor(location=location).send_zmanim(data, kb) 52 | if call: 53 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 54 | 55 | 56 | async def update_zmanim(lat: float, lng: float, date: Optional[str]): 57 | user = await bot_repository.get_or_create_user() 58 | location = user.get_location_by_coords(lat, lng) 59 | 60 | data = await zmanim_api_client.get_zmanim( 61 | location.coordinates, 62 | user.zmanim_settings.dict(), 63 | date_=date 64 | ) 65 | kb = inline.get_location_variants_menu( 66 | user.location_list, 67 | location, 68 | CallbackPrefixes.update_zmanim, 69 | date_=date 70 | ) 71 | await user.get_processor(location).update_zmanim(data, kb) 72 | 73 | 74 | async def init_zmanim_by_date(): 75 | await ZmanimGregorianDateState().waiting_for_gregorian_date.set() 76 | 77 | 78 | async def get_shabbat(): 79 | user = await bot_repository.get_or_create_user() 80 | data = await zmanim_api_client.get_shabbat( 81 | location=user.location.coordinates, 82 | cl_offset=user.cl_offset, 83 | havdala_opinion=user.havdala_opinion 84 | ) 85 | kb = inline.get_location_variants_menu(user.location_list, user.location, CallbackPrefixes.update_shabbat) 86 | await user.get_processor().send_shabbat(data, kb) 87 | 88 | 89 | async def update_shabbat(lat: float, lng: float, state: FSMContext): 90 | user = await bot_repository.get_or_create_user() 91 | location = user.get_location_by_coords(lat, lng) 92 | await state.update_data({'current_location': [lat, lng]}) 93 | 94 | data = await zmanim_api_client.get_shabbat( 95 | location=location.coordinates, 96 | cl_offset=user.cl_offset, 97 | havdala_opinion=user.havdala_opinion 98 | ) 99 | kb = inline.get_location_variants_menu(user.location_list, location, CallbackPrefixes.update_shabbat) 100 | await user.get_processor(location).update_shabbat(data, kb) 101 | 102 | 103 | async def get_daf_yomi(): 104 | user = await bot_repository.get_or_create_user() 105 | data = await zmanim_api_client.get_daf_yomi() 106 | await user.get_processor().send_daf_yomi(data) 107 | 108 | 109 | async def get_rosh_chodesh(): 110 | user = await bot_repository.get_or_create_user() 111 | data = await zmanim_api_client.get_rosh_chodesh() 112 | await user.get_processor().send_rosh_chodesh(data) 113 | -------------------------------------------------------------------------------- /zmanim_bot/integrations/zmanim_api_client.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import Optional, Tuple 3 | 4 | from zmanim_bot.config import config 5 | from zmanim_bot.integrations.zmanim_models import * 6 | from zmanim_bot.misc import bot 7 | 8 | __all__ = [ 9 | 'get_zmanim', 10 | 'get_shabbat', 11 | 'get_daf_yomi', 12 | 'get_rosh_chodesh', 13 | 'get_generic_yomtov', 14 | 'get_generic_holiday', 15 | 'get_generic_fast', 16 | 'get_israel_holidays' 17 | ] 18 | 19 | 20 | async def get_zmanim(location: Tuple[float, float, int], zmanim_settings: dict, date_: str = None) -> Zmanim: 21 | url = config.ZMANIM_API_URL.format('zmanim') 22 | params = { 23 | 'lat': str(location[0]), 24 | 'lng': str(location[1]), 25 | 'elevation': str(location[2]) if location[2] else '0' 26 | } 27 | if date_: 28 | params['date'] = date_ 29 | 30 | async with (await bot.get_session()).post(url, params=params, json=zmanim_settings) as resp: 31 | raw_resp = await resp.json() 32 | return Zmanim(**raw_resp) 33 | 34 | 35 | async def get_shabbat( 36 | location: Tuple[float, float, int], 37 | cl_offset: int, 38 | havdala_opinion: str, 39 | date_: str = None 40 | ) -> Shabbat: 41 | url = config.ZMANIM_API_URL.format('shabbat') 42 | params = { 43 | 'lat': str(location[0]), 44 | 'lng': str(location[1]), 45 | 'elevation': str(location[2]) if location[2] else '0', 46 | 'cl_offset': str(cl_offset), 47 | 'havdala': havdala_opinion 48 | } 49 | if date_: 50 | params['date'] = date_ 51 | 52 | async with (await bot.get_session()).get(url, params=params) as resp: 53 | raw_resp = await resp.json() 54 | return Shabbat(**raw_resp) 55 | 56 | 57 | async def get_daf_yomi(date_=None) -> DafYomi: 58 | url = config.ZMANIM_API_URL.format('daf_yomi') 59 | params = None if not date_ else {'date': date_} 60 | 61 | async with (await bot.get_session()).get(url, params=params) as resp: 62 | raw_resp = await resp.json() 63 | return DafYomi(**raw_resp) 64 | 65 | 66 | async def get_rosh_chodesh(date_=None) -> RoshChodesh: 67 | url = config.ZMANIM_API_URL.format('rosh_chodesh') 68 | params = None if not date_ else {'date': date_} 69 | 70 | async with (await bot.get_session()).get(url, params=params) as resp: 71 | raw_resp = await resp.json() 72 | return RoshChodesh(**raw_resp) 73 | 74 | 75 | async def get_generic_yomtov( 76 | name: str, 77 | location: Tuple[float, float, int], 78 | cl_offset: int, 79 | havdala_opinion: str 80 | ) -> YomTov: 81 | url = config.ZMANIM_API_URL.format('yom_tov') 82 | params = { 83 | 'lat': str(location[0]), 84 | 'lng': str(location[1]), 85 | 'elevation': str(location[2]) if location[2] else '0', 86 | 'yomtov_name': name, 87 | 'cl': str(cl_offset), 88 | 'havdala': havdala_opinion 89 | } 90 | 91 | async with (await bot.get_session()).get(url, params=params) as resp: 92 | raw_resp = await resp.json() 93 | return YomTov(**raw_resp) 94 | 95 | 96 | async def get_generic_fast(name: str, location: Tuple[float, float, int], havdala_opinion: str) -> Fast: 97 | url = config.ZMANIM_API_URL.format('fast') 98 | params = { 99 | 'lat': str(location[0]), 100 | 'lng': str(location[1]), 101 | 'elevation': str(location[2]) if location[2] else '0', 102 | 'fast_name': name, 103 | 'havdala': havdala_opinion 104 | } 105 | 106 | async with (await bot.get_session()).get(url, params=params) as resp: 107 | raw_resp = await resp.json() 108 | return Fast(**raw_resp) 109 | 110 | 111 | async def get_generic_holiday(name: str) -> Holiday: 112 | url = config.ZMANIM_API_URL.format('holiday') 113 | params = {'holiday_name': name} 114 | 115 | async with (await bot.get_session()).get(url, params=params) as resp: 116 | raw_resp = await resp.json() 117 | return Holiday(**raw_resp) 118 | 119 | 120 | async def get_israel_holidays() -> IsraelHolidays: 121 | url = config.ZMANIM_API_URL.format('holiday') 122 | result = [] 123 | settings: Optional[SimpleSettings] = None 124 | 125 | for name in ['yom_hashoah', 'yom_hazikaron', 'yom_haatzmaut', 'yom_yerushalaim']: 126 | params = {'holiday_name': name} 127 | 128 | async with (await bot.get_session()).get(url, params=params) as resp: 129 | raw_resp = await resp.json() 130 | result.append((name, date.fromisoformat(raw_resp['date']))) 131 | 132 | if not settings: 133 | settings = raw_resp['settings'] 134 | 135 | return IsraelHolidays(settings=settings, holiday_list=result) 136 | -------------------------------------------------------------------------------- /zmanim_bot/texts/single/messages.py: -------------------------------------------------------------------------------- 1 | from zmanim_bot.middlewares.i18n import lazy_gettext as _ 2 | 3 | init_bot = _('Welcome to zmanim bot!') 4 | init_main_menu = _('You are in the main menu:') 5 | init_help = _('How can I help you?') 6 | init_settings = _('You are in the settings:') 7 | init_report = _('Please, describe your problem:') 8 | init_converter = _('Choose the way of conversion:') 9 | select = _('Choose:') 10 | 11 | settings_cl = _('How long before shkiya do you light candles?') 12 | settings_zmanim = _('Select zmanim that you want to receive:') 13 | settings_havdala = _('Select your opinion for havdala:') 14 | settings_omer = _('Here you can enable or disable notifications about the Omer count.') 15 | settings_location = _('Here you can activate, edit or send new location;\nIf you want to add new location, just send it right now.\nNotice! if you change the name of a location, it will point to the same location. To add another location, use the "Add Location" button.') 16 | settings_format = _('Select an output type for the bot\'s responces:') 17 | 18 | request_language = _('Select your language:') 19 | 20 | request_location_on_init = _('For this feature, the bot should know your location. If you want to find it by name, press the button below.\nAlso, you can pass your location as an attachment or as a pair of coordinates, like 55.5, 32.2.') 21 | request_location = _('If you want to find location by name, press the button below.\nAlso, you can pass your location as an attachment or as a pair of coordinates, like 55.5, 32.2.') 22 | location_menu_init = _('Here you can manage your saved locations or add some more.') 23 | incorrect_locations_received = _('You sent incorrect coordinates. Please check and re-send them!') 24 | location_already_exists = _('This location already exists in your saved locations!') 25 | location_name_already_exists = _('This location name already exists in your saved locations!') 26 | too_many_locations_error = _('You saved too many locations, please remove some of them before saving new one!') 27 | custom_location_name_request = _('Automatic location name: {}.\n' 28 | 'You can write custom name for the location or press "Done" button.') 29 | location_deleted = _('Location successfully deleted!') 30 | location_renamed = _('Location successfully renamed!') 31 | location_saved = _('Location successfully saved!') 32 | unable_to_delete_active_location = _('It is unable to delete active location!') 33 | location_new_name_request = _('Please, write here new location name for {}:') 34 | location_activated = _('{} selected!') 35 | 36 | incorrect_text = _('"I’m not aware of such command 🤖\nWould you choose something from the menu:') 37 | 38 | 39 | greg_date_request = _('Please enter Gregorian date in following format: YYYY-MM-DD') 40 | incorrect_greg_date = _('Incorrect/Illegal date format. Take a look at the correct one: ' 41 | 'YYYY-MM-DD') 42 | jew_date_request = _('Please enter Jewish date in following format: \nYYYY-MM-DD\n' 43 | '1 Nisan\n' 44 | '2 Iyar\n' 45 | '3 Sivan\n' 46 | '4 Tammuz\n' 47 | '5 Av\n' 48 | '6 Elul\n' 49 | '7 Tishrei\n' 50 | '8 Cheshvan\n' 51 | '9 Kislev\n' 52 | '10 Tevet\n' 53 | '11 Shvat\n' 54 | '12 Adar/Adar\n' 55 | '13 Adar II') 56 | incorrect_jew_date = _('Incorrect/Illegal date format. Take a look at the correct one: \nYYYY-MM-DD\n' 57 | '1 Nisan\n' 58 | '2 Iyar\n' 59 | '3 Sivan\n' 60 | '4 Tammuz\n' 61 | '5 Av\n' 62 | '6 Elul\n' 63 | '7 Tishrei\n' 64 | '8 Cheshvan\n' 65 | '9 Kislev\n' 66 | '10 Tevet\n' 67 | '11 Shvat\n' 68 | '12 Adar/Adar\n' 69 | '13 Adar II') 70 | 71 | 72 | reports_text_received = _('Your message has been saved for sending. You can attach screenshots or ' 73 | 'videos of your choice. Once done, click the Done button.') 74 | reports_media_received = _('File added successfully. Add another one or click Done.') 75 | reports_incorrect_media_type = _('Unsupported message format. You can attach screenshots or videos ' 76 | 'by sending a photo, picture or video.') 77 | reports_created = _('Your message has been sent to the Bot\'s developer. He will fix the problem soon ' 78 | 'and you will receive a notification.\n' 79 | 'Thanks for your feedback!') 80 | 81 | error_occured = _('Looks like an error occured... The Bot\'s developer already working on it...') 82 | 83 | donate_init = _('Select donate amount in $:') 84 | donate_invoice_title = _('${} donate') 85 | donate_invoice_description = _('Support zmanim bot with one-time ${} payment.') 86 | donate_thanks = _('Thank you so mach! Your support motivates the developer to spend more time on the bot development!') 87 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/geolocation.py: -------------------------------------------------------------------------------- 1 | from hashlib import md5 2 | from typing import List 3 | 4 | from aiogram.dispatcher import FSMContext 5 | from aiogram.types import InlineQuery, InlineQueryResultLocation, Message, CallbackQuery 6 | from aiogram.utils.exceptions import MessageNotModified 7 | from aiogram_metrics import track 8 | 9 | from zmanim_bot.helpers import CallbackPrefixes, parse_coordinates 10 | from zmanim_bot.integrations.geo_client import get_location_name, find_places_by_query 11 | from zmanim_bot.keyboards import inline 12 | from zmanim_bot.keyboards.menus import get_cancel_keyboard 13 | from zmanim_bot.misc import bot 14 | from zmanim_bot.repository.bot_repository import get_or_create_user 15 | from zmanim_bot.repository.models import User 16 | from zmanim_bot.service import settings_service 17 | from zmanim_bot.states import LocationNameState 18 | from zmanim_bot.texts.single import messages 19 | from zmanim_bot.utils import chat_action 20 | 21 | 22 | async def get_locations_by_location(lat: float, lng: float, user: User) -> List[InlineQueryResultLocation]: 23 | location_name = await get_location_name(lat, lng, user.language, no_trim=True) 24 | result_id = md5(f'{lat}, {lng}'.encode()).hexdigest() 25 | return [InlineQueryResultLocation(id=result_id, latitude=lat, longitude=lng, title=location_name)] 26 | 27 | 28 | async def handle_inline_location_query(query: InlineQuery): 29 | user = await get_or_create_user() 30 | 31 | if len(query.query) < 3: 32 | if not query.location: 33 | return # too short query to search 34 | results = await get_locations_by_location(query.location.latitude, query.location.longitude, user) 35 | else: 36 | results = await find_places_by_query(query.query, user.language) 37 | 38 | await bot.answer_inline_query(query.id, results) 39 | 40 | 41 | @chat_action('text') 42 | # @track('Location menu init') 43 | async def location_settings(msg: Message): 44 | kb = inline.get_location_management_kb() 45 | resp = messages.location_menu_init 46 | await msg.reply(resp, reply_markup=kb) 47 | 48 | 49 | # @track('Location menu back') 50 | async def back_to_location_settings(call: CallbackQuery): 51 | await call.answer() 52 | kb = inline.get_location_management_kb() 53 | resp = messages.location_menu_init 54 | await bot.edit_message_text(resp, call.from_user.id, call.message.message_id) 55 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 56 | 57 | 58 | @track('Location menu -> add new location') 59 | async def add_new_location(call: CallbackQuery): 60 | await call.answer() 61 | await bot.edit_message_text(messages.request_location, call.from_user.id, call.message.message_id) 62 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=inline.LOCATION_SEARCH_KB) 63 | 64 | 65 | @track('Location menu -> Manage saved locations') 66 | async def manage_saved_locations(call: CallbackQuery): 67 | await call.answer() 68 | resp, kb = await settings_service.get_locations_menu() 69 | await bot.edit_message_text(resp, call.from_user.id, call.message.message_id) 70 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 71 | 72 | 73 | async def handle_activate_location(call: CallbackQuery): 74 | location_name = call.data.split(CallbackPrefixes.location_activate)[1] 75 | alert, kb = await settings_service.activate_location(location_name) 76 | await call.answer(alert) 77 | 78 | try: 79 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 80 | except MessageNotModified: 81 | pass 82 | 83 | 84 | async def init_location_rename(call: CallbackQuery, state: FSMContext): 85 | await LocationNameState.waiting_for_location_name_state.set() 86 | location_name = call.data.split(CallbackPrefixes.location_rename)[1] 87 | 88 | state_data = { 89 | 'location_name': location_name, 90 | 'redirect_target': 'settings', 91 | 'redirect_message': messages.location_renamed.value, 92 | 'origin_message_id': call.message.message_id 93 | } 94 | 95 | await state.set_data(state_data) 96 | await call.answer() 97 | 98 | resp = messages.location_new_name_request.value.format(location_name) 99 | kb = get_cancel_keyboard() 100 | await bot.send_message(call.from_user.id, resp, reply_markup=kb) 101 | 102 | 103 | async def handle_delete_location(call: CallbackQuery): 104 | location_name = call.data.split(CallbackPrefixes.location_delete)[1] 105 | alert_text, kb = await settings_service.delete_location(location_name) 106 | await call.answer(alert_text, show_alert=True) 107 | 108 | if kb: 109 | await bot.edit_message_reply_markup(call.from_user.id, call.message.message_id, reply_markup=kb) 110 | 111 | 112 | @track('Location regexp') 113 | async def handle_location(msg: Message, state: FSMContext): 114 | if msg.location: 115 | lat = msg.location.latitude 116 | lng = msg.location.longitude 117 | else: 118 | lat, lng = parse_coordinates(msg.text) 119 | 120 | resp, kb, location_name = await settings_service.save_location(lat, lng) 121 | await LocationNameState().waiting_for_location_name_state.set() 122 | await state.set_data({ 123 | 'location_name': location_name, 124 | 'redirect_message': messages.location_saved.value 125 | }) 126 | await msg.reply(resp, reply_markup=kb) 127 | -------------------------------------------------------------------------------- /zmanim_bot/integrations/zmanim_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import date, datetime, time, timedelta 4 | from typing import List, Optional, Tuple 5 | 6 | from pydantic import BaseModel 7 | 8 | __all__ = [ 9 | 'Settings', 10 | 'SimpleSettings', 11 | 'AsurBeMelachaDay', 12 | 'DafYomi', 13 | 'RoshChodesh', 14 | 'Shabbat', 15 | 'YomTov', 16 | 'Holiday', 17 | 'Fast', 18 | 'Zmanim', 19 | 'IsraelHolidays' 20 | ] 21 | 22 | 23 | LEHUMRA_MINUS_MINUTE_NAMES = [ 24 | 'sof_zman_shema_ma', 'sof_zman_shema_gra', 'sof_zman_tefila_ma', 25 | 'sof_zman_tefila_gra', 'sunset', 'fast_start' 26 | ] 27 | LEHUMRA_TO_MIN = False 28 | LEHUMRA_TO_MAX = True 29 | 30 | 31 | def round_time_lehumra(dt: datetime, lehumra_to_max: bool) -> datetime: 32 | delta = timedelta(minutes=1 if lehumra_to_max else -1) 33 | return dt + delta 34 | 35 | 36 | class BaseModelWithZmanimLehumra(BaseModel): 37 | is_second_day: bool = False 38 | 39 | def apply_zmanim_lehumra(self): 40 | for name, value in self.dict(exclude={'settings'}).items(): 41 | if isinstance(value, datetime): 42 | if \ 43 | name in LEHUMRA_MINUS_MINUTE_NAMES or ( 44 | name == 'candle_lighting' and not self.is_second_day and value.weekday() != 5 45 | or (self.is_second_day and value.weekday() == 4) 46 | ): 47 | lehumra = LEHUMRA_TO_MIN 48 | else: 49 | lehumra = LEHUMRA_TO_MAX 50 | value_lehumra = round_time_lehumra(value, lehumra) 51 | setattr(self, name, value_lehumra) 52 | 53 | def __init__(self, **kwargs): 54 | super().__init__(**kwargs) 55 | self.apply_zmanim_lehumra() 56 | 57 | 58 | class SimpleSettings(BaseModel): 59 | date_: Optional[date] = None 60 | jewish_date: Optional[str] = None 61 | holiday_name: Optional[str] = None 62 | 63 | class Config: 64 | fields = {'date_': 'date'} 65 | 66 | 67 | class Settings(SimpleSettings): 68 | cl_offset: Optional[int] = None 69 | havdala_opinion: Optional[str] = None 70 | coordinates: Optional[Tuple[float, float]] = None 71 | elevation: Optional[int] = None 72 | fast_name: Optional[str] = None 73 | yomtov_name: Optional[str] = None 74 | 75 | 76 | class Zmanim(BaseModelWithZmanimLehumra): 77 | settings: Settings 78 | alos: Optional[datetime] = None 79 | misheyakir_10_2: Optional[datetime] = None 80 | sunrise: Optional[datetime] = None 81 | sof_zman_shema_ma: Optional[datetime] = None 82 | sof_zman_shema_gra: Optional[datetime] = None 83 | sof_zman_tefila_ma: Optional[datetime] = None 84 | sof_zman_tefila_gra: Optional[datetime] = None 85 | chatzos: Optional[datetime] = None 86 | mincha_gedola: Optional[datetime] = None 87 | mincha_ketana: Optional[datetime] = None 88 | plag_mincha: Optional[datetime] = None 89 | sunset: Optional[datetime] = None 90 | tzeis_5_95_degrees: Optional[datetime] = None 91 | tzeis_8_5_degrees: Optional[datetime] = None 92 | tzeis_42_minutes: Optional[datetime] = None 93 | tzeis_72_minutes: Optional[datetime] = None 94 | chatzot_laila: Optional[datetime] = None 95 | astronomical_hour_ma: Optional[time] = None 96 | astronomical_hour_gra: Optional[time] = None 97 | 98 | 99 | class AsurBeMelachaDay(BaseModelWithZmanimLehumra): 100 | date: Optional[date] = None 101 | candle_lighting: Optional[datetime] = None 102 | havdala: Optional[datetime] = None 103 | 104 | 105 | class SecondAsurBeMelachaDay(AsurBeMelachaDay): 106 | is_second_day = True 107 | 108 | 109 | class Shabbat(AsurBeMelachaDay): 110 | settings: Settings 111 | torah_part: str = None 112 | late_cl_warning: bool = False 113 | 114 | 115 | class RoshChodesh(BaseModel): 116 | settings: SimpleSettings 117 | month_name: str 118 | days: List[date] 119 | duration: int 120 | molad: Tuple[datetime, int] 121 | 122 | class Config: 123 | json_encoders = { 124 | datetime: lambda d: d.isoformat(timespec='minutes') 125 | } 126 | 127 | 128 | class DafYomi(BaseModel): 129 | settings: SimpleSettings 130 | masehet: str 131 | daf: int 132 | 133 | 134 | class Holiday(BaseModel): 135 | settings: SimpleSettings 136 | date: date 137 | 138 | 139 | class IsraelHolidays(BaseModel): 140 | settings: SimpleSettings 141 | holiday_list: List[Tuple[str, date]] 142 | 143 | 144 | class YomTov(BaseModel): 145 | settings: Settings 146 | 147 | pesach_eating_chanetz_till: Optional[datetime] = None 148 | pesach_burning_chanetz_till: Optional[datetime] = None 149 | 150 | pre_shabbat: Optional[AsurBeMelachaDay] = None 151 | day_1: AsurBeMelachaDay 152 | day_2: Optional[SecondAsurBeMelachaDay] = None 153 | post_shabbat: Optional[SecondAsurBeMelachaDay] = None 154 | hoshana_rabba: Optional[date] = None 155 | 156 | pesach_part_2_day_1: Optional[AsurBeMelachaDay] = None 157 | pesach_part_2_day_2: Optional[SecondAsurBeMelachaDay] = None 158 | pesach_part_2_post_shabat: Optional[AsurBeMelachaDay] = None 159 | 160 | 161 | class Fast(BaseModelWithZmanimLehumra): 162 | settings: Settings 163 | moved_fast: Optional[bool] = False 164 | fast_start: Optional[datetime] = None 165 | chatzot: Optional[datetime] = None 166 | havdala_5_95_dgr: Optional[datetime] = None 167 | havdala_8_5_dgr: Optional[datetime] = None 168 | havdala_42_min: Optional[datetime] = None 169 | -------------------------------------------------------------------------------- /zmanim_bot/service/settings_service.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple 2 | 3 | from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup 4 | 5 | from zmanim_bot import keyboards, texts 6 | from zmanim_bot.config import config 7 | from zmanim_bot.exceptions import ActiveLocationException 8 | from zmanim_bot.helpers import CallbackPrefixes 9 | from zmanim_bot.middlewares.i18n import i18n_ 10 | from zmanim_bot.repository import bot_repository 11 | from zmanim_bot.service import zmanim_service 12 | from zmanim_bot.states import FeedbackState 13 | from zmanim_bot.texts.single import messages 14 | 15 | 16 | async def get_current_cl() -> Tuple[str, InlineKeyboardMarkup]: 17 | current_cl = await bot_repository.get_or_set_cl() 18 | kb = keyboards.inline.get_cl_settings_keyboard(current_cl) 19 | return texts.single.messages.settings_cl, kb 20 | 21 | 22 | async def set_cl(call_data: str) -> InlineKeyboardMarkup: 23 | cl = int(call_data.split(CallbackPrefixes.cl)[1]) 24 | await bot_repository.get_or_set_cl(cl) 25 | 26 | kb = keyboards.inline.get_cl_settings_keyboard(cl) 27 | return kb 28 | 29 | 30 | async def get_current_havdala() -> Tuple[str, InlineKeyboardMarkup]: 31 | current_havdala = await bot_repository.get_or_set_havdala() 32 | kb = keyboards.inline.get_havdala_settings_keyboard(current_havdala) 33 | return texts.single.messages.settings_havdala, kb 34 | 35 | 36 | async def set_havdala(call_data: str) -> InlineKeyboardMarkup: 37 | havdala = call_data.split(CallbackPrefixes.havdala)[1] 38 | await bot_repository.get_or_set_havdala(havdala) 39 | 40 | kb = keyboards.inline.get_havdala_settings_keyboard(havdala) 41 | return kb 42 | 43 | 44 | async def get_current_zmanim() -> Tuple[str, InlineKeyboardMarkup]: 45 | current_zmanim = await bot_repository.get_or_set_zmanim() 46 | kb = keyboards.inline.get_zmanim_settings_keyboard(current_zmanim) 47 | return texts.single.messages.settings_zmanim, kb 48 | 49 | 50 | async def set_zmanim(call_data: str) -> InlineKeyboardMarkup: 51 | zman_name = call_data.split(CallbackPrefixes.zmanim)[1] 52 | current_zmanim = await bot_repository.get_or_set_zmanim() 53 | current_zmanim[zman_name] = not current_zmanim[zman_name] 54 | 55 | await bot_repository.get_or_set_zmanim(current_zmanim) 56 | kb = keyboards.inline.get_zmanim_settings_keyboard(current_zmanim) 57 | return kb 58 | 59 | 60 | async def get_current_omer() -> Tuple[str, InlineKeyboardMarkup]: 61 | current_omer = await bot_repository.get_or_set_omer_flag() 62 | kb = keyboards.inline.get_omer_kb(current_omer) 63 | return texts.single.messages.settings_omer, kb 64 | 65 | 66 | async def set_omer(call_data: str) -> InlineKeyboardMarkup: 67 | omer_flag = not bool(int(call_data.split(CallbackPrefixes.omer)[1])) 68 | zmanim = omer_flag and await zmanim_service.get_zmanim(must_have_8_5=True) 69 | await bot_repository.get_or_set_omer_flag(omer_flag, zmanim) 70 | 71 | kb = keyboards.inline.get_omer_kb(omer_flag) 72 | return kb 73 | 74 | 75 | async def get_current_format() -> Tuple[str, InlineKeyboardMarkup]: 76 | current_format = await bot_repository.get_or_set_processor_type() 77 | kb = keyboards.inline.get_format_options_kb(current_format) 78 | return texts.single.messages.settings_format, kb 79 | 80 | 81 | async def set_format(call_data: str) -> InlineKeyboardMarkup: 82 | current_format = call_data.split(CallbackPrefixes.format)[1] 83 | await bot_repository.get_or_set_processor_type(current_format) 84 | 85 | kb = keyboards.inline.get_format_options_kb(current_format) 86 | return kb 87 | 88 | 89 | async def set_language(lang: str): 90 | lang = config.LANGUAGE_SHORTCUTS[lang] 91 | await bot_repository.get_or_set_lang(lang) 92 | i18n_.ctx_locale.set(lang) 93 | 94 | 95 | async def get_locations_menu() -> Tuple[str, InlineKeyboardMarkup]: 96 | user = await bot_repository.get_or_create_user() 97 | kb = keyboards.inline.get_location_options_menu(user.location_list) 98 | 99 | return messages.settings_location, kb 100 | 101 | 102 | async def save_location(lat: float, lng: float) -> Tuple[str, ReplyKeyboardMarkup, str]: 103 | location = await bot_repository.get_or_set_location((lat, lng)) 104 | kb = keyboards.menus.get_done_keyboard() 105 | resp = messages.custom_location_name_request.value.format(location.name) 106 | return resp, kb, location.name 107 | 108 | 109 | async def activate_location(location_name: str) -> Tuple[str, InlineKeyboardMarkup]: 110 | location_list = await bot_repository.activate_location(location_name) 111 | resp = messages.location_activated.value.format(location_name) 112 | kb = keyboards.inline.get_location_options_menu(location_list) 113 | return resp, kb 114 | 115 | 116 | async def delete_location(location_name: str) -> Tuple[str, Optional[InlineKeyboardMarkup]]: 117 | try: 118 | location_list = await bot_repository.delete_location(location_name) 119 | kb = keyboards.inline.get_location_options_menu(location_list) 120 | msg = messages.location_deleted 121 | except ActiveLocationException: 122 | kb = None 123 | msg = messages.unable_to_delete_active_location 124 | 125 | return msg, kb 126 | 127 | 128 | async def update_location_name(new_name: str, old_name: Optional[str]) -> InlineKeyboardMarkup: 129 | if not old_name: 130 | raise ValueError('There is no old location name!') 131 | 132 | location_list = await bot_repository.set_location_name(new_name=new_name, old_name=old_name) 133 | kb = keyboards.inline.get_location_options_menu(location_list) 134 | return kb 135 | 136 | 137 | async def init_report() -> Tuple[str, ReplyKeyboardMarkup]: 138 | await FeedbackState.waiting_for_feedback_text.set() 139 | kb = keyboards.menus.get_cancel_keyboard() 140 | return texts.single.messages.init_report, kb 141 | -------------------------------------------------------------------------------- /zmanim_bot/keyboards/inline.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import List, Optional, TypeVar 3 | 4 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 5 | 6 | from zmanim_bot.config import config 7 | from zmanim_bot.helpers import (CL_OFFET_OPTIONS, HAVDALA_OPINION_OPTIONS, CallbackPrefixes) 8 | from zmanim_bot.processors.text_utils import humanize_date 9 | from zmanim_bot.texts.single import buttons, zmanim 10 | from zmanim_bot.texts.single.buttons import zmanim_for_date_prefix 11 | 12 | Location = TypeVar('Location') 13 | 14 | 15 | BACK_BUTTON = InlineKeyboardButton(text=buttons.back, callback_data=CallbackPrefixes.location_menu_back) 16 | 17 | LOCATION_SEARCH_KB = InlineKeyboardMarkup() 18 | LOCATION_SEARCH_KB.add(InlineKeyboardButton(text=buttons.search_location, switch_inline_query_current_chat='')) 19 | LOCATION_SEARCH_KB.add(BACK_BUTTON) 20 | 21 | DONATE_KB = InlineKeyboardMarkup() 22 | DONATE_KB.row(*[ 23 | InlineKeyboardButton( 24 | text=option, 25 | callback_data=f'{CallbackPrefixes.donate}{option}' 26 | ) for option in config.DONATE_OPTIONS 27 | ]) 28 | 29 | 30 | def shorten_name(name: str, limit: int) -> str: 31 | if len(name) <= limit: 32 | return name 33 | return f'{name[:limit]}...' 34 | 35 | 36 | def merge_inline_keyboards(kb1: InlineKeyboardMarkup, kb2: InlineKeyboardMarkup) -> InlineKeyboardMarkup: 37 | for row in kb2.inline_keyboard: 38 | kb1.row(*row) 39 | return kb1 40 | 41 | 42 | def get_cl_settings_keyboard(current_value: int) -> InlineKeyboardMarkup: 43 | kb = InlineKeyboardMarkup() 44 | row_1 = [] 45 | row_2 = [] 46 | 47 | for i, option in enumerate(CL_OFFET_OPTIONS): 48 | button = InlineKeyboardButton( 49 | text=f'{option}' if option != current_value else f'✅ {option}', 50 | callback_data=f'{CallbackPrefixes.cl}{option}' 51 | ) 52 | row_1.append(button) if i < 3 else row_2.append(button) 53 | 54 | kb.row(*row_1) 55 | kb.row(*row_2) 56 | return kb 57 | 58 | 59 | def get_havdala_settings_keyboard(current_value: str) -> InlineKeyboardMarkup: 60 | kb = InlineKeyboardMarkup() 61 | 62 | for option in HAVDALA_OPINION_OPTIONS: 63 | option_name = getattr(zmanim, option) 64 | button = InlineKeyboardButton( 65 | text=f'{option_name}' if option != current_value else f'✅ {option_name}', 66 | callback_data=f'{CallbackPrefixes.havdala}{option}' 67 | ) 68 | kb.add(button) 69 | return kb 70 | 71 | 72 | def get_zman_button(name: str, status: bool) -> InlineKeyboardButton: 73 | button = InlineKeyboardButton( 74 | text=f'{"✅" if status else "❌"} {getattr(zmanim, name)}', 75 | callback_data=f'{CallbackPrefixes.zmanim}{name}' 76 | ) 77 | return button 78 | 79 | 80 | def get_zmanim_settings_keyboard(zmanim_data: dict) -> InlineKeyboardMarkup: 81 | kb = InlineKeyboardMarkup() 82 | 83 | for name, status in zmanim_data.items(): 84 | row = [get_zman_button(name, status)] 85 | kb.row(*row) 86 | 87 | return kb 88 | 89 | 90 | def get_omer_kb(status: bool) -> InlineKeyboardMarkup: 91 | kb = InlineKeyboardMarkup() 92 | kb.row( 93 | InlineKeyboardButton( 94 | text=f'✅ {buttons.settings_enabled}' if status 95 | else f'❌ {buttons.settings_disabled}', 96 | callback_data=f'{CallbackPrefixes.omer}{int(status)}' 97 | ) 98 | ) 99 | return kb 100 | 101 | 102 | def get_zmanim_by_date_buttons(dates: List[date]) -> InlineKeyboardMarkup: 103 | kb = InlineKeyboardMarkup() 104 | for d in dates: 105 | kb.row(InlineKeyboardButton( 106 | text=f'{zmanim_for_date_prefix} {humanize_date([d])}', 107 | callback_data=f'{CallbackPrefixes.zmanim_by_date}{d.isoformat()}' 108 | )) 109 | return kb 110 | 111 | 112 | # todo: refactor imports and fix typing 113 | def get_location_options_menu(location_list: List[Location]) -> InlineKeyboardMarkup: 114 | kb = InlineKeyboardMarkup() 115 | 116 | for location in location_list: 117 | status = '🔘' if location.is_active else '⚪️' 118 | kb.row(InlineKeyboardButton( 119 | text=f'{status} {location.name}', 120 | callback_data=f'{CallbackPrefixes.location_activate}{location.name}' 121 | )) 122 | kb.row( 123 | InlineKeyboardButton(text='✏️', callback_data=f'{CallbackPrefixes.location_rename}{location.name}'), 124 | InlineKeyboardButton(text='❌', callback_data=f'{CallbackPrefixes.location_delete}{location.name}') 125 | ) 126 | 127 | kb.add(BACK_BUTTON) 128 | return kb 129 | 130 | 131 | def get_location_management_kb() -> InlineKeyboardMarkup: 132 | kb = InlineKeyboardMarkup() 133 | kb.add(InlineKeyboardButton( 134 | text=buttons.manage_locations, callback_data=CallbackPrefixes.location_namage 135 | )) 136 | kb.add(InlineKeyboardButton( 137 | text=buttons.add_location, callback_data=CallbackPrefixes.location_add 138 | )) 139 | return kb 140 | 141 | 142 | # todo: refactor imports and fix typing 143 | def get_location_variants_menu( 144 | locations: List[Location], 145 | current_loc: Location, 146 | callback_prefix: str, 147 | date_: Optional[date] = None 148 | ) -> Optional[InlineKeyboardMarkup]: 149 | if len(locations) < 2: 150 | return 151 | 152 | kb = InlineKeyboardMarkup() 153 | index = locations.index(current_loc) 154 | 155 | if date_: 156 | date_str = f':{date_}' 157 | else: 158 | date_str = '' 159 | 160 | if len(locations) == 2: 161 | new_index = int(not index) 162 | if current_loc.is_active: 163 | text = f'📍 {locations[new_index].name} ▶️' 164 | else: 165 | text = f'◀️ {locations[new_index].name} 📍' 166 | 167 | kb.row(InlineKeyboardButton( 168 | text=text, 169 | callback_data=f'' 170 | f'{callback_prefix}' 171 | f'{locations[new_index].lat},{locations[new_index].lng}' 172 | f'{date_str}' 173 | )) 174 | else: 175 | loc_len = len(locations) 176 | prev_index = ((index - 1) % loc_len + loc_len) % loc_len 177 | next_index = ((index + 1) % loc_len + loc_len) % loc_len 178 | 179 | kb.row( 180 | InlineKeyboardButton( 181 | text=f'◀️ {shorten_name(locations[prev_index].name, 13)} 📍', 182 | callback_data=f'' 183 | f'{callback_prefix}' 184 | f'{locations[prev_index].lat},{locations[prev_index].lng}' 185 | f'{date_str}' 186 | ), 187 | InlineKeyboardButton( 188 | text=f'📍 {shorten_name(locations[next_index].name, 13)} ▶️', 189 | callback_data=f'' 190 | f'{callback_prefix}' 191 | f'{locations[next_index].lat},{locations[next_index].lng}' 192 | f'{date_str}' 193 | ) 194 | ) 195 | 196 | return kb 197 | 198 | 199 | def get_format_options_kb(current_type: str) -> InlineKeyboardMarkup: 200 | kb = InlineKeyboardMarkup() 201 | row = [] 202 | for processor_type, button_name in buttons.processor_types.items(): 203 | button = InlineKeyboardButton( 204 | text=f'{button_name}' if processor_type != current_type else f'✅ {button_name}', 205 | callback_data=f'{CallbackPrefixes.format}{processor_type}' 206 | ) 207 | row.append(button) 208 | kb.row(*row) 209 | return kb 210 | -------------------------------------------------------------------------------- /zmanim_bot/repository/_storage.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime as dt 2 | from typing import List, Optional, Tuple 3 | 4 | import aiogram_metrics 5 | from aiogram import types 6 | 7 | from zmanim_bot.config import config 8 | from zmanim_bot.exceptions import ( 9 | ActiveLocationException, 10 | MaxLocationLimitException, 11 | NoLanguageException, NoLocationException, 12 | NonUniqueLocationException, 13 | NonUniqueLocationNameException 14 | ) 15 | from zmanim_bot.integrations.geo_client import get_location_name 16 | from zmanim_bot.misc import db_engine 17 | from .models import Location, User, UserInfo, ZmanimSettings 18 | 19 | __all__ = [ 20 | '_get_or_create_user', 21 | 'get_cl_offset', 22 | 'get_zmanim', 23 | 'get_havdala', 24 | 'get_lang', 25 | 'get_location', 26 | 'set_zmanim', 27 | 'set_cl', 28 | 'set_havdala', 29 | 'set_lang', 30 | 'set_location', 31 | 'do_set_location_name', 32 | 'do_activate_location', 33 | 'do_delete_location', 34 | 'get_processor_type', 35 | 'set_processor_type', 36 | 'get_omer_flag', 37 | 'set_omer_flag', 38 | ] 39 | 40 | from ..integrations.open_topo_data_client import get_elevation 41 | 42 | MAX_LOCATION_NAME_SIZE = 27 43 | 44 | 45 | def validate_location_coordinates(location: Location, locations: List[Location]): 46 | if len(locations) >= config.LOCATION_NUMBER_LIMIT: 47 | raise MaxLocationLimitException 48 | 49 | for loc in locations: 50 | if loc.lat == location.lat and loc.lng == location.lng: 51 | raise NonUniqueLocationException 52 | 53 | 54 | def validate_location_name(new_name: str, locations: List[Location]): 55 | for loc in locations: 56 | if loc.name == new_name: 57 | raise NonUniqueLocationNameException 58 | 59 | 60 | async def _get_or_create_user(tg_user: types.User) -> User: 61 | user = await db_engine.find_one(User, User.user_id == tg_user.id) 62 | 63 | if not user: 64 | user = User( 65 | user_id=tg_user.id, 66 | personal_info=UserInfo( 67 | first_name=tg_user.first_name, 68 | last_name=tg_user.last_name, 69 | username=tg_user.username 70 | ) 71 | ) 72 | await db_engine.save(user) 73 | aiogram_metrics.manual_track('New user has joined') 74 | 75 | elif user.personal_info.first_name != tg_user.first_name or \ 76 | user.personal_info.last_name != tg_user.last_name or \ 77 | user.personal_info.username != tg_user.username: 78 | user.personal_info = UserInfo( 79 | first_name=tg_user.first_name, 80 | last_name=tg_user.last_name, 81 | username=tg_user.username 82 | ) 83 | 84 | user.meta.last_seen_at = dt.now() 85 | await db_engine.save(user) 86 | 87 | return user 88 | 89 | 90 | async def get_lang(tg_user: types.User) -> Optional[str]: 91 | user = await _get_or_create_user(tg_user) 92 | lang = user.language 93 | if not lang: 94 | raise NoLanguageException 95 | return lang 96 | 97 | 98 | async def set_lang(tg_user: types.User, lang: str): 99 | user = await _get_or_create_user(tg_user) 100 | user.language = lang 101 | await db_engine.save(user) 102 | 103 | 104 | async def get_location(tg_user: types.User) -> Tuple[float, float]: 105 | user = await _get_or_create_user(tg_user) 106 | location = list(filter(lambda loc: loc.is_active is True, user.location_list)) 107 | if not location: 108 | raise NoLocationException 109 | 110 | return location[0].lat, location[0].lng 111 | 112 | 113 | async def set_location(tg_user: types.User, location: Tuple[float, float]) -> Location: 114 | user = await _get_or_create_user(tg_user) 115 | location_name = await get_location_name(location[0], location[1], user.language) 116 | if len(location_name) > MAX_LOCATION_NAME_SIZE: 117 | location_name = f'{location_name[:MAX_LOCATION_NAME_SIZE]}...' 118 | 119 | elevation = await get_elevation(*location) 120 | 121 | location_obj = Location( 122 | lat=location[0], 123 | lng=location[1], 124 | name=location_name, 125 | is_active=True, 126 | elevation=elevation 127 | ) 128 | validate_location_coordinates(location_obj, user.location_list) 129 | 130 | for i in range(len(user.location_list)): 131 | user.location_list[i].is_active = False 132 | 133 | user.location_list.append(location_obj) 134 | await db_engine.save(user) 135 | return location_obj 136 | 137 | 138 | async def do_set_location_name(tg_user: types.User, new_name: str, old_name: str) -> List[Location]: 139 | user = await _get_or_create_user(tg_user) 140 | location = list(filter(lambda l: l.name == old_name, user.location_list)) 141 | 142 | if len(location) == 0: 143 | raise ValueError('Unknown old location name!') 144 | validate_location_name(new_name, user.location_list) 145 | 146 | if len(new_name) > MAX_LOCATION_NAME_SIZE: 147 | new_name = f'{new_name[:MAX_LOCATION_NAME_SIZE]}...' 148 | 149 | location = location[0] 150 | location.name = new_name 151 | 152 | location_index = user.location_list.index(location) 153 | user.location_list[location_index] = location 154 | await db_engine.save(user) 155 | return user.location_list 156 | 157 | 158 | async def do_activate_location(tg_user: types.User, name: str) -> List[Location]: 159 | user = await _get_or_create_user(tg_user) 160 | 161 | if len(user.location_list) == 1: 162 | return user.location_list 163 | 164 | for location in user.location_list: 165 | location.is_active = False 166 | 167 | location = list(filter(lambda l: l.name == name, user.location_list)) 168 | if len(location) == 0: 169 | raise ValueError('Unknown location name!') 170 | 171 | index = user.location_list.index(location[0]) 172 | user.location_list[index].is_active = True 173 | 174 | await db_engine.save(user) 175 | return user.location_list 176 | 177 | 178 | async def do_delete_location(tg_user: types.User, name: str) -> List[Location]: 179 | user = await _get_or_create_user(tg_user) 180 | location = list(filter(lambda l: l.name == name, user.location_list)) 181 | 182 | if len(location) == 0: 183 | raise ValueError('Unknown location name!') 184 | if location[0].is_active: 185 | raise ActiveLocationException() 186 | 187 | user.location_list.remove(location[0]) 188 | await db_engine.save(user) 189 | return user.location_list 190 | 191 | 192 | async def get_cl_offset(tg_user: types.User) -> int: 193 | user = await _get_or_create_user(tg_user) 194 | return user.cl_offset 195 | 196 | 197 | async def set_cl(tg_user: types.User, cl: int): 198 | user = await _get_or_create_user(tg_user) 199 | user.cl_offset = cl 200 | await db_engine.save(user) 201 | 202 | 203 | async def get_havdala(tg_user: types.User) -> str: 204 | user = await _get_or_create_user(tg_user) 205 | return user.havdala_opinion 206 | 207 | 208 | async def set_havdala(tg_user: types.User, havdala: str): 209 | user = await _get_or_create_user(tg_user) 210 | user.havdala_opinion = havdala 211 | await db_engine.save(user) 212 | 213 | 214 | async def get_zmanim(tg_user: types.User) -> dict: 215 | user = await _get_or_create_user(tg_user) 216 | return user.zmanim_settings.dict() 217 | 218 | 219 | async def set_zmanim(tg_user: types.User, zmanim: dict): 220 | zmanim_obj = ZmanimSettings(**zmanim) 221 | user = await _get_or_create_user(tg_user) 222 | user.zmanim_settings = zmanim_obj 223 | await db_engine.save(user) 224 | 225 | 226 | async def get_processor_type(tg_user: types.User) -> str: 227 | user = await _get_or_create_user(tg_user) 228 | return user.processor_type 229 | 230 | 231 | async def set_processor_type(tg_user: types.User, processor_type: str): 232 | user = await _get_or_create_user(tg_user) 233 | user.processor_type = processor_type 234 | await db_engine.save(user) 235 | 236 | 237 | async def get_omer_flag(tg_user: types.User) -> bool: 238 | user = await _get_or_create_user(tg_user) 239 | return user.omer.is_enabled 240 | 241 | 242 | async def set_omer_flag(tg_user: types.User, omer_flag: bool, omer_time: Optional[str] = None): 243 | user = await _get_or_create_user(tg_user) 244 | 245 | user.omer.is_enabled = omer_flag 246 | user.omer.notification_time = omer_time 247 | 248 | await db_engine.save(user) 249 | -------------------------------------------------------------------------------- /zmanim_bot/texts/single/names.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from babel.support import LazyProxy 4 | 5 | from zmanim_bot.middlewares.i18n import lazy_gettext as _ 6 | 7 | MONTH_NAMES_GENETIVE: Dict[int, LazyProxy] = { 8 | # NOTE Genative month name 9 | 1: _('January'), 10 | # NOTE Genative month name 11 | 2: _('February'), 12 | # NOTE Genative month name 13 | 3: _('March'), 14 | # NOTE Genative month name 15 | 4: _('April'), 16 | # NOTE Genative month name 17 | 5: _('May'), 18 | # NOTE Genative month name 19 | 6: _('June'), 20 | # NOTE Genative month name 21 | 7: _('July'), 22 | # NOTE Genative month name 23 | 8: _('August'), 24 | # NOTE Genative month name 25 | 9: _('September'), 26 | # NOTE Genative month name 27 | 10: _('October'), 28 | # NOTE Genative month name 29 | 11: _('November'), 30 | # NOTE Genative month name 31 | 12: _('December'), 32 | 33 | } 34 | 35 | WEEKDAYS: Dict[int, LazyProxy] = { 36 | 0: _('Monday'), 37 | 1: _('Tuesday'), 38 | 2: _('Wednesday'), 39 | 3: _('Thursday'), 40 | 4: _('Friday'), 41 | 5: _('Saturday'), 42 | 6: _('Sunday') 43 | } 44 | 45 | # Titles 46 | title_daf_yomi = _('DAF YOMI') 47 | title_rosh_chodesh = _('ROSH CHODESH') 48 | title_shabbath = _('SHABBAT') 49 | title_zmanim = _('ZMANIM') 50 | FASTS_TITLES = { 51 | 'fast_gedalia': _('FAST OF GEDALIAH'), 52 | 'fast_10_teves': _('10th OF TEVET'), 53 | 'fast_esther': _('FAST OF ESTHER'), 54 | 'fast_17_tammuz': _('17th OF TAMMUZ'), 55 | 'fast_9_av': _('9th OF AV') 56 | } 57 | HOLIDAYS_TITLES = { 58 | 'chanukah': _('CHANUKAH'), 59 | 'tu_bi_shvat': _('TU BI-SHVAT'), 60 | 'purim': _('PURIM'), 61 | 'lag_baomer': _('LAG BA-OMER'), 62 | 'israel_holidays': _('ISRAEL HOLIDAYS') 63 | } 64 | YOMTOVS_TITLES = { 65 | 'rosh_hashana': _('ROSH HA-SHANA'), 66 | 'yom_kippur': _('YOM KIPUR'), 67 | 'succot': _('SUCCOT'), 68 | 'shmini_atzeres': _('SHMINI ATZERET'), 69 | 'pesach': _('PESACH'), 70 | 'shavuot': _('SHAVUOT') 71 | } 72 | 73 | shabbat = _('shabbat') 74 | 75 | TORAH_PARTS = { 76 | 'bereishis': _('Bereshit'), 77 | 'noach': _('Noach'), 78 | 'lech_lecha': _('Lech-Lecha'), 79 | 'vayeira': _('Vayeira'), 80 | 'chayei_sarah': _('Chayei Sarah'), 81 | 'toldos': _('Toledot'), 82 | 'vayeitzei': _('Vayetze'), 83 | 'vayishlach': _('Vayishlach'), 84 | 'vayeishev': _('Vayeshev'), 85 | 'mikeitz': _('Miketz'), 86 | 'vayigash': _('Vayigash'), 87 | 'vayechi': _('Vayechi'), 88 | 'shemos': _('Shemot'), 89 | 'vaeirah': _('Va\'eira'), 90 | 'bo': _('Bo'), 91 | 'beshalach': _('Beshalach'), 92 | 'yisro': _('Yitro'), 93 | 'mishpatim': _('Mishpatim'), 94 | 'terumah': _('Terumah'), 95 | 'tetzaveh': _('Tetzaveh'), 96 | 'ki_sisa': _('Ki Tisa'), 97 | 'vayakheil': _('Vayakhel'), 98 | 'pekudei': _('Pekudei'), 99 | 'vayikra': _('Vayikra'), 100 | 'tzav': _('Tzav'), 101 | 'shemini': _('Shemini'), 102 | 'tazria': _('Tazria'), 103 | 'metzora': _('Metzora'), 104 | 'acharei': _('Acharei Mot'), 105 | 'kedoshim': _('Kedoshim'), 106 | 'emor': _('Emor'), 107 | 'behar': _('Behar'), 108 | 'bechukosai': _('Bechukotai'), 109 | 'bamidbar': _('Bamidbar'), 110 | 'naso': _('Naso'), 111 | 'behaalosecha': _('Behaalotecha'), 112 | 'shelach': _('Shlach'), 113 | 'korach': _('Korach'), 114 | 'chukas': _('Chukat'), 115 | 'balak': _('Balak'), 116 | 'pinchas': _('Pinchas'), 117 | 'matos': _('Matot'), 118 | 'masei': _('Masei'), 119 | 'devarim': _('Devarim'), 120 | 'vaeschanan': _('Va\'etchanan'), 121 | 'eikev': _('Eikev'), 122 | 'reei': _('Re\'eh'), 123 | 'shoftim': _('Shoftim'), 124 | 'ki_seitzei': _('Ki Teitzei'), 125 | 'ki_savo': _('Ki Tavo'), 126 | 'nitzavim': _('Nitzavim'), 127 | 'vayeilech': _('Vayelech'), 128 | 'haazinu': _('Haazinu'), 129 | 'vezos_haberacha': _('V\'Zot HaBerachah'), 130 | 131 | 'vayakheil - pikudei': _('Vayakhel - Pekudei'), 132 | 'tazria - metzora': _('Tazria - Metzora'), 133 | 'acharei - kedoshim': _('Acharei Mot - Kedoshim'), 134 | 'behar - bechukosai': _('Behar - Bechukotai'), 135 | 'chukas - balak': _('Chukat - Balak'), 136 | 'matos - masei': _('Matot - Masei'), 137 | 'nitzavim - vayeilech': _('Nitzavim - Vayelech'), 138 | 139 | 'rosh_hashana': _('Shabbat Rosh ha-Shana'), 140 | 'yom_kippur': _('Shabbat Yom Kippur'), 141 | 'succos': _('Shabat Succot'), 142 | 'chol_hamoed_succos': _('Shabbat Chol ha-moed Succot'), 143 | 'hoshana_rabbah': _('Shabbat Chol ha-moed Succot'), 144 | 'shemini_atzeres': _('Shabbat Shmini Atzeret'), 145 | 'simchas_torah': _('Shabbat Simchat Torah'), 146 | 'pesach': _('Shabbat Pesach'), 147 | 'chol_hamoed_pesach': _('Shabbat Chol ha-moed Pesach'), 148 | 'shavuos': _('Shabbat Shavuot') 149 | } 150 | 151 | GEMARA_BOOKS = { 152 | 'berachos': _('Brachot'), 153 | # NOTE masehet name; It is very important to add space after the month name!! 154 | 'shabbos': _('Shabbat '), 155 | 'eruvin': _('Eruvin'), 156 | 'pesachim': _('Pesachim'), 157 | 'shekalim': _('Shekalim'), 158 | 'yoma': _('Yoma'), 159 | 'sukkah': _('Sukah'), 160 | 'beitzah': _('Beitzah'), 161 | 'rosh_hashanah': _('Rosh Hashana'), 162 | 'taanis': _('Taanit'), 163 | 'megillah': _('Megilah'), 164 | 'moed_katan': _('Moed Katan'), 165 | 'chagigah': _('Chagigah'), 166 | 'yevamos': _('Yevamot'), 167 | 'kesubos': _('Kesuvot'), 168 | 'nedarim': _('Nedarim'), 169 | 'nazir': _('Nazir'), 170 | 'sotah': _('Sotah'), 171 | 'gitin': _('Gitin'), 172 | 'kiddushin': _('Kidushin'), 173 | 'bava_kamma': _('Bava Kama'), 174 | 'bava_metzia': _('Bava Metzia'), 175 | 'bava_basra': _('Bava Batra'), 176 | 'sanhedrin': _('Sanhedrin'), 177 | 'makkos': _('Makot'), 178 | 'shevuos': _('Shevuot'), 179 | 'avodah_zarah': _('Avodah Zarah'), 180 | 'horiyos': _('Horayot'), 181 | 'zevachim': _('Zevachim'), 182 | 'menachos': _('Menachot'), 183 | 'chullin': _('Chulin'), 184 | 'bechoros': _('Bechorot'), 185 | 'arachin': _('Erchin'), 186 | 'temurah': _('Temurah'), 187 | 'kerisos': _('Kerisot'), 188 | 'meilah': _('Meilah'), 189 | 'kinnim': _('Kinnim'), 190 | 'tamid': _('Tamid'), 191 | 'midos': _('Midot'), 192 | 'niddah': _('Nidah') 193 | } 194 | 195 | JEWISH_MONTHS = { 196 | # NOTE It is very important to add space after the month name!! 197 | 'nissan': _('Nisan '), 198 | # NOTE It is very important to add space after the month name!! 199 | 'iyar': _('Iyar '), 200 | # NOTE It is very important to add space after the month name!! 201 | 'sivan': _('Sivan '), 202 | # NOTE It is very important to add space after the month name!! 203 | 'tammuz': _('Tammuz '), 204 | # NOTE It is very important to add space after the month name!! 205 | 'av': _('Av '), 206 | # NOTE It is very important to add space after the month name!! 207 | 'elul': _('Elul '), 208 | # NOTE It is very important to add space after the month name!! 209 | 'tishrei': _('Tishrei '), 210 | # NOTE It is very important to add space after the month name!! 211 | 'cheshvan': _('Cheshvan '), 212 | # NOTE It is very important to add space after the month name!! 213 | 'kislev': _('Kislev '), 214 | # NOTE It is very important to add space after the month name!! 215 | 'teves': _('Tevet '), 216 | # NOTE It is very important to add space after the month name!! 217 | 'shevat': _('Shevat '), 218 | # NOTE It is very important to add space after the month name!! 219 | 'adar': _('Adar '), 220 | # NOTE It is very important to add space after the month name!! 221 | 'adar_ii': _('Adar II '), 222 | } 223 | 224 | JEWISH_MONTHS_GENITIVE = { 225 | # NOTE Genative month name 226 | 'nissan': _('Nisan'), 227 | # NOTE Genative month name 228 | 'iyar': _('Iyar'), 229 | # NOTE Genative month name 230 | 'sivan': _('Sivan'), 231 | # NOTE Genative month name 232 | 'tammuz': _('Tammuz'), 233 | # NOTE Genative month name 234 | 'av': _('Av'), 235 | # NOTE Genative month name 236 | 'elul': _('Elul'), 237 | # NOTE Genative month name 238 | 'tishrei': _('Tishrei'), 239 | # NOTE Genative month name 240 | 'cheshvan': _('Cheshvan'), 241 | # NOTE Genative month name 242 | 'kislev': _('Kislev'), 243 | # NOTE Genative month name 244 | 'teves': _('Tevet'), 245 | # NOTE Genative month name 246 | 'shevat': _('Shevat'), 247 | # NOTE Genative month name 248 | 'adar': _('Adar'), 249 | # NOTE Genative month name 250 | 'adar_ii': _('Adar II'), 251 | } 252 | -------------------------------------------------------------------------------- /zmanim_bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import ContentType 2 | 3 | from zmanim_bot import exceptions as e 4 | from zmanim_bot.admin.states import AdminReportResponse 5 | from zmanim_bot.config import config 6 | from zmanim_bot.helpers import CallbackPrefixes, LOCATION_PATTERN 7 | from zmanim_bot.misc import dp 8 | from zmanim_bot.states import FeedbackState, ConverterGregorianDateState, ConverterJewishDateState, \ 9 | LocationNameState, ZmanimGregorianDateState 10 | from . import admin, converter, errors, festivals, forms, main, menus, settings, \ 11 | incorrect_text_handler, reset_handler, payments, geolocation 12 | from ..texts.single import buttons 13 | 14 | __all__ = ['register_handlers'] 15 | 16 | 17 | def register_handlers(): 18 | # errors 19 | dp.register_errors_handler(errors.empty_exception_handler, exception=e.UnsupportedChatTypeException) 20 | dp.register_errors_handler(errors.access_denied_exception_handler, exception=e.AccessDeniedException) 21 | dp.register_errors_handler(errors.no_location_exception_handler, exception=e.NoLocationException) 22 | dp.register_errors_handler(errors.incorrect_location_exception_handler, exception=e.IncorrectLocationException) 23 | dp.register_errors_handler(errors.non_unique_location_exception_handler, exception=e.NonUniqueLocationException) 24 | dp.register_errors_handler(errors.non_unique_location_name_exception_handler, exception=e.NonUniqueLocationNameException) 25 | dp.register_errors_handler(errors.location_limit_exception_handler, exception=e.MaxLocationLimitException) 26 | dp.register_errors_handler(errors.no_language_exception_handler, exception=e.NoLanguageException) 27 | dp.register_errors_handler(errors.gregorian_date_exception_handler, exception=e.IncorrectGregorianDateException) 28 | dp.register_errors_handler(errors.jewish_date_exception_handler, exception=e.IncorrectJewishDateException) 29 | dp.register_errors_handler(errors.polar_coordinates_exception_handler, exception=e.PolarCoordinatesException) 30 | dp.register_errors_handler(errors.unknown_processor_exception_handler, exception=e.UnknownProcessorException) 31 | dp.register_errors_handler(errors.main_errors_handler, exception=Exception) 32 | 33 | # reset handlers whech clears any state (should be first!) 34 | dp.register_message_handler(reset_handler.handle_start, commands=['start']) 35 | dp.register_message_handler(reset_handler.handle_back, text=[buttons.back, buttons.cancel], state="*") 36 | 37 | # admin 38 | dp.register_message_handler(admin.handle_report_response, lambda msg: msg.from_user.id in config.REPORT_ADMIN_LIST, state=AdminReportResponse.waiting_for_response_text) 39 | dp.register_message_handler(admin.handle_done_report, text=buttons.done, state=AdminReportResponse.waiting_for_payload) 40 | dp.register_message_handler(admin.handle_report_payload, content_types=ContentType.ANY, state=AdminReportResponse.waiting_for_payload) 41 | dp.register_callback_query_handler(admin.handle_report, lambda call: call.from_user.id in config.REPORT_ADMIN_LIST, text_startswith=CallbackPrefixes.report) 42 | 43 | # forms/states 44 | dp.register_message_handler(forms.handle_report, state=FeedbackState.waiting_for_feedback_text) 45 | dp.register_message_handler(forms.handle_done_report, text=buttons.done, state=FeedbackState.waiting_for_payload) 46 | dp.register_message_handler(forms.handle_report_payload, content_types=ContentType.ANY, state=FeedbackState.waiting_for_payload) 47 | dp.register_message_handler(forms.handle_converter_gregorian_date, state=ConverterGregorianDateState.waiting_for_gregorian_date) 48 | dp.register_message_handler(forms.handle_converter_jewish_date, state=ConverterJewishDateState.waiting_for_jewish_date) 49 | dp.register_message_handler(forms.handle_zmanim_gregorian_date, state=ZmanimGregorianDateState.waiting_for_gregorian_date) 50 | dp.register_message_handler(forms.handle_location_name, state=LocationNameState.waiting_for_location_name_state) 51 | 52 | # main 53 | dp.register_message_handler(main.handle_zmanim, text=buttons.mm_zmanim) 54 | dp.register_message_handler(main.handle_zmanim_by_date, text=buttons.mm_zmanim_by_date) 55 | dp.register_message_handler(main.handle_shabbat, text=buttons.mm_shabbat) 56 | dp.register_message_handler(main.handle_daf_yomi, text=buttons.mm_daf_yomi) 57 | dp.register_message_handler(main.handle_rosh_chodesh, text=buttons.mm_rh) 58 | 59 | dp.register_callback_query_handler(main.handle_zmanim_by_date_callback, text_startswith=CallbackPrefixes.zmanim_by_date) 60 | dp.register_callback_query_handler(main.handle_update_zmanim, text_startswith=CallbackPrefixes.update_zmanim) 61 | dp.register_callback_query_handler(main.handle_update_shabbat, text_startswith=CallbackPrefixes.update_shabbat) 62 | 63 | # menus 64 | dp.register_message_handler(menus.handle_holidays_menu, text=[buttons.mm_holidays, buttons.hom_main]) 65 | dp.register_message_handler(menus.handle_more_holidays_menu, text=buttons.hom_more) 66 | dp.register_message_handler(menus.handle_fasts_menu, text=buttons.mm_fasts) 67 | dp.register_message_handler(menus.handle_settings_menu, commands=['settings']) 68 | dp.register_message_handler(menus.handle_settings_menu, text=buttons.mm_settings) 69 | dp.register_message_handler(menus.handle_donate, text=buttons.mm_donate) 70 | dp.register_message_handler(menus.handle_donate, commands=['donate']) 71 | 72 | # festivals 73 | dp.register_message_handler(festivals.handle_fast, text=buttons.FASTS) 74 | dp.register_message_handler(festivals.handle_yom_tov, text=buttons.YOMTOVS) 75 | dp.register_message_handler(festivals.handle_holiday, text=buttons.HOLIDAYS) 76 | 77 | dp.register_callback_query_handler(festivals.handle_fast_update, text_startswith=CallbackPrefixes.update_fast) 78 | dp.register_callback_query_handler(festivals.handle_yom_tov_update, text_startswith=CallbackPrefixes.update_yom_tov) 79 | 80 | # converter 81 | dp.register_message_handler(converter.handle_converter_entry, commands=['converter_api']) 82 | dp.register_message_handler(converter.handle_converter_entry, text=buttons.mm_converter) 83 | dp.register_message_handler(converter.start_greg_to_jew_converter, text=buttons.conv_greg_to_jew) 84 | dp.register_message_handler(converter.start_jew_to_greg_converter, text=buttons.conv_jew_to_greg) 85 | 86 | # settings 87 | dp.register_message_handler(settings.settings_menu_cl, text=buttons.sm_candle) 88 | dp.register_message_handler(settings.settings_menu_havdala, text=buttons.sm_havdala) 89 | dp.register_message_handler(settings.settings_menu_zmanim, text=buttons.sm_zmanim) 90 | dp.register_message_handler(settings.handle_omer_settings, text=buttons.sm_omer) 91 | dp.register_message_handler(settings.handle_language_request, commands=['language']) 92 | dp.register_message_handler(settings.handle_language_request, text=buttons.sm_lang) 93 | dp.register_message_handler(settings.set_language, text=config.LANGUAGE_LIST) 94 | dp.register_message_handler(settings.help_menu_report, commands=['report']) 95 | dp.register_message_handler(settings.help_menu_report, text=buttons.hm_report) 96 | dp.register_message_handler(settings.handle_format_settings, text=buttons.sm_format) 97 | 98 | dp.register_callback_query_handler(settings.set_cl, text_startswith=CallbackPrefixes.cl) 99 | dp.register_callback_query_handler(settings.set_havdala, text_startswith=CallbackPrefixes.havdala) 100 | dp.register_callback_query_handler(settings.set_zmanim, text_startswith=CallbackPrefixes.zmanim) 101 | dp.register_callback_query_handler(settings.set_omer, text_startswith=CallbackPrefixes.omer) 102 | dp.register_callback_query_handler(settings.set_format, text_startswith=CallbackPrefixes.format) 103 | 104 | # location 105 | dp.register_message_handler(geolocation.location_settings, commands=['location']) 106 | dp.register_message_handler(geolocation.location_settings, text=buttons.sm_location) 107 | dp.register_message_handler(geolocation.handle_location, regexp=LOCATION_PATTERN) 108 | dp.register_message_handler(geolocation.handle_location, content_types=[ContentType.LOCATION, ContentType.VENUE]) 109 | 110 | dp.register_callback_query_handler(geolocation.add_new_location, text_startswith=CallbackPrefixes.location_add) 111 | dp.register_callback_query_handler(geolocation.manage_saved_locations, text_startswith=CallbackPrefixes.location_namage) 112 | dp.register_callback_query_handler(geolocation.back_to_location_settings, text_startswith=CallbackPrefixes.location_menu_back) 113 | dp.register_callback_query_handler(geolocation.handle_activate_location, text_startswith=CallbackPrefixes.location_activate) 114 | dp.register_callback_query_handler(geolocation.init_location_rename, text_startswith=CallbackPrefixes.location_rename) 115 | dp.register_callback_query_handler(geolocation.handle_delete_location, text_startswith=CallbackPrefixes.location_delete) 116 | 117 | dp.register_inline_handler(geolocation.handle_inline_location_query) 118 | 119 | # payments 120 | dp.register_callback_query_handler(payments.handle_donate, text_startswith=CallbackPrefixes.donate) 121 | dp.register_pre_checkout_query_handler(payments.handle_pre_checkout) 122 | dp.register_message_handler(payments.on_success_payment, content_types=[ContentType.SUCCESSFUL_PAYMENT]) 123 | dp.register_message_handler(payments.on_success_payment, content_types=[ContentType]) 124 | 125 | # unknown messages (SHOULD BE LAST!) 126 | dp.register_message_handler(incorrect_text_handler.handle_incorrect_text) 127 | -------------------------------------------------------------------------------- /zmanim_bot/processors/text/composer.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime as dt, date, timedelta 2 | from typing import Tuple, Optional, Union 3 | 4 | from aiogram.types import InlineKeyboardMarkup 5 | from aiogram.utils.markdown import hbold, hitalic, hcode 6 | from babel.support import LazyProxy 7 | 8 | from zmanim_bot import texts 9 | from zmanim_bot.exceptions import PolarCoordinatesException 10 | from zmanim_bot.integrations.zmanim_models import Zmanim, Shabbat, RoshChodesh, DafYomi, Holiday, \ 11 | IsraelHolidays, Fast, YomTov, AsurBeMelachaDay 12 | from zmanim_bot.keyboards.inline import get_zmanim_by_date_buttons 13 | from zmanim_bot.middlewares.i18n import gettext as _ 14 | from zmanim_bot.processors.text_utils import humanize_date, parse_jewish_date, humanize_time 15 | from zmanim_bot.texts.plural import units 16 | from zmanim_bot.texts.single import names, helpers, headers 17 | 18 | 19 | def _emojize_holiday_title(holiday_type: str) -> str: 20 | emojis = { 21 | 'chanukah': '🕎', 22 | 'tu_bi_shvat': '🌳', 23 | 'purim': '🎭', 24 | 'lag_baomer': '🔥', 25 | 'israel_holidays': '🇮🇱' 26 | } 27 | resp = f'{emojis[holiday_type]} {names.HOLIDAYS_TITLES[holiday_type]}' 28 | return resp 29 | 30 | 31 | def _emojize_yom_tov_title(yom_tov_type: str) -> str: 32 | emojis = { 33 | 'rosh_hashana': '🍯', 34 | 'yom_kippur': '⚖️', 35 | 'succot': '⛺️', 36 | 'shmini_atzeres': '🏁', 37 | 'pesach': '🍷', 38 | 'shavuot': '🌾' 39 | } 40 | resp = f'{emojis[yom_tov_type]} {names.YOMTOVS_TITLES[yom_tov_type]}' 41 | return resp 42 | 43 | 44 | def _compose_response( 45 | title: Union[LazyProxy, str], 46 | content: str, 47 | date_str: Optional[str] = None, 48 | location: Optional[str] = None 49 | ) -> str: 50 | title = hbold(title) 51 | location_str = f'📍 {hcode(location)}\n\n' if location else '' 52 | date_str = f'🗓 {date_str}\n' if date_str else '' 53 | if date_str and not location_str: 54 | date_str = f'{date_str}\n' 55 | resp = f'{title}\n\n' \ 56 | f'{date_str}' \ 57 | f'{location_str}' \ 58 | f'{content}' 59 | return resp 60 | 61 | 62 | def compose_zmanim(data: Zmanim, location_name: str) -> str: 63 | zmanim_rows: dict[str, dt] = data.dict(exclude={'settings', 'is_second_day'}, exclude_none=True) 64 | 65 | if len(zmanim_rows) == 0: 66 | raise PolarCoordinatesException() 67 | 68 | text_lines = [] 69 | 70 | for zman_name, zman_value in zmanim_rows.items(): 71 | header = hbold(getattr(texts.single.zmanim, zman_name) + ':') 72 | value = humanize_time(zman_value) if isinstance(zman_value, date) \ 73 | else humanize_time(zman_value) 74 | line = f'{header} {value}' 75 | text_lines.append(line) 76 | 77 | content = '\n'.join(text_lines) 78 | 79 | date_str = hitalic(f'{humanize_date([data.settings.date_])} / ' 80 | f'{parse_jewish_date(data.settings.jewish_date)}') 81 | resp = _compose_response(f'🕓 {names.title_zmanim}', content, date_str, location_name) 82 | return resp 83 | 84 | 85 | def compose_shabbat(data: Shabbat, location_name: str) -> Tuple[str, Optional[InlineKeyboardMarkup]]: 86 | torah_part = names.TORAH_PARTS.get(data.torah_part, '') 87 | date_str = hitalic(humanize_date([data.havdala.date()])) if data.havdala else None 88 | torah_part_str = f'{hbold(headers.parsha + ":")} {torah_part}' 89 | 90 | if not data.candle_lighting: 91 | polar_error = hitalic('\n' + helpers.cl_error_warning) 92 | content = f'{torah_part_str}\n\n{polar_error}' 93 | resp = _compose_response(names.title_shabbath, content, date_str, location_name) 94 | return resp, None 95 | 96 | cl_str = f'{hbold(headers.cl + ":")} {humanize_time(data.candle_lighting)}' 97 | cl_offset = data.settings.cl_offset 98 | cl_offset_str = hitalic(f'({cl_offset} {_(*units.tu_minute, cl_offset)} {helpers.cl_offset})') 99 | 100 | havdala_str = f'{hbold(headers.havdala + ":")} {humanize_time(data.havdala)}' 101 | havdala_opinion = getattr(texts.single.zmanim, data.settings.havdala_opinion) 102 | opinion_value = havdala_opinion.value.split('[')[1].split(']')[0] 103 | havdala_opinion_str = hitalic(f'({opinion_value})') 104 | 105 | late_cl_warning = '\n\n' + hitalic(helpers.cl_late_warning) if data.late_cl_warning else '' 106 | 107 | content = f'{torah_part_str}\n' \ 108 | f'{cl_str} {cl_offset_str}\n' \ 109 | f'{havdala_str} {havdala_opinion_str} {late_cl_warning}' 110 | resp = _compose_response(f'🕯 {names.title_shabbath}', content, date_str, location_name) 111 | 112 | kb = get_zmanim_by_date_buttons([data.havdala.date()]) 113 | return resp, kb 114 | 115 | 116 | def compose_rosh_chodesh(data: RoshChodesh) -> str: 117 | date_str = hitalic(humanize_date(data.days)) 118 | 119 | month_str = f'{hbold(_(*units.tu_month, 1).capitalize() + ":")} {names.JEWISH_MONTHS[data.month_name]}' 120 | duration_str = f'{hbold(headers.rh_duration) + ":"} {data.duration}' 121 | molad = data.molad[0] 122 | molad_value = f'{molad.day} {names.MONTH_NAMES_GENETIVE[molad.month]} {molad.year},' \ 123 | f'{molad.time().hour} {_(*units.tu_hour, molad.time().hour)} ' \ 124 | f'{molad.time().minute} {_(*units.tu_minute, molad.time().minute)} ' \ 125 | f'{helpers.and_word} {data.molad[1]} {_(*units.tu_part, data.molad[1])}' 126 | molad_str = f'{hbold(headers.rh_molad) + ":"} {molad_value}' 127 | 128 | content = f'{month_str}\n' \ 129 | f'{duration_str}\n' \ 130 | f'{molad_str}' 131 | 132 | resp = _compose_response(f'🌙 {names.title_rosh_chodesh}', content, date_str) 133 | return resp 134 | 135 | 136 | def compose_daf_yomi(data: DafYomi) -> str: 137 | date_str = hitalic(f'{humanize_date([data.settings.date_])}') 138 | 139 | masehet_str = f'{hbold(headers.daf_masehet) + ":"} {names.GEMARA_BOOKS.get(data.masehet, "")}' 140 | daf_str = f'{hbold(headers.daf_page) + ":"} {data.daf}' 141 | content = f'{masehet_str}\n{daf_str}' 142 | resp = _compose_response(f'📚 {names.title_daf_yomi}', content, date_str) 143 | return resp 144 | 145 | 146 | def compose_holiday(data: Holiday) -> str: 147 | holiday_last_date = data.date 148 | if data.settings.holiday_name == 'chanukah': 149 | holiday_last_date += timedelta(days=7) 150 | 151 | date_value = humanize_date([data.date, holiday_last_date]) 152 | date_str = f'{hbold(headers.date) + ":"} {date_value}' 153 | resp = _compose_response(_emojize_holiday_title(data.settings.holiday_name), date_str) 154 | return resp 155 | 156 | 157 | def compose_israel_holidays(data: IsraelHolidays) -> str: 158 | content_lines = [] 159 | for holiday in data.holiday_list: 160 | line = f'{hbold(headers.israel_holidays[holiday[0]]) + ":"} {humanize_date([holiday[1]])}' 161 | content_lines.append(line) 162 | 163 | content = '\n'.join(content_lines) 164 | resp = _compose_response(_emojize_holiday_title('israel_holidays'), content) 165 | return resp 166 | 167 | 168 | def compose_fast(data: Fast, location_name: str) -> Tuple[str, InlineKeyboardMarkup]: 169 | deferred_fast_header = headers.fast_moved.value if data.moved_fast \ 170 | else headers.fast_not_moved.value 171 | deferred_fast_str = hitalic(deferred_fast_header) 172 | 173 | fast_start_date = humanize_date([data.fast_start]) 174 | fast_start_time = humanize_time(data.fast_start) 175 | fast_start_str = f'{hbold(headers.fast_start) + ":"} {fast_start_date}, {fast_start_time}' 176 | 177 | if data.chatzot: 178 | chatzot_time = humanize_time(data.chatzot) 179 | chatzot_str = f'\n{hbold(texts.single.zmanim.chatzos) + ":"} {chatzot_time}' 180 | else: 181 | chatzot_str = '' 182 | 183 | havdala_header = hbold(headers.fast_end) 184 | havdala_5_95_str = f'{hbold(headers.fast_end_5_95_dgr + ":")} ' \ 185 | f'{humanize_time(data.havdala_5_95_dgr)}' 186 | havdala_8_5_str = f'{hbold(headers.fast_end_8_5_dgr + ":")} ' \ 187 | f'{humanize_time(data.havdala_8_5_dgr)}' 188 | havdala_42_str = f'{hbold(headers.fast_end_42_min + ":")} ' \ 189 | f'{humanize_time(data.havdala_42_min)}' 190 | 191 | content = f'{fast_start_str}\n' \ 192 | f'{deferred_fast_str}\n' \ 193 | f'{chatzot_str}\n' \ 194 | f'{havdala_header}\n' \ 195 | f'{havdala_5_95_str}\n' \ 196 | f'{havdala_8_5_str}\n' \ 197 | f'{havdala_42_str}' 198 | 199 | resp = _compose_response( 200 | names.FASTS_TITLES[data.settings.fast_name], 201 | content, 202 | location=location_name 203 | ) 204 | kb = get_zmanim_by_date_buttons([data.havdala_42_min.date()]) 205 | return resp, kb 206 | 207 | 208 | def compose_yom_tov(data: YomTov, location_name: str) -> Tuple[str, InlineKeyboardMarkup]: 209 | dates = [ 210 | data.pre_shabbat, 211 | data.pesach_eating_chanetz_till and ( 212 | data.pesach_eating_chanetz_till, 213 | data.pesach_burning_chanetz_till 214 | ), 215 | data.day_1, 216 | data.day_2, 217 | data.post_shabbat, 218 | data.pesach_part_2_day_1, 219 | data.pesach_part_2_day_2, 220 | data.pesach_part_2_post_shabat, 221 | data.hoshana_rabba 222 | ] 223 | dates = [d for d in dates if d is not None] 224 | 225 | lines = [] 226 | 227 | yomtov_last_day = data.pesach_part_2_day_2 or data.pesach_part_2_day_1 \ 228 | or data.day_2 or data.day_1 229 | date_str = hitalic(humanize_date([data.day_1.date, yomtov_last_day.date])) 230 | 231 | for date_ in dates: 232 | if isinstance(date_, tuple) and isinstance(date_[0], dt): # for pesach chametz times 233 | header = hbold(headers.pesach_end_eating_chametz + ':') 234 | value = humanize_time(date_[0]) 235 | lines.append(f'{header} {value}') 236 | 237 | header = hbold(headers.pesach_end_burning_chametz + ':') 238 | value = humanize_time(date_[1]) 239 | lines.append(f'{header} {value}\n') 240 | continue 241 | if isinstance(date_, date): # hoshana rabbah case 242 | header = hbold(headers.hoshana_raba + ':') 243 | value = f'{date_.day} {names.MONTH_NAMES_GENETIVE[date_.month]}, ' \ 244 | f'{names.WEEKDAYS[date_.weekday()]}' 245 | lines.append(f'{header} {value}\n') 246 | continue 247 | if date_ == data.pesach_part_2_day_1: 248 | lines.append('\n') 249 | if date_.candle_lighting: 250 | 251 | if date_.candle_lighting.weekday() == 4: 252 | shabbat = f' ({names.shabbat})' 253 | else: 254 | shabbat = '' 255 | 256 | header = f'{headers.cl} {date_.candle_lighting.day} ' \ 257 | f'{names.MONTH_NAMES_GENETIVE[date_.candle_lighting.month]}{shabbat}' 258 | lines.append(f'{hbold(header + ":")} {humanize_time(date_.candle_lighting)}') 259 | 260 | if date_.havdala: 261 | header = f'{headers.havdala} {date_.havdala.day} ' \ 262 | f'{names.MONTH_NAMES_GENETIVE[date_.havdala.month]}' 263 | lines.append(f'{hbold(header + ":")} {humanize_time(date_.havdala)}') 264 | 265 | content = '\n'.join(lines) 266 | resp = _compose_response( 267 | _emojize_yom_tov_title(data.settings.yomtov_name), 268 | content, 269 | date_str, 270 | location_name 271 | ) 272 | kb = get_zmanim_by_date_buttons( 273 | list(map( 274 | lambda d: d.date if isinstance(d, AsurBeMelachaDay) else d, 275 | filter(lambda v: isinstance(v, AsurBeMelachaDay), dates) 276 | )) 277 | ) 278 | return resp, kb 279 | -------------------------------------------------------------------------------- /zmanim_bot/processors/image/renderer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import date 4 | from datetime import datetime as dt 5 | from datetime import timedelta 6 | from io import BytesIO 7 | from pathlib import Path 8 | from typing import Dict, List, Optional, Tuple, Union 9 | 10 | from PIL import Image, ImageDraw, ImageFont, PngImagePlugin 11 | from aiogram.types import InlineKeyboardMarkup 12 | from babel.support import LazyProxy 13 | 14 | from zmanim_bot import texts 15 | from zmanim_bot.exceptions import PolarCoordinatesException 16 | from zmanim_bot.integrations.zmanim_models import * 17 | from zmanim_bot.keyboards.inline import get_zmanim_by_date_buttons 18 | from zmanim_bot.middlewares.i18n import gettext as _, i18n_ 19 | from zmanim_bot.processors.text_utils import humanize_date, humanize_time, parse_jewish_date 20 | from zmanim_bot.texts.plural import units 21 | from zmanim_bot.texts.single import headers, helpers, names, zmanim 22 | 23 | IMG_SIZE = 1181 24 | Line = Tuple[Optional[str], Optional[str], Optional[bool]] 25 | EMPTY_LINE = None, None, None 26 | 27 | 28 | def _convert_img_to_bytes_io(img: PngImagePlugin.PngImageFile) -> BytesIO: 29 | bytes_io = BytesIO() 30 | img.save(bytes_io, 'png') 31 | bytes_io.seek(0) 32 | return bytes_io 33 | 34 | 35 | def _get_draw(background_path: str) -> Tuple[Image.Image, ImageDraw.ImageDraw]: 36 | image = Image.open(background_path) 37 | draw = ImageDraw.Draw(image) 38 | return image, draw 39 | 40 | 41 | class BaseImage: 42 | _font_path = Path(__file__).parent / 'res' / 'fonts' / 'gothic.TTF' 43 | _bold_font_path = Path(__file__).parent / 'res' / 'fonts' / 'gothic-bold.TTF' 44 | _title_font = ImageFont.truetype(str(_bold_font_path), 60) 45 | _font_size = 0 46 | _warning_font_size = 0 47 | _background_path = None 48 | 49 | _image: PngImagePlugin.PngImageFile 50 | _draw: ImageDraw 51 | 52 | _is_rtl: bool 53 | 54 | __x: int 55 | y: int 56 | y_offset: int 57 | 58 | @property 59 | def x(self) -> int: 60 | return self.__x 61 | 62 | @x.setter 63 | def x(self, value: int): 64 | self.__x = value if not self._is_rtl else IMG_SIZE - value 65 | 66 | def shift_y(self): 67 | self.y += self.y_offset 68 | 69 | def get_x_for_text(self, text: str) -> ...: 70 | length = self._x_font_offset(text) 71 | return self.__x + length if not self._is_rtl else self.__x - length 72 | 73 | def __init__(self): 74 | self._font = ImageFont.truetype(str(self._font_path), self._font_size) 75 | self._bold_font = ImageFont.truetype(str(self._bold_font_path), self._font_size) 76 | 77 | if self._background_path: 78 | self._image, self._draw = _get_draw(str(self._background_path)) 79 | 80 | self._warning_font = ImageFont.truetype(str(self._bold_font_path), self._warning_font_size) 81 | self._is_rtl = i18n_.is_rtl() 82 | 83 | def _draw_title(self, draw: ImageDraw, title: LazyProxy) -> None: 84 | coordinates = (180, 30) 85 | font = self._title_font 86 | draw.text(coordinates, title.value, font=font) 87 | 88 | def _draw_date( 89 | self, 90 | greg_date: List[Union[date, dt]], 91 | jewish_date: Optional[str] = None, 92 | no_weekday: bool = False 93 | ): 94 | x = 180 95 | y = 100 96 | 97 | greg_date_str = humanize_date(greg_date) 98 | if no_weekday: 99 | greg_date_str = greg_date_str.split(', ')[0] 100 | 101 | jewish_date_str = f' / {parse_jewish_date(jewish_date)}' if jewish_date else '' 102 | date_str = f'{greg_date_str}{jewish_date_str}' 103 | 104 | date_font = ImageFont.truetype(str(self._font_path), 40) 105 | self._draw.text((x, y), date_str, font=date_font) 106 | 107 | def _draw_location(self, location_name: str, *, is_fast: bool = False): 108 | x = 185 109 | y = 150 if not is_fast else 100 110 | 111 | icon_font = ImageFont.truetype(str(Path(__file__).parent / 'res' / 'fonts' / 'fontello.ttf'), 40) 112 | text_font = ImageFont.truetype(str(self._font_path), 40) 113 | 114 | self._draw.text((x, y + 5), f'\ue800', font=icon_font, embedded_color=True) 115 | self._draw.text((x + 20, y), f' {location_name} ', font=text_font, embedded_color=True) 116 | 117 | def _x_font_offset(self, text: str) -> int: 118 | """Returns size in px of given text in axys x""" 119 | last_line = min(text.split('\n')) 120 | offset = self._bold_font.getsize(last_line)[0] 121 | if self._is_rtl: 122 | offset *= -1 123 | 124 | return offset 125 | 126 | def _y_font_offset(self, text: str) -> int: 127 | """Returns size in px of given text in axys y""" 128 | return self._bold_font.getsize(text)[1] 129 | 130 | def _draw_line( 131 | self, 132 | header: Optional[str], 133 | value: Optional[str] = None, 134 | *, 135 | value_on_new_line: bool = False, 136 | value_without_x_offset: bool = False 137 | ): 138 | header = f'{str(header)}{":" if value else ""} ' if header else '' 139 | header_offset = self._x_font_offset(header) 140 | x = self.x + header_offset if self._is_rtl else self.x 141 | header and self._draw.text((x, self.y), text=header, font=self._bold_font) 142 | 143 | if not value: 144 | return 145 | 146 | if not value_without_x_offset: 147 | x += self._x_font_offset(header) if not self._is_rtl else self._x_font_offset(value) 148 | if value_on_new_line or value_without_x_offset: 149 | self.y += self._y_font_offset(header.split('\n')[0]) 150 | 151 | self._draw.text( 152 | (x, self.y), 153 | text=str(value), 154 | font=self._font, 155 | direction='rtl' if self._is_rtl else 'ltr', 156 | align='left' if not self._is_rtl else 'right' 157 | ) 158 | 159 | def get_image(self): 160 | raise NotImplemented 161 | 162 | 163 | class DafYomImage(BaseImage): 164 | def __init__(self, data: DafYomi): 165 | self._font_size = 90 166 | self.data = data 167 | self._background_path = Path(__file__).parent / 'res' / 'backgrounds' / 'daf_yomi.png' 168 | 169 | super().__init__() 170 | self._draw_title(self._draw, names.title_daf_yomi) 171 | self._draw_date([self.data.settings.date_], self.data.settings.jewish_date) 172 | 173 | self.y = 470 174 | self.x = 100 175 | self.y_offset = 100 176 | 177 | def get_image(self) -> BytesIO: 178 | # draw masehet 179 | self._draw_line(headers.daf_masehet, names.GEMARA_BOOKS.get(self.data.masehet, '')) 180 | self.shift_y() 181 | 182 | # draw daf 183 | self._draw_line(headers.daf_page, str(self.data.daf)) 184 | return _convert_img_to_bytes_io(self._image) 185 | 186 | 187 | class RoshChodeshImage(BaseImage): 188 | def __init__(self, data: RoshChodesh): 189 | self.data = data 190 | self._font_size = 52 191 | self._background_path = Path(__file__).parent / 'res' / 'backgrounds' / 'rosh_hodesh.png' 192 | 193 | super().__init__() 194 | self._draw_title(self._draw, names.title_rosh_chodesh) 195 | self._draw_date(self.data.days, self.data.settings.jewish_date) 196 | 197 | self.y = 370 198 | self.x = 100 199 | self.y_offset = 80 200 | 201 | def get_image(self) -> BytesIO: 202 | 203 | # draw month 204 | self._draw_line(_(*units.tu_month, 1).capitalize(), names.JEWISH_MONTHS[self.data.month_name]) 205 | self.shift_y() 206 | 207 | # draw duration 208 | self._draw_line(headers.rh_duration, str(self.data.duration)) 209 | self.shift_y() 210 | 211 | # draw molad string 212 | molad = self.data.molad[0] 213 | molad_value = f'{molad.day} {names.MONTH_NAMES_GENETIVE[molad.month]} {molad.year},\n'\ 214 | f'{molad.time().hour} {_(*units.tu_hour, molad.time().hour)} ' \ 215 | f'{molad.time().minute} {_(*units.tu_minute, molad.time().minute)} ' \ 216 | f'{helpers.and_word} {self.data.molad[1]} {_(*units.tu_part, self.data.molad[1])}' 217 | self._draw_line(headers.rh_molad, molad_value) 218 | 219 | return _convert_img_to_bytes_io(self._image) 220 | 221 | 222 | class ShabbatImage(BaseImage): 223 | 224 | def __init__(self, data: Shabbat, location_name: str): 225 | self.data = data 226 | self._font_size = 60 227 | self._warning_font_size = 48 228 | self.location_name = location_name 229 | 230 | super().__init__() 231 | 232 | if not data.candle_lighting or data.late_cl_warning: 233 | self._background_path = Path(__file__).parent / 'res' / 'backgrounds' / 'shabbos_attention.png' 234 | else: 235 | self._background_path = Path(__file__).parent / 'res' / 'backgrounds' / 'shabbos.png' 236 | self._image, self._draw = _get_draw(str(self._background_path)) 237 | 238 | self._draw_title(self._draw, names.title_shabbath) 239 | self._draw_location(self.location_name) 240 | 241 | if self.data.havdala: 242 | self._draw_date([self.data.havdala.date()], no_weekday=True) 243 | 244 | self.y = 400 if self.data.candle_lighting else 470 245 | self.x = 100 246 | self.y_offset: int = 80 247 | 248 | def get_image(self) -> Tuple[BytesIO, Optional[InlineKeyboardMarkup]]: 249 | # draw parashat hashavua 250 | torah_part = names.TORAH_PARTS.get(self.data.torah_part, '') 251 | if (self._x_font_offset(headers.parsha.value) + self._x_font_offset(torah_part) + self.x) > IMG_SIZE: 252 | value_on_new_line = True 253 | else: 254 | value_on_new_line = False 255 | 256 | self._draw_line(headers.parsha, torah_part, value_without_x_offset=value_on_new_line) 257 | self.shift_y() 258 | value_on_new_line and self.shift_y() 259 | 260 | # if polar error, draw error message and return 261 | if not self.data.candle_lighting: 262 | x = 100 263 | y = 840 if helpers.cl_error_warning.value.count('\n') < 2 else 810 264 | 265 | self._draw.text((x, y), helpers.cl_error_warning.value, font=self._warning_font, 266 | fill='#ff5959') 267 | return _convert_img_to_bytes_io(self._image), None 268 | 269 | # draw candle lighting 270 | self._draw_line(headers.cl, self.data.candle_lighting.time().isoformat('minutes')) 271 | self.shift_y() 272 | 273 | # draw shekiah offset 274 | cl_offset = self.data.settings.cl_offset 275 | offset_value = f'({cl_offset} {_(*units.tu_minute, cl_offset)} {helpers.cl_offset})' 276 | self._draw_line(None, offset_value) 277 | self.shift_y() 278 | 279 | # draw havdala 280 | self._draw_line(headers.havdala, self.data.havdala.time().isoformat('minutes')) 281 | self.shift_y() 282 | 283 | # draw havdala opinion 284 | havdala_opinion = getattr(texts.single.zmanim, self.data.settings.havdala_opinion) 285 | opinion_value = havdala_opinion.value.split('[')[1].split(']')[0] 286 | self._draw_line(None, f'({opinion_value})') 287 | self.shift_y() 288 | 289 | kb = get_zmanim_by_date_buttons([self.data.havdala.date()]) 290 | 291 | # draw warning if need 292 | if not self.data.late_cl_warning: 293 | return _convert_img_to_bytes_io(self._image), kb 294 | 295 | x, y = 100, 840 if not self._is_rtl else 860 296 | self._draw.text((x, y), helpers.cl_late_warning.value, font=self._warning_font, fill='#ff5959') 297 | 298 | return _convert_img_to_bytes_io(self._image), kb 299 | 300 | 301 | class ZmanimImage(BaseImage): 302 | 303 | def __init__(self, data: Zmanim, location_name: str): 304 | self.data = data 305 | self._background_path = Path(__file__).parent / 'res' / 'backgrounds' / 'zmanim.png' 306 | super().__init__() 307 | 308 | self._draw_title(self._draw, names.title_zmanim) 309 | self._draw_date([self.data.settings.date_], self.data.settings.jewish_date) 310 | self._draw_location(location_name) 311 | 312 | self.zmanim_rows: Dict[str, dt] = self.data.dict(exclude={'settings', 'is_second_day'}, exclude_none=True) 313 | 314 | if len(self.zmanim_rows) == 0: 315 | raise PolarCoordinatesException() 316 | 317 | self._set_font_properties(len(self.zmanim_rows)) 318 | 319 | self.y: int = 200 + self._start_y_offset 320 | self.x: int = 50 321 | 322 | def _set_font_properties(self, number_of_lines: int): 323 | p = { 324 | # [font_size, y_offset, start_y_offset 325 | 1: [44, 53, 350], 326 | 2: [44, 53, 300], 327 | 3: [44, 53, 270], 328 | 4: [44, 53, 220], 329 | 5: [44, 53, 180], 330 | 6: [44, 53, 160], 331 | 7: [44, 53, 140], 332 | 8: [44, 53, 100], 333 | 9: [44, 53, 100], 334 | 10: [44, 53, 80], 335 | 11: [44, 53, 40], 336 | 12: [44, 53, 25], 337 | 13: [44, 53, 30], 338 | 14: [44, 53, 30], 339 | 15: [44, 53, 20], 340 | 16: [40, 42, 20], 341 | 17: [40, 42, 20], 342 | 18: [40, 42, 0], 343 | 19: [40, 42, 0] 344 | } 345 | self._font_size, self.y_offset, self._start_y_offset = p.get(number_of_lines) 346 | self._font = ImageFont.truetype(str(self._font_path), size=self._font_size) 347 | self._bold_font = ImageFont.truetype(str(self._bold_font_path), size=self._font_size) 348 | 349 | def get_image(self) -> BytesIO: 350 | for header, value in self.zmanim_rows.items(): 351 | self._draw_line( 352 | getattr(texts.single.zmanim, header), 353 | value.time().isoformat('minutes') if isinstance(value, date) else value.isoformat('minutes') 354 | ) 355 | self.shift_y() 356 | 357 | return _convert_img_to_bytes_io(self._image) 358 | 359 | 360 | class FastImage(BaseImage): 361 | 362 | def __init__(self, data: Fast, location_name: str): 363 | self.data = data 364 | self._background_path = Path(__file__).parent / 'res' / 'backgrounds' / 'fast.png' 365 | self._font_size = 60 366 | 367 | super().__init__() 368 | 369 | self._draw_title(self._draw, names.FASTS_TITLES[data.settings.fast_name]) 370 | self._draw_location(location_name, is_fast=True) 371 | 372 | self.x = 100 373 | self.y = 300 if data.chatzot else 350 374 | self.y_offset = 80 375 | 376 | def get_image(self) -> Tuple[BytesIO, InlineKeyboardMarkup]: 377 | 378 | fast_moved_header = headers.fast_moved.value if self.data.moved_fast else headers.fast_not_moved.value 379 | self._draw.text( 380 | ( 381 | 210 if not self._is_rtl else (IMG_SIZE - 60 + self._x_font_offset(fast_moved_header)), 382 | 155 383 | ), 384 | fast_moved_header, 385 | font=self._bold_font, 386 | fill='#ff5959' if self.data.moved_fast else '#8bff59' 387 | ) 388 | 389 | # draw date and start time 390 | fast_date, fast_weekday = humanize_date([self.data.fast_start]).split(', ') 391 | fast_start_value = f'{fast_date},\n{fast_weekday}, {self.data.fast_start.time().isoformat("minutes")}' 392 | self._draw_line(headers.fast_start, fast_start_value) 393 | self.y += self._y_font_offset(fast_start_value) + self.y_offset * 2 394 | 395 | # draw hatzot, if need 396 | if self.data.chatzot: 397 | self._draw_line(zmanim.chatzos, self.data.chatzot.time().isoformat('minutes')) 398 | self.shift_y() 399 | 400 | # draw havdala 401 | self._draw_line(headers.fast_end, '') 402 | self.shift_y() 403 | 404 | havdala_options = ( 405 | (self.data.havdala_5_95_dgr, headers.fast_end_5_95_dgr), 406 | (self.data.havdala_8_5_dgr, headers.fast_end_8_5_dgr), 407 | (self.data.havdala_42_min, headers.fast_end_42_min) 408 | ) 409 | for havdala_value, havdala_header in havdala_options: 410 | self._draw_line(havdala_header, havdala_value.time().isoformat('minutes')) 411 | self.shift_y() 412 | 413 | kb = get_zmanim_by_date_buttons([self.data.havdala_42_min.date()]) 414 | return _convert_img_to_bytes_io(self._image), kb 415 | 416 | 417 | class HolidayImage(BaseImage): 418 | 419 | def __init__(self, data: Holiday): 420 | self.data = data 421 | background_and_font_params = { 422 | 'chanukah': ('chanuka.png', 60), 423 | 'tu_bi_shvat': ('tubishvat.png', 70), 424 | 'purim': ('purim.png', 70), 425 | 'lag_baomer': ('lagbaomer.png', 70), 426 | 'israel_holidays': ('israel_holidays.png', 50), 427 | } 428 | background, font_size = background_and_font_params[data.settings.holiday_name] 429 | 430 | self._background_path = Path(__file__).parent / 'res' / 'backgrounds' / background 431 | self._font_size = font_size 432 | 433 | super().__init__() 434 | 435 | self._draw_title(self._draw, names.HOLIDAYS_TITLES[data.settings.holiday_name]) 436 | 437 | self.x = 100 438 | self.y = 450 439 | 440 | def get_image(self) -> BytesIO: 441 | 442 | holiday_last_date = self.data.date 443 | line_break = False 444 | if self.data.settings.holiday_name == 'chanukah': 445 | holiday_last_date += timedelta(days=7) 446 | line_break = True 447 | 448 | date_value = humanize_date([self.data.date, holiday_last_date], weekday_on_new_line=line_break) 449 | 450 | if (self.x + self._x_font_offset(headers.date.value) + self._x_font_offset(date_value)) > IMG_SIZE: 451 | date_value = humanize_date([self.data.date, holiday_last_date], weekday_on_new_line=True) 452 | 453 | self._draw_line(headers.date, date_value) 454 | return _convert_img_to_bytes_io(self._image) 455 | 456 | 457 | class IsraelHolidaysImage(BaseImage): 458 | 459 | def __init__(self, data: IsraelHolidays): 460 | self.data = data 461 | self._background_path = Path( 462 | __file__).parent / 'res' / 'backgrounds' / 'israel_holidays.png' 463 | self._font_size = 53 464 | 465 | super().__init__() 466 | self.x = 80 467 | self.y = 300 468 | self.y_offset = 90 469 | 470 | self._draw_title(self._draw, names.HOLIDAYS_TITLES['israel_holidays']) 471 | 472 | def get_image(self) -> BytesIO: 473 | y_offset_small = 60 474 | 475 | for holiday in self.data.holiday_list: 476 | header = f'{headers.israel_holidays[holiday[0]]}:' 477 | x = self.x + self._x_font_offset(header) if self._is_rtl else self.x 478 | 479 | self._draw.text((x, self.y), header, font=self._bold_font) 480 | self.y += y_offset_small 481 | 482 | self._draw_line(headers.date, humanize_date([holiday[1]])) 483 | self.shift_y() 484 | 485 | return _convert_img_to_bytes_io(self._image) 486 | 487 | 488 | class YomTovImage(BaseImage): 489 | 490 | def __init__(self, data: YomTov, location_name: str): 491 | backgrounds = { 492 | 'rosh_hashana': 'rosh_hashana.png', 493 | 'yom_kippur': 'yom_kippur.png', 494 | 'succot': 'succos.png', 495 | 'shmini_atzeres': 'shmini_atzeret.png', 496 | 'pesach': 'pesah.png', 497 | 'shavuot': 'shavuot.png', 498 | } 499 | background = backgrounds[data.settings.yomtov_name] 500 | 501 | self._background_path = Path(__file__).parent / 'res' / 'backgrounds' / background 502 | 503 | self.data = data 504 | super().__init__() 505 | self.x = 80 506 | 507 | self.dates = self._get_dates() 508 | self.lines = self._prepare_lines(self.dates) 509 | self.y, self.y_offset, self._font_size = self._get_font_properties(len(self.lines)) 510 | 511 | self._font = ImageFont.truetype(str(self._font_path), self._font_size) 512 | self._bold_font = ImageFont.truetype(str(self._bold_font_path), self._font_size) 513 | 514 | self._draw_title(self._draw, names.YOMTOVS_TITLES[data.settings.yomtov_name]) 515 | self._draw_location(location_name) 516 | 517 | @staticmethod 518 | def _humanize_header_date(header_type: str, date_: Union[date, dt]) -> Tuple[str, bool]: 519 | """ 520 | Use this function for date header, not for date title. 521 | :return header and flag indicates if it's two-lines height header 522 | """ 523 | if header_type == headers.cl and date_.weekday() == 4: 524 | shabbat = f'\n({names.shabbat})' 525 | else: 526 | shabbat = '' 527 | 528 | resp = f'{header_type} {date_.day} {names.MONTH_NAMES_GENETIVE[date_.month]}{shabbat}' 529 | return resp, bool(shabbat) 530 | 531 | def _get_dates(self) -> List[Union[AsurBeMelachaDay, date, Tuple[dt, str]]]: 532 | dates = [ 533 | self.data.pre_shabbat, 534 | self.data.pesach_eating_chanetz_till and ( 535 | self.data.pesach_eating_chanetz_till, 536 | self.data.pesach_burning_chanetz_till 537 | ), 538 | self.data.day_1, 539 | self.data.day_2, 540 | self.data.post_shabbat, 541 | self.data.pesach_part_2_day_1, 542 | self.data.pesach_part_2_day_2, 543 | self.data.pesach_part_2_post_shabat, 544 | self.data.hoshana_rabba 545 | ] 546 | return [d for d in dates if d is not None] 547 | 548 | def _prepare_lines(self, dates: List[Union[AsurBeMelachaDay, date]]) -> List[Line]: 549 | lines = [] 550 | 551 | yomtov_last_day = self.data.pesach_part_2_day_2 \ 552 | or self.data.pesach_part_2_day_1 \ 553 | or self.data.day_2 \ 554 | or self.data.day_1 555 | self._draw_date([self.data.day_1.date, yomtov_last_day.date]) 556 | 557 | for date_ in dates: 558 | if isinstance(date_, tuple) and isinstance(date_[0], dt): # for pesach chametz times 559 | header = str(headers.pesach_end_eating_chametz) 560 | value = humanize_time(date_[0]) 561 | lines.append((header, value, False)) 562 | 563 | header = str(headers.pesach_end_burning_chametz) 564 | value = humanize_time(date_[1]) 565 | lines.append((header, value, False)) 566 | lines.append(EMPTY_LINE) 567 | continue 568 | 569 | if isinstance(date_, date): # hoshana rabbah case 570 | header = str(headers.hoshana_raba) 571 | sep = '\n' if not self._is_rtl else '' 572 | value = f'{date_.day} {names.MONTH_NAMES_GENETIVE[date_.month]},{sep} {names.WEEKDAYS[date_.weekday()]}' 573 | lines.append(EMPTY_LINE) 574 | lines.append((header, value, False)) 575 | continue 576 | 577 | if date_ == self.data.pesach_part_2_day_1: 578 | lines.append(EMPTY_LINE) 579 | 580 | if date_.candle_lighting: 581 | header, new_line = self._humanize_header_date(headers.cl, date_.candle_lighting) 582 | value = humanize_time(date_.candle_lighting) 583 | lines.append((header, value, new_line)) 584 | if date_.havdala: 585 | header, new_line = self._humanize_header_date(headers.havdala, date_.havdala) 586 | value = humanize_time(date_.havdala) 587 | lines.append((header, value, new_line)) 588 | 589 | return lines 590 | 591 | @staticmethod 592 | def _get_font_properties(number_of_lines: int) -> Tuple[int, int, int]: 593 | p = { 594 | # [font_size, y_offset, start_y_position] 595 | 2: (50, 70, 400), 596 | 3: (50, 70, 400), 597 | 4: (50, 70, 400), 598 | 5: (50, 80, 260), 599 | 6: (50, 70, 260), 600 | 7: (50, 50, 260), 601 | 8: (50, 50, 260), 602 | 9: (45, 50, 230), 603 | 10: (45, 50, 230), 604 | 11: (45, 50, 230) 605 | } 606 | font_size, y_offset, start_position_y = p.get(number_of_lines) 607 | return start_position_y, y_offset, font_size 608 | 609 | def get_image(self) -> Tuple[BytesIO, Optional[InlineKeyboardMarkup]]: 610 | for header, value, new_line in self.lines: 611 | if not header: 612 | self.y += self.y_offset * 2 613 | continue 614 | 615 | if '\n' in header: 616 | if abs(self._x_font_offset(header.replace('\n', ' '))) < IMG_SIZE * 0.8: 617 | header = header.replace('\n', ' ') 618 | self._draw_line(header, value) 619 | else: 620 | header_1, header_2 = header.split('\n') 621 | self._draw_line(header_1) 622 | self.y += self._y_font_offset(header_1) 623 | self._draw_line(header_2, value) 624 | else: 625 | self._draw_line(header, value) 626 | 627 | if new_line: 628 | self.y += self._y_font_offset(header) 629 | self.shift_y() 630 | 631 | kb = get_zmanim_by_date_buttons( 632 | list(map( 633 | lambda d: d.date if isinstance(d, AsurBeMelachaDay) else d, 634 | filter(lambda v: isinstance(v, AsurBeMelachaDay), self.dates) 635 | )) 636 | ) 637 | return _convert_img_to_bytes_io(self._image), kb 638 | --------------------------------------------------------------------------------