From 502ed0406ed87c3219cd6b5f87debfaca8533915 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 27 Aug 2023 22:43:16 +0200 Subject: [PATCH] Initial commit --- .gitignore | 164 ++++++++++++++++++++++++++++ .renovaterc | 20 ++++ classes/callbacks.py | 30 +++++ classes/enums/__init__.py | 1 + classes/enums/garbage_type.py | 10 ++ classes/garbage_entry.py | 73 +++++++++++++ classes/geobase/geobase.py | 20 ++++ classes/geobase/geobase_api.py | 23 ++++ classes/location.py | 58 ++++++++++ classes/point.py | 12 ++ classes/pyroclient.py | 68 ++++++++++++ classes/pyrouser.py | 118 ++++++++++++++++++++ commands.json | 114 +++++++++++++++++++ config_example.json | 22 ++++ locale/en.json | 46 ++++++++ locale/uk.json | 46 ++++++++ main.py | 57 ++++++++++ modules/custom_filters.py | 13 +++ modules/database.py | 28 +++++ modules/migrator.py | 22 ++++ modules/reminder.py | 72 ++++++++++++ modules/scheduler.py | 3 + modules/utils.py | 0 plugins/callbacks/callback.py | 11 ++ plugins/commands/checkout.py | 106 ++++++++++++++++++ plugins/commands/help.py | 13 +++ plugins/commands/import.py | 74 +++++++++++++ plugins/commands/remove_commands.py | 12 ++ plugins/commands/set_offset.py | 60 ++++++++++ plugins/commands/set_time.py | 58 ++++++++++ plugins/commands/setup.py | 104 ++++++++++++++++++ plugins/commands/shutdown.py | 16 +++ plugins/commands/start.py | 96 ++++++++++++++++ plugins/commands/toggle.py | 29 +++++ plugins/commands/upcoming.py | 59 ++++++++++ plugins/language.py | 45 ++++++++ requirements.txt | 11 ++ validation/entries.json | 37 +++++++ validation/locations.json | 45 ++++++++ validation/users.json | 58 ++++++++++ 40 files changed, 1854 insertions(+) create mode 100644 .gitignore create mode 100644 .renovaterc create mode 100644 classes/callbacks.py create mode 100644 classes/enums/__init__.py create mode 100644 classes/enums/garbage_type.py create mode 100644 classes/garbage_entry.py create mode 100644 classes/geobase/geobase.py create mode 100644 classes/geobase/geobase_api.py create mode 100644 classes/location.py create mode 100644 classes/point.py create mode 100644 classes/pyroclient.py create mode 100644 classes/pyrouser.py create mode 100644 commands.json create mode 100644 config_example.json create mode 100644 locale/en.json create mode 100644 locale/uk.json create mode 100644 main.py create mode 100644 modules/custom_filters.py create mode 100644 modules/database.py create mode 100644 modules/migrator.py create mode 100644 modules/reminder.py create mode 100644 modules/scheduler.py create mode 100644 modules/utils.py create mode 100644 plugins/callbacks/callback.py create mode 100644 plugins/commands/checkout.py create mode 100644 plugins/commands/help.py create mode 100644 plugins/commands/import.py create mode 100644 plugins/commands/remove_commands.py create mode 100644 plugins/commands/set_offset.py create mode 100644 plugins/commands/set_time.py create mode 100644 plugins/commands/setup.py create mode 100644 plugins/commands/shutdown.py create mode 100644 plugins/commands/start.py create mode 100644 plugins/commands/toggle.py create mode 100644 plugins/commands/upcoming.py create mode 100644 plugins/language.py create mode 100644 requirements.txt create mode 100644 validation/entries.json create mode 100644 validation/locations.json create mode 100644 validation/users.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4a0707 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Custom +config.json +*.session +*.session-journal + +venv +venv_linux +venv_windows + +.vscode \ No newline at end of file diff --git a/.renovaterc b/.renovaterc new file mode 100644 index 0000000..c416352 --- /dev/null +++ b/.renovaterc @@ -0,0 +1,20 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "baseBranches": [ + "dev" + ], + "packageRules": [ + { + "matchUpdateTypes": [ + "minor", + "patch", + "pin", + "digest" + ], + "automerge": true + } + ] +} \ No newline at end of file diff --git a/classes/callbacks.py b/classes/callbacks.py new file mode 100644 index 0000000..0093c9b --- /dev/null +++ b/classes/callbacks.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass + +from pyrogram.types import CallbackQuery + + +@dataclass +class CallbackLanguage: + __slots__ = ("language",) + + language: str + + @classmethod + def from_callback(cls, callback: CallbackQuery): + """Parse callback query and extract language data from it. + + ### Args: + * callback (`CallbackQuery`): Callback query got from user interaction. + + ### Raises: + * `ValueError`: Raised when callback provided is not a language one. + + ### Returns: + * `CallbackLanguage`: Parsed callback query. + """ + action, language = str(callback.data).split(":") + + if action.lower() != "language": + raise ValueError("Callback provided is not a language callback") + + return cls(language) diff --git a/classes/enums/__init__.py b/classes/enums/__init__.py new file mode 100644 index 0000000..31f3c6b --- /dev/null +++ b/classes/enums/__init__.py @@ -0,0 +1 @@ +from .garbage_type import GarbageType diff --git a/classes/enums/garbage_type.py b/classes/enums/garbage_type.py new file mode 100644 index 0000000..d529e95 --- /dev/null +++ b/classes/enums/garbage_type.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class GarbageType(Enum): + BIO = 0 + PLASTIC = 1 + PAPER = 2 + GENERAL = 3 + GLASS = 4 + UNSPECIFIED = 5 diff --git a/classes/garbage_entry.py b/classes/garbage_entry.py new file mode 100644 index 0000000..7c97b60 --- /dev/null +++ b/classes/garbage_entry.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Any, List, Mapping, Union + +from bson import ObjectId + +from classes.enums.garbage_type import GarbageType +from classes.location import Location + + +@dataclass +class GarbageEntry: + __slots__ = ( + "_id", + "locations", + "garbage_type", + "date", + ) + + _id: Union[ObjectId, None] + locations: List[Location] + garbage_type: GarbageType + date: datetime + + @classmethod + async def from_dict(cls, data: Mapping[str, Any]): + """Generate GarbageEntry object from the mapping provided + + ### Args: + * data (`Mapping[str, Any]`): Entry + + ### Raises: + * `KeyError`: Key is missing. + * `TypeError`: Key of a wrong type provided. + * `ValueError`: "date" is not a valid ISO string. + + ### Returns: + * `GarbageEntry`: Valid GarbageEntry object. + """ + for key in ("locations", "garbage_type", "date"): + if key not in data: + raise KeyError + if key == "locations" and not isinstance(data[key], list): + raise TypeError + if key == "garbage_type" and not isinstance(data[key], int): + raise TypeError + if key == "date": + datetime.fromisoformat(str(data[key])) + + locations = [ + await Location.get(location_id) for location_id in data["locations"] + ] + garbage_type = GarbageType(data["garbage_type"]) + + return cls( + None, + locations, + garbage_type, + data["date"], + ) + + @classmethod + async def from_record(cls, data: Mapping[str, Any]): + locations = [ + await Location.get(location_id) for location_id in data["locations"] + ] + garbage_type = GarbageType(data["garbage_type"]) + return cls( + data["_id"], + locations, + garbage_type, + data["date"], + ) diff --git a/classes/geobase/geobase.py b/classes/geobase/geobase.py new file mode 100644 index 0000000..c920352 --- /dev/null +++ b/classes/geobase/geobase.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import List + +from classes.location import Location + + +class GeoBase(ABC): + @abstractmethod + async def get_location(self) -> Location: + pass + + @abstractmethod + async def find_location(self) -> List[Location]: + pass + + @abstractmethod + async def nearby_location(self) -> List[Location]: + pass diff --git a/classes/geobase/geobase_api.py b/classes/geobase/geobase_api.py new file mode 100644 index 0000000..cd40f89 --- /dev/null +++ b/classes/geobase/geobase_api.py @@ -0,0 +1,23 @@ +from typing import List + +from aiohttp import ClientSession + +from classes.geobase.geobase import GeoBase +from classes.location import Location + +# from urllib.parse import urlencode + + +class GeoBaseAPI(GeoBase): + async def get_location(self, session: ClientSession, location_id: int) -> Location: + # query = {"geoNameId": location_id, "style": "MEDIUM"} + # response = await session.get(f"http://api.geonames.org/get?{urlencode(query)}") + pass + + async def find_location(self, session: ClientSession, name: str) -> List[Location]: + pass + + async def nearby_location( + self, session: ClientSession, lat: float, lon: float, radius: int + ) -> List[Location]: + pass diff --git a/classes/location.py b/classes/location.py new file mode 100644 index 0000000..26d9ebd --- /dev/null +++ b/classes/location.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass + +from bson import ObjectId + +from classes.point import Point +from modules.database import col_locations + + +@dataclass +class Location: + __slots__ = ( + "_id", + "id", + "name", + "location", + "country", + "timezone", + ) + + _id: ObjectId + id: int + name: str + location: Point + country: int + timezone: str + + @classmethod + async def get(cls, id: int): + db_entry = await col_locations.find_one({"id": id}) + + if db_entry is None: + raise ValueError(f"No location with ID {id} found.") + + db_entry["location"] = Point(*db_entry["location"]) # type: ignore + + return cls(**db_entry) + + @classmethod + async def find(cls, name: str): + db_entry = await col_locations.find_one({"name": {"$regex": name}}) + + if db_entry is None: + raise ValueError(f"No location with name {name} found.") + + db_entry["location"] = Point(*db_entry["location"]) # type: ignore + + return cls(**db_entry) + + @classmethod + async def nearby(cls, lat: float, lon: float): + db_entry = await col_locations.find_one({"location": {"$near": [lon, lat]}}) + + if db_entry is None: + raise ValueError(f"No location near {lat}, {lon} found.") + + db_entry["location"] = Point(*db_entry["location"]) # type: ignore + + return cls(**db_entry) diff --git a/classes/point.py b/classes/point.py new file mode 100644 index 0000000..685c85e --- /dev/null +++ b/classes/point.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass +class Point: + __slots__ = ( + "lon", + "lat", + ) + + lon: float + lat: float diff --git a/classes/pyroclient.py b/classes/pyroclient.py new file mode 100644 index 0000000..b99a9ea --- /dev/null +++ b/classes/pyroclient.py @@ -0,0 +1,68 @@ +from typing import List, Union + +from apscheduler.triggers.cron import CronTrigger +from libbot.pyrogram.classes import PyroClient as LibPyroClient +from pymongo import ASCENDING, GEO2D +from pyrogram.types import User + +from classes.location import Location +from classes.pyrouser import PyroUser +from modules.database import col_locations +from modules.reminder import remind + + +class PyroClient(LibPyroClient): + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self.scheduler is not None: + self.scheduler.add_job( + remind, CronTrigger.from_crontab("* * * * *"), args=(self,) + ) + + async def start(self, **kwargs): + await col_locations.create_index( + [("id", ASCENDING)], name="location_id", unique=True + ) + await col_locations.create_index( + [("location", GEO2D)], + name="location_location", + ) + return await super().start(**kwargs) + + async def find_user(self, user: Union[int, User]) -> PyroUser: + """Find User by it's ID or User object. + + ### Args: + * user (`Union[int, User]`): ID or User object to extract ID from. + + ### Returns: + * `PyroUser`: User in database representation. + """ + + return ( + await PyroUser.find(user) + if isinstance(user, int) + else await PyroUser.find(user.id, locale=user.language_code) + ) + + async def get_location(self, id: int) -> Location: + """Get Location by it's ID. + + ### Args: + * id (`int`): Location's ID. Defaults to `None`. + + ### Returns: + * `Location`: Location from database as an object. + """ + + return await Location.get(id) + + async def list_locations(self) -> List[Location]: + """Get all locations stored in database. + + ### Returns: + * `List[Location]`: List of `Location` objects. + """ + return [ + await Location.get(record["id"]) async for record in col_locations.find({}) + ] diff --git a/classes/pyrouser.py b/classes/pyrouser.py new file mode 100644 index 0000000..ef7d286 --- /dev/null +++ b/classes/pyrouser.py @@ -0,0 +1,118 @@ +import logging +from dataclasses import dataclass +from typing import Any, Union + +from bson import ObjectId + +from classes.location import Location +from modules.database import col_users + +logger = logging.getLogger(__name__) + + +@dataclass +class PyroUser: + """Dataclass of DB entry of a user""" + + __slots__ = ( + "_id", + "id", + "locale", + "enabled", + "location", + "offset", + "time_hour", + "time_minute", + ) + + _id: ObjectId + id: int + locale: Union[str, None] + enabled: bool + location: Union[Location, None] + offset: int + time_hour: int + time_minute: int + + @classmethod + async def find( + cls, + id: int, + locale: Union[str, None] = None, + enabled: bool = True, + location_id: int = 0, + offset: int = 1, + time_hour: int = 18, + time_minute: int = 0, + ): + db_entry = await col_users.find_one({"id": id}) + + if db_entry is None: + inserted = await col_users.insert_one( + { + "id": id, + "locale": locale, + "enabled": enabled, + "location": location_id, + "offset": offset, + "time_hour": time_hour, + "time_minute": time_minute, + } + ) + db_entry = await col_users.find_one({"_id": inserted.inserted_id}) + + if db_entry is None: + raise RuntimeError("Could not find inserted user entry.") + + try: + db_entry["location"] = await Location.get(db_entry["location"]) # type: ignore + except ValueError: + db_entry["location"] = None # type: ignore + + return cls(**db_entry) + + async def update_locale(self, locale: Union[str, None]) -> None: + """Change user's locale stored in the database. + + ### Args: + * locale (`Union[str, None]`): New locale to be set. + """ + logger.debug("%s's locale has been set to %s", self.id, locale) + await col_users.update_one({"_id": self._id}, {"$set": {"locale": locale}}) + + async def update_state(self, enabled: bool = False) -> None: + logger.debug("%s's state has been set to %s", self.id, enabled) + await col_users.update_one({"_id": self._id}, {"$set": {"enabled": enabled}}) + + async def update_location(self, location_id: int = 0) -> None: + logger.debug("%s's location has been set to %s", self.id, location_id) + await col_users.update_one( + {"_id": self._id}, {"$set": {"location": location_id}} + ) + + async def update_offset(self, offset: int = 1) -> None: + logger.debug("%s's offset has been set to %s", self.id, offset) + await col_users.update_one({"_id": self._id}, {"$set": {"offset": offset}}) + + async def update_time(self, hour: int = 18, minute: int = 0) -> None: + logger.debug("%s's time has been set to %s h. %s m.", self.id, hour, minute) + await col_users.update_one( + {"_id": self._id}, {"$set": {"time_hour": hour, "time_minute": minute}} + ) + + async def delete(self) -> None: + logger.debug("%s's data has been deleted", self.id) + await col_users.delete_one({"_id": self._id}) + + async def checkout(self) -> Any: + logger.debug("%s's data has been checked out", self.id) + db_entry = await col_users.find_one({"_id": self._id}) + + if db_entry is None: + raise KeyError( + f"DB record with id {self._id} of user {self.id} is not found" + ) + + del db_entry["_id"] # type: ignore + + return db_entry diff --git a/commands.json b/commands.json new file mode 100644 index 0000000..2a696ec --- /dev/null +++ b/commands.json @@ -0,0 +1,114 @@ +{ + "help": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "setup": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "toggle": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "set_time": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "set_offset": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "upcoming": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "language": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "checkout": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "import": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "shutdown": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "remove_commands": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + } +} \ No newline at end of file diff --git a/config_example.json b/config_example.json new file mode 100644 index 0000000..a8b98b5 --- /dev/null +++ b/config_example.json @@ -0,0 +1,22 @@ +{ + "locale": "en", + "bot": { + "owner": 0, + "api_id": 0, + "api_hash": "", + "bot_token": "", + "max_concurrent_transmissions": 1, + "scoped_commands": true + }, + "database": { + "user": null, + "password": null, + "host": "127.0.0.1", + "port": 27017, + "name": "garbagebot" + }, + "reports": { + "chat_id": "owner" + }, + "disabled_plugins": [] +} \ No newline at end of file diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..20c570d --- /dev/null +++ b/locale/en.json @@ -0,0 +1,46 @@ +{ + "metadata": { + "flag": "🇬🇧", + "name": "English", + "codes": [ + "en", + "en-GB" + ] + }, + "formats": { + "date": "%d/%m/%Y", + "time": "%H:%M" + }, + "garbage_types": { + "0": "🟤 Bio", + "1": "🟡 Plastic", + "2": "🔵 Paper", + "3": "⚫️ General", + "4": "🟢 Glass", + "5": "❓ Unspecified" + }, + "commands": { + "help": "Show help message", + "setup": "Select the location", + "toggle": "Enable/disable notifications", + "set_time": "Set notification time", + "set_offset": "Set notification days offset", + "upcoming": "Collection for the next 30 days", + "language": "Change bot's messages language", + "checkout": "Export or delete user data", + "import": "Upload from JSON to database", + "shutdown": "Turn off the bot", + "remove_commands": "Unregister all commands" + }, + "messages": { + "help": "Help message here, lol.", + "start": "👋 Welcome!\n\nThis small open-source bot is made to simplify your life a bit easier by sending you notifications about upcoming garbage collection in your location.\n\nBy using this bot you accept [Privacy Policy]({privacy_policy}), otherwise please block and remove this bot before further interaction.\n\nNow the official part is over so you can dive into the bot.", + "locale_choice": "Alright. Please choose the language using keyboard below." + }, + "buttons": { + "configure": "Let's configure the bot" + }, + "callbacks": { + "locale_set": "Your language now is: {locale}" + } +} \ No newline at end of file diff --git a/locale/uk.json b/locale/uk.json new file mode 100644 index 0000000..f61a429 --- /dev/null +++ b/locale/uk.json @@ -0,0 +1,46 @@ +{ + "metadata": { + "flag": "🇺🇦", + "name": "Українська", + "codes": [ + "uk", + "uk-UA" + ] + }, + "formats": { + "date": "%d.%m.%Y", + "time": "%H:%M" + }, + "garbage_types": { + "0": "🟤 Біо", + "1": "🟡 Пластик", + "2": "🔵 Папір", + "3": "⚫️ Загальне", + "4": "🟢 Скло", + "5": "❓Невизначене" + }, + "commands": { + "help": "Показати меню допомоги", + "setup": "Обрати місто та район", + "toggle": "Вимкнути/вимкнути повідомлення", + "set_time": "Встановити час повідомлень", + "set_offset": "Встановити дні випередження", + "upcoming": "Дати збирання на наступні 30 днів", + "language": "Змінити мову повідомлень бота", + "checkout": "Експортувати чи видалити дані", + "import": "Завантажити з JSON до бази даних", + "shutdown": "Вимкнути бота", + "remove_commands": "Видалити всі команди" + }, + "messages": { + "help": "Привіт! Я твій бот!", + "start": "Hi! By using this bot you accept **terms of service** and **privacy policy**, otherwise please block and remove this bot before further interaction.", + "locale_choice": "Гаразд. Будь ласка, оберіть мову за допомогою клавіатури нижче." + }, + "buttons": { + "configure": "Давайте налаштуємо бота" + }, + "callbacks": { + "locale_set": "Встановлено мову: {locale}" + } +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..af04103 --- /dev/null +++ b/main.py @@ -0,0 +1,57 @@ +import contextlib +import logging +from argparse import ArgumentParser +from os import getpid +from pathlib import Path + +from convopyro import Conversation +from libbot import sync + +from classes.pyroclient import PyroClient +from modules.migrator import migrate_database +from modules.scheduler import scheduler + +logging.basicConfig( + level=logging.INFO, + format="%(name)s.%(funcName)s | %(levelname)s | %(message)s", + datefmt="[%X]", +) + +logger = logging.getLogger(__name__) + +parser = ArgumentParser( + prog="GarbageCollection", + description="Bot that notifies about upcoming garbage collection", +) + +parser.add_argument("--migrate", action="store_true") + +args = parser.parse_args() + +with contextlib.suppress(ImportError): + import uvloop + + uvloop.install() + + +def main(): + client = PyroClient( + scheduler=scheduler, commands_source=sync.json_read(Path("commands.json")) + ) + Conversation(client) + + if args.migrate: + migrate_database() + + try: + client.run() + except KeyboardInterrupt: + logger.warning("Forcefully shutting down with PID %s...", getpid()) + finally: + if client.scheduler is not None: + client.scheduler.shutdown() + exit() + + +if __name__ == "__main__": + main() diff --git a/modules/custom_filters.py b/modules/custom_filters.py new file mode 100644 index 0000000..37b019e --- /dev/null +++ b/modules/custom_filters.py @@ -0,0 +1,13 @@ +"""Custom message filters""" + +from pyrogram import filters +from pyrogram.types import Message + +from classes.pyroclient import PyroClient + + +async def _owner_func(_, __: PyroClient, message: Message): + return False if message.from_user is None else __.owner == message.from_user.id + + +owner = filters.create(_owner_func) diff --git a/modules/database.py b/modules/database.py new file mode 100644 index 0000000..f85e739 --- /dev/null +++ b/modules/database.py @@ -0,0 +1,28 @@ +"""Module that provides all database collections""" + +from typing import Any, Mapping + +from async_pymongo import AsyncClient, AsyncCollection, AsyncDatabase +from libbot.sync import config_get + +db_config: Mapping[str, Any] = config_get("database") + +if db_config["user"] is not None and db_config["password"] is not None: + con_string = "mongodb://{0}:{1}@{2}:{3}/{4}".format( + db_config["user"], + db_config["password"], + db_config["host"], + db_config["port"], + db_config["name"], + ) +else: + con_string = "mongodb://{0}:{1}/{2}".format( + db_config["host"], db_config["port"], db_config["name"] + ) + +db_client = AsyncClient(con_string) +db: AsyncDatabase = db_client.get_database(name=db_config["name"]) + +col_users: AsyncCollection = db.get_collection("users") +col_entries: AsyncCollection = db.get_collection("entries") +col_locations: AsyncCollection = db.get_collection("locations") diff --git a/modules/migrator.py b/modules/migrator.py new file mode 100644 index 0000000..5ebeb91 --- /dev/null +++ b/modules/migrator.py @@ -0,0 +1,22 @@ +from typing import Any, Mapping + +from libbot.sync import config_get +from mongodb_migrations.cli import MigrationManager +from mongodb_migrations.config import Configuration + + +def migrate_database() -> None: + """Apply migrations from folder `migrations/` to the database""" + db_config: Mapping[str, Any] = config_get("database") + + manager_config = Configuration( + { + "mongo_host": db_config["host"], + "mongo_port": db_config["port"], + "mongo_database": db_config["name"], + "mongo_username": db_config["user"], + "mongo_password": db_config["password"], + } + ) + manager = MigrationManager(manager_config) + manager.run() diff --git a/modules/reminder.py b/modules/reminder.py new file mode 100644 index 0000000..05d084c --- /dev/null +++ b/modules/reminder.py @@ -0,0 +1,72 @@ +import logging +from datetime import datetime, timedelta, timezone + +from libbot.pyrogram.classes import PyroClient +from pytz import timezone as pytz_timezone + +from classes.enums import GarbageType +from classes.location import Location +from classes.pyrouser import PyroUser +from modules.database import col_entries, col_users + +logger = logging.getLogger(__name__) + + +async def remind(app: PyroClient) -> None: + now = datetime.now() + + users = await col_users.find( + {"time_hour": now.hour, "time_minute": now.minute} + ).to_list() + + for user_db in users: + user = PyroUser(**user_db) + + logger.info("Processing %s...", user.id) + + if not user.enabled or user.location is None: + continue + + try: + location: Location = await app.get_location(user.location.id) # type: ignore + except ValueError: + continue + + user_date = ( + datetime.now(pytz_timezone(location.timezone)).replace( + second=0, microsecond=0 + ) + + timedelta(days=user.offset) + ).replace(tzinfo=timezone.utc) + + entries = await col_entries.find( + { + "location": {"$in": location.id}, + "date": user_date.replace(hour=0, minute=0), + } + ).to_list() + + logger.info("Entries of %s for %s: %s", user.id, user_date, entries) + + for entry in entries: + try: + garbage_type = app._( + str(GarbageType(entry["garbage_type"]).value), + "garbage_types", + locale=user.locale, + ) + garbage_date = datetime.strftime( + entry["date"], app._("date", "formats", locale=user.locale) + ) + + await app.send_message( + user.id, + "**Garbage Collection**\n\nType: {type}\nDate: {date}\n\nDon't forget to prepare your bin for collection!".format( + type=garbage_type, date=garbage_date + ), + ) + except Exception as exc: + logger.warning( + "Could not send a notification to %s due to %s", user.id, exc + ) + continue diff --git a/modules/scheduler.py b/modules/scheduler.py new file mode 100644 index 0000000..a5eb79d --- /dev/null +++ b/modules/scheduler.py @@ -0,0 +1,3 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +scheduler = AsyncIOScheduler() diff --git a/modules/utils.py b/modules/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/callbacks/callback.py b/plugins/callbacks/callback.py new file mode 100644 index 0000000..56c73f6 --- /dev/null +++ b/plugins/callbacks/callback.py @@ -0,0 +1,11 @@ +from pyrogram import filters +from pyrogram.types import CallbackQuery + +from classes.pyroclient import PyroClient + + +@PyroClient.on_callback_query(filters.regex("nothing")) # type: ignore +async def callback_nothing(app: PyroClient, callback: CallbackQuery): + await callback.answer( + text=app._("nothing", "callbacks", locale=callback.from_user.language_code) + ) diff --git a/plugins/commands/checkout.py b/plugins/commands/checkout.py new file mode 100644 index 0000000..e7ff747 --- /dev/null +++ b/plugins/commands/checkout.py @@ -0,0 +1,106 @@ +import logging +from io import BytesIO + +from convopyro import listen_message +from pykeyboard import ReplyButton, ReplyKeyboard +from pyrogram import filters +from pyrogram.types import Message, ReplyKeyboardRemove +from ujson import dumps + +from classes.pyroclient import PyroClient + +logger = logging.getLogger(__name__) + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["checkout"], prefixes=["/"]) # type: ignore +) +async def command_checkout(app: PyroClient, message: Message): + user = await app.find_user(message.from_user) + + user_data = BytesIO( + dumps(await user.checkout(), escape_forward_slashes=False).encode() + ) + + await message.reply_document( + user_data, + file_name="user_data.json", + ) + + # Data deletion request + keyboard_delete = ReplyKeyboard( + row_width=1, resize_keyboard=True, one_time_keyboard=True + ) + keyboard_delete.add( + ReplyButton("Yes, I want to delete it"), + ReplyButton("No, I don't want to delete it"), + ) + + await message.reply_text( + "Here's pretty much all the data bot has. Please, use these buttons to choose whether you want to delete your data from the bot.", + reply_markup=keyboard_delete, + ) + + while True: + answer_delete = await listen_message(app, message.chat.id, 300) + + if answer_delete is None or answer_delete.text == "/cancel": + await message.reply_text( + "Cancelled.", + reply_markup=ReplyKeyboardRemove(), + ) + return + + if answer_delete.text not in [ + "Yes, I want to delete it", + "No, I don't want to delete it", + ]: + await answer_delete.reply_text( + "Invalid answer provided. Use /cancel if you want to cancel this operation." + ) + continue + + if answer_delete.text in [ + "No, I don't want to delete it", + ]: + await answer_delete.reply_text( + "Alright, cancelled.", reply_markup=ReplyKeyboardRemove() + ) + return + + break + + # Confirmation + keyboard_confirm = ReplyKeyboard( + row_width=1, resize_keyboard=True, one_time_keyboard=True + ) + keyboard_confirm.add(ReplyButton("I agree and want to proceed")) + + await message.reply_text( + "Alright. Please, confirm that you want to delete your data from the bot.\n\nFollowing data will be deleted:\nSelected location, preferred language of the messages, notifications time and your notifications offset.", + reply_markup=keyboard_confirm, + ) + + while True: + answer_confirm = await listen_message(app, message.chat.id, 300) + + if answer_confirm is None or answer_confirm.text == "/cancel": + await message.reply_text( + "Cancelled.", + reply_markup=ReplyKeyboardRemove(), + ) + return + + if answer_confirm.text not in ["I agree and want to proceed"]: + await answer_confirm.reply_text( + "Invalid answer provided. Use /cancel if you want to cancel this operation." + ) + continue + + break + + await user.delete() + await answer_confirm.reply_text( + "Your data has been deleted. If you want to start using this bot again, please use /setup command. Otherwise delete/block the bot and do not interact with it anymore.", + reply_markup=ReplyKeyboardRemove(), + ) diff --git a/plugins/commands/help.py b/plugins/commands/help.py new file mode 100644 index 0000000..2864ec2 --- /dev/null +++ b/plugins/commands/help.py @@ -0,0 +1,13 @@ +from pyrogram import filters +from pyrogram.types import Message + +from classes.pyroclient import PyroClient + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["help"], prefixes=["/"]) # type: ignore +) +async def command_help(app: PyroClient, message: Message): + user = await app.find_user(message.from_user) + + await message.reply_text(app._("help", "messages", locale=user.locale)) diff --git a/plugins/commands/import.py b/plugins/commands/import.py new file mode 100644 index 0000000..c87c04c --- /dev/null +++ b/plugins/commands/import.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import List, Mapping, Union + +from convopyro import listen_message +from pyrogram import filters +from pyrogram.types import Message +from ujson import loads + +from classes.pyroclient import PyroClient +from modules import custom_filters +from modules.database import col_entries + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & custom_filters.owner & filters.command(["import"], prefixes=["/"]) # type: ignore +) +async def command_import(app: PyroClient, message: Message): + await message.reply_text("Alright. Send me a valid JSON.") + + while True: + answer = await listen_message(app, message.chat.id, 300) + + if answer is None or answer.text == "/cancel": + await message.reply_text("Cancelled.") + return + + if answer.document is None or answer.document.mime_type != "application/json": + await answer.reply_text( + "Invalid input. Please, send me a JSON file with entries." + ) + continue + + break + + file = await app.download_media(answer, in_memory=True) + + entries: List[Mapping[str, Union[str, int]]] = loads(bytes(file.getbuffer())) # type: ignore + + for entry in entries: + if not isinstance(entries, list): + await answer.reply_text("This is not a valid garbage collection JSON.") + return + + for key in ("locations", "garbage_type", "date"): + if ( + key not in entry + or (key == "garbage_type" and not isinstance(entry[key], int)) + or (key == "locations" and not isinstance(entry[key], list)) + ): + await answer.reply_text("This is not a valid garbage collection JSON.") + return + if key == "date": + try: + datetime.fromisoformat(str(entry[key])) + except (ValueError, TypeError): + await answer.reply_text( + "Entries contain invalid date formats. Use **ISO 8601** date format." + ) + return + + entries_clean: List[Mapping[str, Union[str, int, datetime]]] = [ + { + "locations": entry["locations"], + "garbage_type": entry["garbage_type"], + "date": datetime.fromisoformat(str(entry["date"])), + } + for entry in entries + ] + + await col_entries.insert_many(entries_clean) + + await answer.reply_text( + f"You have successfully inserted {len(entries_clean)} entries." + ) diff --git a/plugins/commands/remove_commands.py b/plugins/commands/remove_commands.py new file mode 100644 index 0000000..008b8a0 --- /dev/null +++ b/plugins/commands/remove_commands.py @@ -0,0 +1,12 @@ +from pyrogram import filters +from pyrogram.types import Message + +from classes.pyroclient import PyroClient + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["remove_commands"], prefixes=["/"]) # type: ignore +) +async def command_remove_commands(app: PyroClient, message: Message): + await message.reply_text("Okay.") + await app.remove_commands(command_sets=await app.collect_commands()) diff --git a/plugins/commands/set_offset.py b/plugins/commands/set_offset.py new file mode 100644 index 0000000..d073027 --- /dev/null +++ b/plugins/commands/set_offset.py @@ -0,0 +1,60 @@ +import logging +from datetime import datetime + +from convopyro import listen_message +from pyrogram import filters +from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove + +from classes.pyroclient import PyroClient + +logger = logging.getLogger(__name__) + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["set_offset"], prefixes=["/"]) # type: ignore +) +async def command_set_offset(app: PyroClient, message: Message): + user = await app.find_user(message.from_user) + + await message.reply_text( + "Alright. Please, send how many days in advance do you want to get a notification about the collection.", + reply_markup=ForceReply(placeholder="Number of days"), + ) + + while True: + answer = await listen_message(app, message.chat.id, 300) + + if answer is None or answer.text == "/cancel": + await message.reply_text("Cancelled.", reply_markup=ReplyKeyboardRemove()) + return + + try: + num = int(answer.text) + if num < 0 or num > 7: + raise ValueError( + "Offset bust not be less than 0 and greater than 7 days." + ) + except (ValueError, TypeError): + await answer.reply_text( + "Please, provide a valid integer number of days in range 0 to 7 (inclusive). Use /cancel if you want to cancel this operation." + ) + continue + + break + + offset = int(answer.text) + + await user.update_offset(offset) + + logger.info("User %s has set offset to %s", user.id, offset) + + notice = "" if user.enabled else "Execute /toggle to enable notifications." + + garbage_time = datetime( + 1970, 1, 1, hour=user.time_hour, minute=user.time_minute + ).strftime(app._("time", "formats")) + + await answer.reply_text( + f"Notifications time has been updated! You will now receive notification about collection **{offset} d.** before the collection at {garbage_time}. {notice}", + reply_markup=ReplyKeyboardRemove(), + ) diff --git a/plugins/commands/set_time.py b/plugins/commands/set_time.py new file mode 100644 index 0000000..242db7e --- /dev/null +++ b/plugins/commands/set_time.py @@ -0,0 +1,58 @@ +import logging +from datetime import datetime + +from convopyro import listen_message +from pyrogram import filters +from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove + +from classes.pyroclient import PyroClient + +logger = logging.getLogger(__name__) + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["set_time"], prefixes=["/"]) # type: ignore +) +async def command_set_time(app: PyroClient, message: Message): + user = await app.find_user(message.from_user) + + await message.reply_text( + "Alright. Please, send your desired time in HH:MM format.", + reply_markup=ForceReply(placeholder="Time as HH:MM"), + ) + + while True: + answer = await listen_message(app, message.chat.id, 300) + + if answer is None or answer.text == "/cancel": + await message.reply_text("Cancelled.", reply_markup=ReplyKeyboardRemove()) + return + + try: + datetime.strptime(answer.text, "%H:%M") + except ValueError: + await answer.reply_text( + "Please, provide a valid time in HH:MM format. Use /cancel if you want to cancel this operation." + ) + continue + + break + + user_time = datetime.strptime(answer.text, "%H:%M") + + await user.update_time(hour=user_time.hour, minute=user_time.minute) + + logger.info( + "User %s has selected notification time of %s", + user.id, + user_time.strftime("%H:%M"), + ) + + notice = "" if user.enabled else "Execute /toggle to enable notifications." + + garbage_time = user_time.strftime(app._("time", "formats")) + + await answer.reply_text( + f"Notifications time has been updated! You will now receive notification about collection {user.offset} d. before the collection at **{garbage_time}**. {notice}", + reply_markup=ReplyKeyboardRemove(), + ) diff --git a/plugins/commands/setup.py b/plugins/commands/setup.py new file mode 100644 index 0000000..b8cfcbc --- /dev/null +++ b/plugins/commands/setup.py @@ -0,0 +1,104 @@ +import logging + +from convopyro import listen_message +from libbot import i18n +from pykeyboard import ReplyButton, ReplyKeyboard +from pyrogram import filters +from pyrogram.types import Message, ReplyKeyboardRemove + +from classes.pyroclient import PyroClient + +logger = logging.getLogger(__name__) + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["setup"] + i18n.sync.in_all_locales("configure", "buttons"), prefixes=["/", ""]) # type: ignore +) +async def command_setup(app: PyroClient, message: Message): + user = await app.find_user(message.from_user) + + await message.reply_text( + "Holy... This one is still WIP...", reply_markup=ReplyKeyboardRemove() + ) + + # # City selection + # city_names = [city_iter.name for city_iter in await app.get_cities()] + # keyboard_cities = ReplyKeyboard(resize_keyboard=True, row_width=2) + # keyboard_cities.add(*[ReplyButton(name) for name in city_names]) + + # await message.reply_text( + # "Alright. Please, use the keyboard provided to choose your town.", + # reply_markup=keyboard_cities, + # ) + + # while True: + # answer_city = await listen_message(app, message.chat.id, 300) + + # if answer_city is None or answer_city.text == "/cancel": + # await message.reply_text("Cancelled.") + # return + + # if answer_city.text not in city_names: + # await answer_city.reply_text( + # "Please, select a valid town using keyboard provided. Use /cancel if you want to cancel this operation." + # ) + # continue + + # break + + # # City recognition + # city = await app.find_city(name=answer_city.text) + + # # District selection + # district_names = [district_iter.name for district_iter in city.districts] + # keyboard_districts = ReplyKeyboard(resize_keyboard=True, row_width=2) + # keyboard_districts.add(*[ReplyButton(name) for name in district_names]) + + # await message.reply_text( + # "Alright. Please, use the keyboard provided to choose your district.", + # reply_markup=keyboard_districts, + # ) + + # while True: + # answer_district = await listen_message(app, message.chat.id, 300) + + # if answer_district is None or answer_district.text == "/cancel": + # await message.reply_text("Cancelled.") + # return + + # if answer_district.text not in district_names: + # await answer_district.reply_text( + # "Please, select a valid district using keyboard provided. Use /cancel if you want to cancel this operation." + # ) + # continue + + # break + + # # District recognition + # district_results = city.find_district(answer_district.text) + + # if len(district_results) == 0: + # await answer_district.reply_text( + # "Something went wrong. Could not find this district in the database.", + # reply_markup=ReplyKeyboardRemove(), + # ) + # return + + # district = district_results[0] + + # await user.update_city(city.id) + # await user.update_district(district.id) + + # logger.info( + # "User %s has finished the location set up with city %s and district %s selected", + # user.id, + # city.id, + # district.id, + # ) + + # notice = "" if user.enabled else "Execute /toggle to enable notifications." + + # await answer_district.reply_text( + # f"All set! You will now receive notification about garbage collection in district **{district.name}** of the town **{city.name}**. {notice}", + # reply_markup=ReplyKeyboardRemove(), + # ) diff --git a/plugins/commands/shutdown.py b/plugins/commands/shutdown.py new file mode 100644 index 0000000..aa971c5 --- /dev/null +++ b/plugins/commands/shutdown.py @@ -0,0 +1,16 @@ +import asyncio + +from pyrogram import filters +from pyrogram.types import Message + +from classes.pyroclient import PyroClient + + +@PyroClient.on_message( + ~filters.scheduled + & filters.private + & filters.command(["shutdown", "reboot", "restart"], prefixes=["/"]) # type: ignore +) +async def command_shutdown(app: PyroClient, msg: Message): + if msg.from_user.id == app.owner: + asyncio.get_event_loop().create_task(app.stop()) diff --git a/plugins/commands/start.py b/plugins/commands/start.py new file mode 100644 index 0000000..69a09a0 --- /dev/null +++ b/plugins/commands/start.py @@ -0,0 +1,96 @@ +from datetime import datetime + +from convopyro import listen_message +from pyrogram import filters +from pyrogram.types import ( + KeyboardButton, + Message, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, +) + +from classes.pyroclient import PyroClient + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["start"], prefixes=["/"]) # type: ignore +) +async def command_start(app: PyroClient, message: Message): + user = await app.find_user(message.from_user) + + await message.reply_text(app._("start", "messages", locale=user.locale)) + + join_code = None if len(message.command) == 1 else message.command[1] + + if join_code is not None: + try: + location = await app.get_location(int(join_code)) + except ValueError: + await message.reply_text( + "🚫 You have provided the location but it does not seem to be a valid one. Please, use the command /setup to manually configure the location." + ) + return + + keyboard = ReplyKeyboardMarkup( + [ + [KeyboardButton("Yes, I want to use it")], + [KeyboardButton("No, I don't want to use it")], + ], + resize_keyboard=True, + one_time_keyboard=True, + ) + + await message.reply_text( + f"ℹ️ You have started the bot by the link containing a location **{location.name}**.\n\nPlease, confirm whether you want to use it as your location.", + reply_markup=keyboard, + ) + + while True: + answer = await listen_message(app, message.chat.id, 300) + + if answer is None or answer.text == "/cancel": + await message.reply_text( + "Cancelled.", reply_markup=ReplyKeyboardRemove() + ) + return + + if answer.text not in [ + "Yes, I want to use it", + "No, I don't want to use it", + ]: + await answer.reply_text( + "Please, select a valid location using keyboard provided. Use /cancel if you want to cancel this operation." + ) + continue + + if answer.text in [ + "No, I don't want to use it", + ]: + await answer.reply_text( + "Alright, you're on your own now. Please, use the command /setup to configure your location and start receiving reminders.", + reply_markup=ReplyKeyboardRemove(), + ) + return + + break + + await user.update_location(location.id) + + user_time = datetime(1970, 1, 1, user.time_hour, user.time_minute).strftime( + app._("time", "formats", locale=user.locale) + ) + await answer.reply_text( + f"✅ Finished! Your location is now **{location.name}**. You will receive reminders about garbage collection {user.offset} d. in advance at {user_time}.\n\nPlease, visit /help if you want to know how to change notifications time or disable them.", + reply_markup=ReplyKeyboardRemove(), + ) + return + + if user.location is None: + await message.reply_text( + "📍 Let's configure your location. Press the button on pop-up keyboard to start the process.", + reply_markup=ReplyKeyboardMarkup( + [[KeyboardButton(app._("configure", "buttons", locale=user.locale))]], + resize_keyboard=True, + one_time_keyboard=True, + ), + ) diff --git a/plugins/commands/toggle.py b/plugins/commands/toggle.py new file mode 100644 index 0000000..5f43bec --- /dev/null +++ b/plugins/commands/toggle.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from pyrogram import filters +from pyrogram.types import Message + +from classes.pyroclient import PyroClient + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["toggle"], prefixes=["/"]) # type: ignore +) +async def command_toggle(app: PyroClient, message: Message): + user = await app.find_user(message.from_user) + + await user.update_state(not user.enabled) + + if user.enabled: + await message.reply_text("Notifications have been disabled.") + return + + if user.location is None: + await message.reply_text( + f"Notifications have been enabled {user.offset} d. before garbage collection at {datetime(1970, 1, 1, user.time_hour, user.time_minute).strftime('%H:%M')}. Use /setup to select your location." + ) + return + + await message.reply_text( + f"Notifications have been enabled {user.offset} d. before garbage collection at {datetime(1970, 1, 1, user.time_hour, user.time_minute).strftime('%H:%M')} at the **{user.location.name}**." + ) diff --git a/plugins/commands/upcoming.py b/plugins/commands/upcoming.py new file mode 100644 index 0000000..3f85153 --- /dev/null +++ b/plugins/commands/upcoming.py @@ -0,0 +1,59 @@ +from datetime import datetime, timedelta, timezone + +from pyrogram import filters +from pyrogram.types import Message +from pytz import timezone as pytz_timezone + +from classes.garbage_entry import GarbageEntry +from classes.pyroclient import PyroClient +from modules.database import col_entries + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["upcoming"], prefixes=["/"]) # type: ignore +) +async def command_upcoming(app: PyroClient, message: Message): + user = await app.find_user(message.from_user) + + if user.location is None: + await message.reply_text( + "You have no location set. Use /setup to select your location." + ) + return + + date_min = ( + datetime.now(pytz_timezone(user.location.timezone)).replace( + second=0, microsecond=0 + ) + ).replace(tzinfo=timezone.utc) + date_max = ( + datetime.now(pytz_timezone(user.location.timezone)).replace( + second=0, microsecond=0 + ) + + timedelta(days=30) + ).replace(tzinfo=timezone.utc) + + entries = [ + await GarbageEntry.from_record(entry) + async for entry in col_entries.find( + { + "location": {"$in": user.location.id}, + "date": {"$gte": date_min, "$lte": date_max}, + } + ) + ] + + entries_text = "\n\n".join( + [ + f"**{entry.date.strftime(app._('date', 'formats', locale=user.locale))}**:\n{app._(str(entry.garbage_type.value), 'garbage_types')}" + for entry in entries + ] + ) + + if not entries: + await message.reply_text( + f"No garbage collection entries found for the next 30 days at **{user.location.name}**" + ) + return + + await message.reply_text(f"Upcoming garbage collection:\n\n{entries_text}") diff --git a/plugins/language.py b/plugins/language.py new file mode 100644 index 0000000..673b72b --- /dev/null +++ b/plugins/language.py @@ -0,0 +1,45 @@ +from typing import List + +from pykeyboard import InlineButton, InlineKeyboard +from pyrogram import filters +from pyrogram.types import CallbackQuery, Message + +from classes.callbacks import CallbackLanguage +from classes.pyroclient import PyroClient + + +@PyroClient.on_message( + ~filters.scheduled & filters.private & filters.command(["language"], prefixes=["/"]) # type: ignore +) +async def command_language(app: PyroClient, message: Message): + user = await app.find_user(message.from_user) + + keyboard = InlineKeyboard(row_width=2) + buttons: List[InlineButton] = [] + + for locale, data in app.in_every_locale("metadata").items(): + buttons.append( + InlineButton(f"{data['flag']} {data['name']}", f"language:{locale}") + ) + + keyboard.add(*buttons) + + await message.reply_text( + app._("locale_choice", "messages", locale=user.locale), + reply_markup=keyboard, + ) + + +@PyroClient.on_callback_query(filters.regex(r"language:[\s\S]*")) # type: ignore +async def callback_language(app: PyroClient, callback: CallbackQuery): + user = await app.find_user(callback.from_user) + parsed = CallbackLanguage.from_callback(callback) + + await user.update_locale(parsed.language) + + await callback.answer( + app._("locale_set", "callbacks", locale=parsed.language).format( + locale=app._("name", "metadata", locale=parsed.language) + ), + show_alert=True, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..39040b9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +aiohttp~=3.8.5 +apscheduler~=3.10.3 +convopyro==0.5 +mongodb-migrations==1.3.0 +pykeyboard==0.1.5 +tgcrypto==1.2.5 +ujson>=5.0.0 +uvloop==0.17.0 +--extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple +async_pymongo==0.1.4 +libbot[speed,pyrogram]==2.0.1 \ No newline at end of file diff --git a/validation/entries.json b/validation/entries.json new file mode 100644 index 0000000..ea5df61 --- /dev/null +++ b/validation/entries.json @@ -0,0 +1,37 @@ +{ + "$jsonSchema": { + "required": [ + "locations", + "garbage_type", + "date" + ], + "properties": { + "locations": { + "bsonType": [ + "array" + ], + "description": "IDs of the locations" + }, + "garbage_type": { + "bsonType": [ + "int" + ], + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "description": "Enum of garbage type. 0 - bio, 1 - plastic, 2 - paper, 3 - general, 4 - glass, 5 - unspecified" + }, + "date": { + "bsonType": [ + "date" + ], + "description": "Date of the collection" + } + } + } +} \ No newline at end of file diff --git a/validation/locations.json b/validation/locations.json new file mode 100644 index 0000000..acad8a4 --- /dev/null +++ b/validation/locations.json @@ -0,0 +1,45 @@ +{ + "$jsonSchema": { + "required": [ + "id", + "name", + "location", + "country", + "timezone" + ], + "properties": { + "id": { + "bsonType": [ + "int", + "long" + ], + "description": "Unique ID of the location" + }, + "name": { + "bsonType": [ + "string" + ], + "description": "Location's name" + }, + "location": { + "bsonType": [ + "array" + ], + "description": "Longitude and latitude of the location" + }, + "country": { + "bsonType": [ + "int", + "long" + ], + "description": "Location's country ID" + }, + "timezone": { + "bsonType": [ + "string" + ], + "description": "Location's timezone according to ISO 8601" + } + } + } +} \ No newline at end of file diff --git a/validation/users.json b/validation/users.json new file mode 100644 index 0000000..7187412 --- /dev/null +++ b/validation/users.json @@ -0,0 +1,58 @@ +{ + "$jsonSchema": { + "required": [ + "id", + "locale", + "enabled", + "location", + "offset", + "time_hour", + "time_minute" + ], + "properties": { + "id": { + "bsonType": [ + "int", + "long" + ], + "description": "Telegram ID of user" + }, + "locale": { + "bsonType": [ + "string", + "null" + ], + "description": "Preferred language of strings" + }, + "enabled": { + "bsonType": "bool", + "description": "Whether notifications are enabled" + }, + "location": { + "bsonType": [ + "int", + "long" + ], + "description": "ID of user's location" + }, + "offset": { + "bsonType": [ + "int" + ], + "description": "Offset between event and notification in days" + }, + "time_hour": { + "bsonType": [ + "int" + ], + "description": "Hour of notifications" + }, + "time_minute": { + "bsonType": [ + "int" + ], + "description": "Minute of notifications" + } + } + } +} \ No newline at end of file