diff --git a/classes/importer/abstract.py b/classes/importer/abstract.py new file mode 100644 index 0000000..096ec11 --- /dev/null +++ b/classes/importer/abstract.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class Importer(ABC): + """ + The Importer class represents the object with + functionality to import/export garbage collection + records and convert them to other object types. + """ + + @abstractmethod + async def import_data(self, data: Any) -> None: + pass + + @abstractmethod + async def export_data(self, data: Any) -> None: + pass diff --git a/classes/importer/csv.py b/classes/importer/csv.py new file mode 100644 index 0000000..f406135 --- /dev/null +++ b/classes/importer/csv.py @@ -0,0 +1,64 @@ +from codecs import decode +from csv import DictReader +from datetime import datetime +from typing import Any, Dict, List, Union + +from bson import ObjectId + +from classes.importer.abstract import Importer +from modules.database_api import col_entries + + +class ImporterCSV(Importer): + """ + The ImporterCSV class represents the object with + functionality to import/export garbage collection + records and convert them to other object types + from CSV files. + """ + + def __init__(self): + super(Importer, self).__init__() + + async def import_data(self, data: bytes) -> List[ObjectId]: + entries: List[Dict[str, Any]] = list( + DictReader(decode(data).split("\n"), delimiter=";") + ) + + for entry in entries: + entry["locations"] = ( + [int(entry["locations"])] + if "," not in entry["locations"] + else [int(id) for id in entry["locations"].split(",")] + ) + entry["garbage_type"] = int(entry["garbage_type"]) + + 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)) + ): + raise ValueError + + if key == "date": + try: + datetime.fromisoformat(str(entry[key])) + except (ValueError, TypeError) as exc: + raise ValueError from exc + + entries_clean: List[Dict[str, Union[str, int, datetime]]] = [ + { + "locations": entry["locations"], + "garbage_type": entry["garbage_type"], + "date": datetime.fromisoformat(str(entry["date"])), + } + for entry in entries + ] + + inserted = await col_entries.insert_many(entries_clean) + + return [] if inserted is None else inserted.inserted_ids + + async def export_data(self, data: Any) -> Any: + return None diff --git a/classes/importer/json.py b/classes/importer/json.py new file mode 100644 index 0000000..798a301 --- /dev/null +++ b/classes/importer/json.py @@ -0,0 +1,56 @@ +from datetime import datetime +from typing import Any, Dict, List, Union + +from bson import ObjectId +from ujson import loads + +from classes.importer.abstract import Importer +from modules.database_api import col_entries + + +class ImporterJSON(Importer): + """ + The ImporterJSON class represents the object with + functionality to import/export garbage collection + records and convert them to other object types + from JSON files. + """ + + def __init__(self): + super(Importer, self).__init__() + + async def import_data(self, data: bytes) -> List[ObjectId]: + entries: List[Dict[str, Any]] = loads(data) + + for entry in entries: + 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)) + ): + print("keys", entry) + raise ValueError + + if key == "date": + try: + datetime.fromisoformat(str(entry[key])) + except (ValueError, TypeError) as exc: + print("date", entry) + raise ValueError from exc + + entries_clean: List[Dict[str, Union[str, int, datetime]]] = [ + { + "locations": entry["locations"], + "garbage_type": entry["garbage_type"], + "date": datetime.fromisoformat(str(entry["date"])), + } + for entry in entries + ] + + inserted = await col_entries.insert_many(entries_clean) + + return [] if inserted is None else inserted.inserted_ids + + async def export_data(self, data: Any) -> Any: + return None diff --git a/classes/location.py b/classes/location.py index 26d9ebd..5a29557 100644 --- a/classes/location.py +++ b/classes/location.py @@ -1,9 +1,12 @@ from dataclasses import dataclass +from typing import Union from bson import ObjectId +from pytz import timezone as pytz_timezone +from pytz.tzinfo import BaseTzInfo, DstTzInfo from classes.point import Point -from modules.database import col_locations +from modules.database_api import col_locations @dataclass @@ -22,7 +25,7 @@ class Location: name: str location: Point country: int - timezone: str + timezone: Union[BaseTzInfo, DstTzInfo] @classmethod async def get(cls, id: int): @@ -32,6 +35,7 @@ class Location: raise ValueError(f"No location with ID {id} found.") db_entry["location"] = Point(*db_entry["location"]) # type: ignore + db_entry["timezone"] = pytz_timezone(db_entry["timezone"]) # type: ignore return cls(**db_entry) @@ -43,6 +47,7 @@ class Location: raise ValueError(f"No location with name {name} found.") db_entry["location"] = Point(*db_entry["location"]) # type: ignore + db_entry["timezone"] = pytz_timezone(db_entry["timezone"]) # type: ignore return cls(**db_entry) @@ -54,5 +59,6 @@ class Location: raise ValueError(f"No location near {lat}, {lon} found.") db_entry["location"] = Point(*db_entry["location"]) # type: ignore + db_entry["timezone"] = pytz_timezone(db_entry["timezone"]) # type: ignore return cls(**db_entry) diff --git a/classes/pyroclient.py b/classes/pyroclient.py index f4b5ebc..adbcf21 100644 --- a/classes/pyroclient.py +++ b/classes/pyroclient.py @@ -7,7 +7,7 @@ from pyrogram.types import User from classes.location import Location from classes.pyrouser import PyroUser -from modules.database import col_locations +from modules.database_api import col_locations from modules.reminder import remind @@ -18,6 +18,7 @@ class PyroClient(LibPyroClient): self.scheduler.add_job( remind, CronTrigger.from_crontab("* * * * *"), args=(self,) ) + self.contexts = [] async def start(self, **kwargs): await col_locations.create_index( diff --git a/classes/pyrouser.py b/classes/pyrouser.py index ef7d286..abb4a50 100644 --- a/classes/pyrouser.py +++ b/classes/pyrouser.py @@ -71,6 +71,15 @@ class PyroUser: return cls(**db_entry) + @classmethod + async def from_dict(cls, **kwargs): + if "location" in kwargs: + try: + kwargs["location"] = await Location.get(kwargs["location"]) # type: ignore + except ValueError: + kwargs["location"] = None # type: ignore + return cls(**kwargs) + async def update_locale(self, locale: Union[str, None]) -> None: """Change user's locale stored in the database. diff --git a/config_example.json b/config_example.json index 40c264a..3302d10 100644 --- a/config_example.json +++ b/config_example.json @@ -1,5 +1,6 @@ { "locale": "en", + "debug": false, "bot": { "owner": 0, "api_id": 0, @@ -13,7 +14,14 @@ "password": null, "host": "127.0.0.1", "port": 27017, - "name": "garbagebot" + "name": "garbage_bot" + }, + "database_api": { + "user": null, + "password": null, + "host": "127.0.0.1", + "port": 27017, + "name": "garbage_reminder" }, "search": { "radius": 0.1 diff --git a/locale/de.json b/locale/de.json new file mode 100644 index 0000000..7d39960 --- /dev/null +++ b/locale/de.json @@ -0,0 +1,98 @@ +{ + "metadata": { + "flag": "🇩🇪", + "name": "Deutsch", + "codes": [ + "de", + "de-DE" + ] + }, + "formats": { + "date": "%d.%m.%Y", + "time": "%H:%M" + }, + "garbage_types": { + "0": "🟤 Biotonne", + "1": "🟡 Gelber Sack", + "2": "🔵 Papiertonne", + "3": "⚫️ Restmüll", + "4": "🟢 Altglas", + "5": "❓ Unbestimmt" + }, + "commands": { + "help": "Hilfemenü anzeigen", + "setup": "Standort auswählen", + "toggle": "Benachrichtigungen aktivieren/deaktivieren", + "set_time": "Benachrichtigungszeit einstellen", + "set_offset": "Benachrichtigungstage-Offset festlegen", + "upcoming": "Sammlung für die nächsten 30 Tage", + "language": "Sprache der Botnachrichten ändern", + "checkout": "Benutzerdaten exportieren oder löschen", + "import": "Hochladen von JSON in die Datenbank", + "shutdown": "Bot ausschalten", + "remove_commands": "Alle Kommandos abmelden" + }, + "messages": { + "cancel": "Verwenden Sie /cancel, wenn Sie diesen Vorgang abbrechen möchten.", + "cancelled": "Die Operation wurde abgebrochen.", + "checkout_deleted": "🗑️ Ihre Daten wurden gelöscht. Wenn Sie diesen Bot wieder benutzen wollen, verwenden Sie bitte den Befehl /setup. Andernfalls löschen/blockieren Sie den Bot und interagieren Sie nicht mehr mit ihm.", + "checkout_deletion": "Prima. Bitte bestätigen Sie, dass Sie Ihre Daten aus dem Bot löschen möchten.\n\nDie folgenden Daten werden gelöscht:\n• Gewählter Sammlungsort\n• Bevorzugte Sprache für alle Nachrichten\n• Zeit der Benachrichtigungen\n• Offset der Benachrichtigungen\n\nVerwenden Sie die Tastatur, um den Vorgang zu bestätigen und fortzusetzen oder /cancel, um ihn abzubrechen.", + "checkout": "Das sind alle Daten, die der Bot hat. Bitte verwenden Sie die Tastatur, um zu wählen, ob Sie Ihre Daten aus dem Bot löschen möchten.", + "commands_removed": "✅ Alle derzeit registrierten Kommandos wurden unregistriert. Die Kommandos werden beim Start des Bots erneut registriert.", + "help": "🔔 Dieser Bot sendet Ihnen Benachrichtigungen über die Müllabfuhr nach Ihrem lokalen Zeitplan.\n\n**Verfügbare Kommandos**\n/help - Diese Menü anzeigen\n/setup - Standort wählen\n/toggle - Deaktivieren/Aktivieren der Benachrichtigungen\n/set_time - Einstellen der Erinnerungszeit\n/set_offset - Offset zwischen Erinnerung und Abholung einstellen\n/upcoming - Zeigt die nächste Abholung an\n/language - Die Sprache des Bots wählen\n/checkout - Daten exportieren oder löschen\n\n💭 Sie können auch vorschlagen, Ihre Stadt/Ihren Bezirk in den Bot aufzunehmen, indem Sie die Administratoren über [diesen Link]({url_contact}) kontaktieren und Ihren Zeitplan angeben.\n\n⚙️ Möchten Sie diesen Bot selbst hosten oder Änderungen vornehmen? Er ist Open-Source, Sie können ihn also forken. Werfen Sie einen Blick auf das [Bot-Repository]({url_repo}) für Details.\n\nViel Spaß beim Benutzen! 🤗", + "import_finished": "Sie haben erfolgreich {count} Einträge eingefügt.", + "import_invalid_date": "Die Einträge enthalten ungültige Datumsformate. Verwenden Sie das Datumsformat **ISO 8601**.", + "import_invalid_filetype": "Ungültige Eingabe. Bitte senden Sie mir eine JSON- oder CSV-Datei mit Einträgen. {cancel_notice}", + "import_invalid": "Dies ist keine gültige Abfalltermine-Datei.", + "import": "Okay. Senden Sie mir eine gültige Datei. Sie kann im JSON- oder CSV-Format sein. Lesen Sie mehr über die unterstützten Formate in der Dokumentation", + "locale_choice": "Prima. Bitte wählen Sie die Sprache mit der Tastatur unten.", + "location_empty": "Sie haben keinen Standort festgelegt. Verwenden Sie /setup, um Ihren Standort auszuwählen.", + "location_name_empty": "Es konnten keine Orte mit diesem Namen gefunden werden. Versuchen Sie, ihn umzuformulieren, oder stellen Sie sicher, dass Sie dieselbe Sprache und denselben Namen verwenden, wie er von Ihren örtlichen Behörden im Müllabfuhrplan angegeben ist.\n\n{cancel_notice}", + "location_name_invalid": "Bitte senden Sie den Namen des Ortes als Text. {cancel_notice}", + "location_name": "Bitte senden Sie mir einen Standortnamen. Es sollte der Name sein, der im Müllabfuhrplan Ihrer örtlichen Behörde verwendet wird. In der Regel ist dies der Name des Bezirks oder sogar der Stadt selbst.", + "location_select": "Wählen Sie den Ort über die Tastatur aus.", + "reminder": "**Müllabfuhr**\n\nTyp: {Typ}\nDatum: {Datum}\n\nVergessen Sie nicht, Ihre Tonne für die Abholung vorzubereiten!", + "search_nearby_empty": "Es konnten keine Orte in der Nähe gefunden werden. Versuchen wir es mit der Namenssuche.", + "selection_invalid": "Bitte wählen Sie eine gültige Option über die Tastatur aus. {cancel_notice}", + "set_offset_finished": "🔔 Benachrichtigungen Offset wurde aktualisiert! Sie erhalten nun eine Benachrichtigung über die Abholung **{offset} T.** vor der Abholung um {time}. {toggle_notice}", + "set_offset_invalid": "Bitte geben Sie eine gültige ganzzahlige Anzahl von Tagen im Bereich von 0 bis 7 (einschließlich) an. {cancel_notice}", + "set_offset": "Okay. Bitte geben Sie an, wie viele Tage im Voraus Sie eine Benachrichtigung über die Abholung erhalten möchten.", + "set_time_finished": "🔔 Die Benachrichtigungszeit wurde aktualisiert! Sie erhalten nun eine Benachrichtigung über die Abholung {offset} T. vor der Abholung um **{time}**. {toggle_notice}", + "set_time_invalid": "Bitte geben Sie eine gültige Uhrzeit im Format SS:MM an. {cancel_notice}", + "set_time": "Okay. Bitte senden Sie die gewünschte Zeit im Format SS:MM.", + "setup_finished": "✅ Fertig! Ihr Standort ist jetzt **{name}**. Sie werden die Benachrichtigungen über die Müllabfuhr {offset} T. im Voraus um {time} erhalten.", + "setup_retry": "ℹ️ Wenn Sie versuchen möchten, den Speicherort erneut auszuwählen, verwenden Sie den Kommando /setup.", + "setup": "⚙️ Beginnen wir die Konfiguration mit der Suche nach Ihrem Standort.\n\nBitte wählen Sie aus, ob Sie unter den Standorten in Ihrer Nähe suchen möchten oder direkt zur Suche nach dem Standortnamen übergehen wollen.\n\nBeachten Sie, dass der von Ihnen gesendete Standort **NICHT** irgendwo gespeichert wird und nur für die Standortsuche in der Datenbank verwendet wird.", + "start_code_invalid": "🚫 Sie haben den Bot über den Link gestartet, der einen Ort enthält, aber es scheint kein gültiger zu sein. Bitte verwenden Sie den Kommando /setup, um den Standort manuell zu konfigurieren.", + "start_code": "ℹ️ Sie haben den Bot über den Link gestartet, der einen Ort **{name}** enthält.\n\nBitte bestätigen Sie, ob Sie diesen als Ihren Standort verwenden möchten.", + "start_configure": "📍 Lassen Sie uns Ihren Standort konfigurieren. Drücken Sie die Taste auf der Pop-up-Tastatur, um den Vorgang zu starten.", + "start_selection_no": "Gut, Sie sind jetzt auf sich allein gestellt. Bitte verwenden Sie den Kommando /setup, um Ihren Standort zu konfigurieren und Erinnerungen zu erhalten.", + "start_selection_yes": "✅ Fertig! Ihr Standort ist jetzt **{name}**. Sie erhalten Erinnerungen an die Müllabfuhr {offset} T. im Voraus um {time}.\n\nBitte besuchen Sie /help Menü, wenn Sie wissen möchten, wie Sie die Zeit der Benachrichtigungen ändern oder sie deaktivieren können.", + "start": "👋 Herzlich willkommen!\n\nDieser kleine Open-Source-Bot soll Ihnen das Leben etwas erleichtern, indem er Sie über die nächste Müllabfuhr in Ihrer Nähe informiert.\n\nDurch die Nutzung dieses Bots akzeptieren Sie die [Datenschutzbestimmungen]({privacy_policy}), andernfalls blockieren und entfernen Sie diesen Bot bitte vor weiterer Interaktion.\n\nNun ist der offizielle Teil vorbei und Sie können sich mit dem Bot beschäftigen.", + "toggle_disabled": "🔕 Die Benachrichtigungen wurden deaktiviert.", + "toggle_enabled_location": "🔔 Benachrichtigungen wurden aktiviert {offset} T. vor der Sammlung um {time} am **{name}**.", + "toggle_enabled": "🔔 Benachrichtigungen wurden aktiviert {offset} T. vor der Sammlung um {time}. Verwenden Sie /setup, um Ihren Standort auszuwählen.", + "toggle": "Führen Sie /toggle aus, um Benachrichtigungen zu aktivieren.", + "upcoming_empty": "Keine Müllabfuhr-Einträge für die nächsten 30 Tage bei **{name}** gefunden", + "upcoming": "Bevorstehende Müllabfuhr:\n\n{entries}" + }, + "force_replies": { + "import": "JSON mit Abfalltermine", + "location_name": "Ortsname", + "set_offset": "Anzahl der Tage", + "set_time": "Uhrzeit als SS:MM" + }, + "buttons": { + "delete_confirm": "Ich stimme zu und möchte fortfahren", + "delete_no": "❌ Nein, ich möchte sie nicht löschen", + "delete_yes": "✅ Ja, ich möchte sie löschen", + "search_name": "🔎 Suche nach Ortsnamen", + "search_nearby": "📍 Suche in der Nähe", + "start_code_no": "❌ Nein, ich will es nicht benutzen", + "start_code_yes": "✅ Ja, ich möchte es benutzen", + "start_configure": "⚙️ Konfigurieren wir den Bot" + }, + "callbacks": { + "locale_set": "Ihre Sprache ist jetzt: {locale}" + } +} diff --git a/locale/en.json b/locale/en.json index 9f96544..b555e9e 100644 --- a/locale/en.json +++ b/locale/en.json @@ -42,10 +42,11 @@ "help": "🔔 This bot sends you notifications about garbage collection according to your local schedule.\n\n**Available commands**\n/help - Show this message\n/setup - Select the location\n/toggle - Disable/enable the reminders\n/set_time - Set the reminders' time\n/set_offset - Set offset between reminders and collection\n/upcoming - Show the upcoming collection\n/language - Select the bot's language\n/checkout - Export or remove your data\n\n💭 You can also suggest adding your town/district to the bot by contacting the admins using [this link]({url_contact}) and providing your schedule.\n\n⚙️ Want to host this bot yourself or make some changes? It's open-source, so you can basically fork it. Take a look at [bot's repository]({url_repo}) for details.\n\nHappy using! 🤗", "import_finished": "You have successfully inserted {count} entries.", "import_invalid_date": "Entries contain invalid date formats. Use **ISO 8601** date format.", - "import_invalid_filetype": "Invalid input. Please, send me a JSON file with entries. {cancel_notice}", - "import_invalid": "This is not a valid garbage collection JSON.", - "import": "Alright. Send me a valid JSON.", + "import_invalid_filetype": "Invalid input. Please, send me a JSON or CSV file with entries. {cancel_notice}", + "import_invalid": "This is not a valid garbage collection file.", + "import": "Alright. Send me a valid file. It can be in JSON or CSV format. Read more about supported formats in the documentation", "locale_choice": "Alright. Please choose the language using keyboard below.", + "location_empty": "You have no location set. Use /setup to select your location first.", "location_name_empty": "Could not find any locations by this name. Try rephrasing it or make sure you use the same location language and name itself as it in written by your local authorities in garbage collection schedule.\n\n{cancel_notice}", "location_name_invalid": "Please, send the name of the location as a text. {cancel_notice}", "location_name": "Please, send me a location name. It should be the name used in your local authorities' garbage collection schedule. This usually is a name of the district or even the town itself.", @@ -72,7 +73,6 @@ "toggle_enabled_location": "🔔 Notifications have been enabled {offset} d. before garbage collection at {time} at the **{name}**.", "toggle_enabled": "🔔 Notifications have been enabled {offset} d. before garbage collection at {time}. Use /setup to select your location.", "toggle": "Execute /toggle to enable notifications.", - "upcoming_empty_location": "You have no location set. Use /setup to select your location.", "upcoming_empty": "No garbage collection entries found for the next 30 days at **{name}**", "upcoming": "Upcoming garbage collection:\n\n{entries}" }, diff --git a/locale/ru.json b/locale/ru.json new file mode 100644 index 0000000..4c2a819 --- /dev/null +++ b/locale/ru.json @@ -0,0 +1,98 @@ +{ + "metadata": { + "codes": [ + "ru", + "ru-RU" + ], + "flag": "🇺🇦", + "name": "Російська" + }, + "messages": { + "cancelled": "Операцію скасовано.", + "setup": "⚙️ Почнемо налаштування з пошуку Вашого місцезнаходження.\n\nБудь ласка, виберіть, чи хочете Ви шукати серед найближчих до Вас локацій, чи одразу перейти до пошуку за назвою.\n\nЗверніть увагу, що надіслане Вами місцезнаходження **НЕ** зберігається ніде і використовується лише для пошуку місць поряд в базі даних.", + "cancel": "Якщо Ви хочете скасувати цю операцію, використовуйте /cancel.", + "checkout_deleted": "🗑️ Ваші дані було видалено. Якщо Ви хочете знову почати користуватися цим ботом, скористайтеся командою /setup. В іншому випадку видаліть/заблокуйте бота і більше не взаємодійте з ним.", + "checkout_deletion": "Гаразд. Будь ласка, підтвердіть, що Ви хочете видалити свої дані з бота.\n\nНаступні дані будуть видалені:\n• Вибране місце\n• Бажана мова всіх повідомлень\n• Час сповіщень\n• Зсув сповіщень\n\nВикористовуйте клавіатуру, щоб підтвердити й продовжити або /cancel, щоб перервати цю операцію.", + "checkout": "Це фактично всі дані, які має бот. Будь ласка, використовуйте ці кнопки, щоб вибрати, чи хочете Ви видалити свої дані з бота.", + "commands_removed": "✅ Всі зареєстровані на цю мить команди було видалено. Команди будуть зареєстровані знову при запуску бота.", + "help": "🔔 Цей бот надсилає сповіщення про вивезення сміття згідно з Вашим місцевим графіком.\n\n**Доступні команди**\n/help - Показати це повідомлення\n/setup - Вибрати місце розташування\n/toggle - Увімкнути/вимкнути нагадування\n/set_time - Встановити час нагадувань\n/set_offset - Встановити зсув між нагадуваннями та вивозом\n/upcoming - Показати майбутні вивезення\n/language - Обрати мову бота\n/checkout - Експортувати або видалити дані\n\nВи також можете запропонувати додати своє місто/район до бота, зв'язавшись з адміністраторами за [цим посиланням]({url_contact}) та вказавши свій розклад.\n\n⚙️ Бажаєте розмістити цього бота самостійно або внести деякі зміни? Бот має відкритий вихідний код, тож ви можете форкнути його. Ознайомтесь із [репозиторієм бота]({url_repo}) щоб дізнатись деталі.\n\nПриємного користування! 🤗", + "import_finished": "Ви успішно вставили {count} записів.", + "import_invalid_date": "Записи містять невірні формати дат. Використовуйте формат дати **ISO 8601**.", + "import_invalid_filetype": "Неправильний ввід. Будь ласка, надішліть мені JSON або CSV файл із записами. {cancel_notice}", + "import_invalid": "Це недійсний файл з даними про збір сміття.", + "import": "Гаразд. Надішліть мені правильний файл. Він може бути у форматі JSON або CSV. Дізнайтеся більше про підтримувані формати в документації", + "locale_choice": "Гаразд. Будь ласка, оберіть мову за допомогою клавіатури нижче.", + "location_empty": "У Вас не встановлено локацію вивозу. Оберіть свою локацію за допомогою /setup.", + "location_name_empty": "Не вдалося знайти жодного населеного пункту з такою назвою. Спробуйте перефразувати назву або переконайтеся, що Ви використовуєте ту саму мову та назву, що й місцева влада у графіку вивезення сміття.\n\n{cancel_notice}", + "location_name_invalid": "Будь ласка, надішліть назву місця у вигляді тексту. {cancel_notice}", + "location_name": "Будь ласка, надішліть мені назву населеного пункту. Це має бути назва, яка використовується у графіку вивезення сміття Вашою місцевою владою. Зазвичай це назва району або міста.", + "location_select": "Виберіть місце за допомогою показаної клавіатури.", + "reminder": "**Вивіз сміття**\n\nТип: {type}\nДата: {date}\n\nНе забудьте підготувати свій контейнер до збору!", + "search_nearby_empty": "Не вдалося знайти жодної локації поблизу. Спробуємо скористатися пошуком за назвою.", + "selection_invalid": "Будь ласка, виберіть правильний варіант за допомогою клавіатури. {cancel_notice}", + "set_offset_finished": "🔔 Час сповіщень було оновлено! Тепер Ви будете отримувати сповіщення про вивіз сміття за **{offset} д.** до вивозу о {time}. {toggle_notice}", + "set_offset_invalid": "Будь ласка, вкажіть дійсну цілу кількість днів у діапазоні від 0 до 7 (включно). {cancel_notice}", + "set_offset": "Гаразд. Будь ласка, напишіть, за скільки днів до збору Ви хочете отримати сповіщення про збір.", + "set_time_finished": "🔔 Час сповіщень було оновлено! Тепер Ви будете отримувати сповіщення про вивіз сміття за {offset} д. до вивозу о **{time}**. {toggle_notice}", + "set_time_invalid": "Будь ласка, вкажіть дійсний час у форматі ГГ:ХХ. {cancel_notice}", + "set_time": "Гаразд. Будь ласка, надішліть бажаний час у форматі ГГ:ХХ.", + "setup_finished": "✅ Готово! Ваше місцезнаходження тепер **{name}**. Ви будете отримувати сповіщення про вивезення сміття за {offset} д. заздалегідь о {time}.", + "setup_retry": "ℹ️ Якщо Ви захочете вибрати місце розташування, скористайтеся командою /setup.", + "start_code_invalid": "🚫 Ви запустили бота за посиланням, що містить локацію, але, схоже, вона не є дійсною. Будь ласка, скористайтеся командою /setup, щоб налаштувати локацію вручну.", + "start_code": "ℹ️ Ви запустили бота за посиланням, що містить локацію **{name}**.\n\nБудь ласка, підтвердіть, чи хочете Ви використовувати її як свою локацію для сповіщень.", + "start_configure": "📍 Налаштуймо Вашу локацію. Натисніть кнопку на показаній клавіатурі, щоб почати процес.", + "start_selection_no": "Гаразд, тепер Ви самі по собі. Будь ласка, скористайтеся командою /setup, щоб налаштувати своє місцезнаходження і почати отримувати нагадування.", + "start_selection_yes": "✅ Готово! Ваша локація тепер **{name}**. Ви будете отримувати нагадування про вивезення сміття за {offset} д. заздалегідь о {time}.\n\nБудь ласка, скористайтесь /help, якщо Ви хочете дізнатися, як змінити час сповіщень або вимкнути їх.", + "start": "👋 Вітання!\n\nЦей невеличкий бот з відкритим вихідним кодом створений для того, щоб трохи спростити Вам життя, надсилаючи сповіщення про вивіз сміття у вашому регіоні.\n\nКористуючись цим ботом, Ви приймаєте [Політику конфіденційності]({privacy_policy}), в іншому випадку, будь ласка, заблокуйте та видаліть цього бота перед подальшою взаємодією.\n\nТепер офіційна частина закінчена, тож Ви можете зануритися в бота.", + "toggle_disabled": "🔕 Сповіщення було вимкнено.", + "toggle_enabled_location": "🔔 Сповіщення увімкнено за {offset} д. до вивезення сміття о {time} для локації **{name}**.", + "toggle_enabled": "🔔 Сповіщення було увімкнено за {offset} д. до вивезення сміття о {time}. Оберіть своє розташування за допомогою /setup.", + "toggle": "Використовуйте /toggle, щоб увімкнути сповіщення.", + "upcoming_empty": "Не знайдено записів про вивезення сміття на найближчі 30 днів для **{name}**", + "upcoming": "Найближчі вивози сміття:\n\n{entries}" + }, + "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": "Видалити всі команди" + }, + "buttons": { + "delete_confirm": "Я погоджуюсь і хочу продовжити", + "delete_no": "❌ Ні, я не хочу видаляти їх", + "delete_yes": "✅ Так, я хочу видалити їх", + "search_name": "🔎 Пошук за назвою місця", + "search_nearby": "📍 Пошук найближчих локацій", + "start_code_no": "❌ Ні, я не хочу її використовувати", + "start_code_yes": "✅ Так, я хочу її використовувати", + "start_configure": "⚙️ Налаштуймо бота" + }, + "callbacks": { + "locale_set": "Встановлено мову: {locale}" + }, + "force_replies": { + "import": "JSON із записами вивозу сміття", + "location_name": "Назва локації", + "set_offset": "Кількість днів", + "set_time": "Час у вигляді ГГ:ХХ" + } +} diff --git a/locale/uk.json b/locale/uk.json index 718bed6..275f792 100644 --- a/locale/uk.json +++ b/locale/uk.json @@ -21,27 +21,78 @@ }, "commands": { "help": "Показати меню допомоги", - "setup": "Обрати місто та район", - "toggle": "Вимкнути/вимкнути повідомлення", - "set_time": "Встановити час повідомлень", - "set_offset": "Встановити дні випередження", - "upcoming": "Дати збирання на наступні 30 днів", + "setup": "Обрати місце розташування", + "toggle": "Увімкнути/вимкнути сповіщення", + "set_time": "Встановити час сповіщень", + "set_offset": "Встановити зміщення сповіщень", + "upcoming": "Дати збору на наступні 30 днів", "language": "Змінити мову повідомлень бота", - "checkout": "Експортувати чи видалити дані", + "checkout": "Експортувати або видалити дані", "import": "Завантажити з JSON до бази даних", "shutdown": "Вимкнути бота", "remove_commands": "Видалити всі команди" }, "messages": { - "help": "Привіт! Я твій бот!", + "cancel": "Якщо Ви хочете скасувати цю операцію, використовуйте /cancel.", + "cancelled": "Операцію скасовано.", + "checkout_deleted": "🗑️ Ваші дані було видалено. Якщо Ви хочете знову почати користуватися цим ботом, скористайтеся командою /setup. В іншому випадку видаліть/заблокуйте бота і більше не взаємодійте з ним.", + "checkout_deletion": "Гаразд. Будь ласка, підтвердіть, що Ви хочете видалити свої дані з бота.\n\nНаступні дані будуть видалені:\n• Вибране місце\n• Бажана мова всіх повідомлень\n• Час сповіщень\n• Зсув сповіщень\n\nВикористовуйте клавіатуру, щоб підтвердити й продовжити або /cancel, щоб перервати цю операцію.", + "checkout": "Це фактично всі дані, які має бот. Будь ласка, використовуйте ці кнопки, щоб вибрати, чи хочете Ви видалити свої дані з бота.", + "commands_removed": "✅ Всі зареєстровані на цю мить команди було видалено. Команди будуть зареєстровані знову при запуску бота.", + "help": "🔔 Цей бот надсилає сповіщення про вивезення сміття згідно з Вашим місцевим графіком.\n\n**Доступні команди**\n/help - Показати це повідомлення\n/setup - Вибрати місце розташування\n/toggle - Увімкнути/вимкнути нагадування\n/set_time - Встановити час нагадувань\n/set_offset - Встановити зсув між нагадуваннями та вивозом\n/upcoming - Показати майбутні вивезення\n/language - Обрати мову бота\n/checkout - Експортувати або видалити дані\n\nВи також можете запропонувати додати своє місто/район до бота, зв'язавшись з адміністраторами за [цим посиланням]({url_contact}) та вказавши свій розклад.\n\n⚙️ Бажаєте розмістити цього бота самостійно або внести деякі зміни? Бот має відкритий вихідний код, тож ви можете форкнути його. Ознайомтесь із [репозиторієм бота]({url_repo}) щоб дізнатись деталі.\n\nПриємного користування! 🤗", + "import_finished": "Ви успішно вставили {count} записів.", + "import_invalid_date": "Записи містять невірні формати дат. Використовуйте формат дати **ISO 8601**.", + "import_invalid_filetype": "Неправильний ввід. Будь ласка, надішліть мені JSON або CSV файл із записами. {cancel_notice}", + "import_invalid": "Це недійсний файл з даними про збір сміття.", + "import": "Гаразд. Надішліть мені правильний файл. Він може бути у форматі JSON або CSV. Дізнайтеся більше про підтримувані формати в документації", + "locale_choice": "Гаразд. Будь ласка, оберіть мову за допомогою клавіатури нижче.", + "location_empty": "У Вас не встановлено локацію вивозу. Оберіть свою локацію за допомогою /setup.", + "location_name_empty": "Не вдалося знайти жодного населеного пункту з такою назвою. Спробуйте перефразувати назву або переконайтеся, що Ви використовуєте ту саму мову та назву, що й місцева влада у графіку вивезення сміття.\n\n{cancel_notice}", + "location_name_invalid": "Будь ласка, надішліть назву місця у вигляді тексту. {cancel_notice}", + "location_name": "Будь ласка, надішліть мені назву населеного пункту. Це має бути назва, яка використовується у графіку вивезення сміття Вашою місцевою владою. Зазвичай це назва району або міста.", + "location_select": "Виберіть місце за допомогою показаної клавіатури.", + "reminder": "**Вивіз сміття**\n\nТип: {type}\nДата: {date}\n\nНе забудьте підготувати свій контейнер до збору!", + "search_nearby_empty": "Не вдалося знайти жодної локації поблизу. Спробуємо скористатися пошуком за назвою.", + "selection_invalid": "Будь ласка, виберіть правильний варіант за допомогою клавіатури. {cancel_notice}", + "set_offset_finished": "🔔 Час сповіщень було оновлено! Тепер Ви будете отримувати сповіщення про вивіз сміття за **{offset} д.** до вивозу о {time}. {toggle_notice}", + "set_offset_invalid": "Будь ласка, вкажіть дійсну цілу кількість днів у діапазоні від 0 до 7 (включно). {cancel_notice}", + "set_offset": "Гаразд. Будь ласка, напишіть, за скільки днів до збору Ви хочете отримати сповіщення про збір.", + "set_time_finished": "🔔 Час сповіщень було оновлено! Тепер Ви будете отримувати сповіщення про вивіз сміття за {offset} д. до вивозу о **{time}**. {toggle_notice}", + "set_time_invalid": "Будь ласка, вкажіть дійсний час у форматі ГГ:ХХ. {cancel_notice}", + "set_time": "Гаразд. Будь ласка, надішліть бажаний час у форматі ГГ:ХХ.", + "setup_finished": "✅ Готово! Ваше місцезнаходження тепер **{name}**. Ви будете отримувати сповіщення про вивезення сміття за {offset} д. заздалегідь о {time}.", + "setup_retry": "ℹ️ Якщо Ви захочете вибрати місце розташування, скористайтеся командою /setup.", + "setup": "⚙️ Почнемо налаштування з пошуку Вашого місцезнаходження.\n\nБудь ласка, виберіть, чи хочете Ви шукати серед найближчих до Вас локацій, чи одразу перейти до пошуку за назвою.\n\nЗверніть увагу, що надіслане Вами місцезнаходження **НЕ** зберігається ніде і використовується лише для пошуку місць поряд в базі даних.", + "start_code_invalid": "🚫 Ви запустили бота за посиланням, що містить локацію, але, схоже, вона не є дійсною. Будь ласка, скористайтеся командою /setup, щоб налаштувати локацію вручну.", + "start_code": "ℹ️ Ви запустили бота за посиланням, що містить локацію **{name}**.\n\nБудь ласка, підтвердіть, чи хочете Ви використовувати її як свою локацію для сповіщень.", + "start_configure": "📍 Налаштуймо Вашу локацію. Натисніть кнопку на показаній клавіатурі, щоб почати процес.", + "start_selection_no": "Гаразд, тепер Ви самі по собі. Будь ласка, скористайтеся командою /setup, щоб налаштувати своє місцезнаходження і почати отримувати нагадування.", + "start_selection_yes": "✅ Готово! Ваша локація тепер **{name}**. Ви будете отримувати нагадування про вивезення сміття за {offset} д. заздалегідь о {time}.\n\nБудь ласка, скористайтесь /help, якщо Ви хочете дізнатися, як змінити час сповіщень або вимкнути їх.", "start": "👋 Вітання!\n\nЦей невеличкий бот з відкритим вихідним кодом створений для того, щоб трохи спростити Вам життя, надсилаючи сповіщення про вивіз сміття у вашому регіоні.\n\nКористуючись цим ботом, Ви приймаєте [Політику конфіденційності]({privacy_policy}), в іншому випадку, будь ласка, заблокуйте та видаліть цього бота перед подальшою взаємодією.\n\nТепер офіційна частина закінчена, тож Ви можете зануритися в бота.", - "locale_choice": "Гаразд. Будь ласка, оберіть мову за допомогою клавіатури нижче." + "toggle_disabled": "🔕 Сповіщення було вимкнено.", + "toggle_enabled_location": "🔔 Сповіщення увімкнено за {offset} д. до вивезення сміття о {time} для локації **{name}**.", + "toggle_enabled": "🔔 Сповіщення було увімкнено за {offset} д. до вивезення сміття о {time}. Оберіть своє розташування за допомогою /setup.", + "toggle": "Використовуйте /toggle, щоб увімкнути сповіщення.", + "upcoming_empty": "Не знайдено записів про вивезення сміття на найближчі 30 днів для **{name}**", + "upcoming": "Найближчі вивози сміття:\n\n{entries}" }, "buttons": { - "configure": "Давайте налаштуємо бота" + "delete_confirm": "Я погоджуюсь і хочу продовжити", + "delete_no": "❌ Ні, я не хочу видаляти їх", + "delete_yes": "✅ Так, я хочу видалити їх", + "search_name": "🔎 Пошук за назвою місця", + "search_nearby": "📍 Пошук найближчих локацій", + "start_code_no": "❌ Ні, я не хочу її використовувати", + "start_code_yes": "✅ Так, я хочу її використовувати", + "start_configure": "⚙️ Налаштуймо бота" }, "callbacks": { "locale_set": "Встановлено мову: {locale}" }, - "force_replies": {} -} \ No newline at end of file + "force_replies": { + "import": "JSON із записами вивозу сміття", + "location_name": "Назва локації", + "set_offset": "Кількість днів", + "set_time": "Час у вигляді ГГ:ХХ" + } +} diff --git a/main.py b/main.py index af04103..6aa78c8 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ from modules.migrator import migrate_database from modules.scheduler import scheduler logging.basicConfig( - level=logging.INFO, + level=logging.DEBUG if sync.config_get("debug") else logging.INFO, format="%(name)s.%(funcName)s | %(levelname)s | %(message)s", datefmt="[%X]", ) diff --git a/modules/custom_filters.py b/modules/custom_filters.py index 37b019e..4b3c709 100644 --- a/modules/custom_filters.py +++ b/modules/custom_filters.py @@ -10,4 +10,9 @@ async def _owner_func(_, __: PyroClient, message: Message): return False if message.from_user is None else __.owner == message.from_user.id +async def _context_func(_, __: PyroClient, message: Message): + return message.from_user.id in __.contexts + + owner = filters.create(_owner_func) +context = filters.create(_context_func) diff --git a/modules/database.py b/modules/database.py index f85e739..03e589b 100644 --- a/modules/database.py +++ b/modules/database.py @@ -1,4 +1,4 @@ -"""Module that provides all database collections""" +"""Module that provides bot's database collections.""" from typing import Any, Mapping @@ -24,5 +24,3 @@ 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/database_api.py b/modules/database_api.py new file mode 100644 index 0000000..fcbf92a --- /dev/null +++ b/modules/database_api.py @@ -0,0 +1,29 @@ +"""Module that provides API database collections. +It's possible to use REST API client instead, but +using MongoDB directly is MUCH faster this way.""" + +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_api") + +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_entries: AsyncCollection = db.get_collection("entries") +col_locations: AsyncCollection = db.get_collection("locations") diff --git a/modules/reminder.py b/modules/reminder.py index b845303..e2151e0 100644 --- a/modules/reminder.py +++ b/modules/reminder.py @@ -1,26 +1,38 @@ import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta +from bson import json_util 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 +from modules.database import col_users +from modules.database_api import col_entries +from modules.utils import from_utc logger = logging.getLogger(__name__) async def remind(app: PyroClient) -> None: - now = datetime.now() + utcnow = datetime.utcnow() + + logger.debug("Performing reminder lookup for %s (UTCNOW)", utcnow) users = await col_users.find( - {"time_hour": now.hour, "time_minute": now.minute} + {"time_hour": utcnow.hour, "time_minute": utcnow.minute} ).to_list() + logger.debug( + "Found following reminders for %s (UTC NOW): %s", + utcnow, + json_util.dumps(users, indent=None), + ) + for user_db in users: - user = PyroUser(**user_db) + logger.debug("Processing user %s...", json_util.dumps(user_db, indent=None)) + + user = await PyroUser.from_dict(**user_db) if not user.enabled or user.location is None: continue @@ -30,23 +42,27 @@ async def remind(app: PyroClient) -> None: except ValueError: continue - user_date = ( - datetime.now(pytz_timezone(location.timezone)).replace( - second=0, microsecond=0 - ) - + timedelta(days=user.offset) - ).replace(tzinfo=timezone.utc) + user_date = from_utc( + datetime.utcnow() + timedelta(days=user.offset), + user.location.timezone.zone, + ).replace(hour=0, minute=0, second=0, microsecond=0) entries = await col_entries.find( { - "location": {"$in": location.id}, - "date": user_date.replace(hour=0, minute=0), + "locations": location.id, + "date": user_date, } ).to_list() logger.info("Entries of %s for %s: %s", user.id, user_date, entries) for entry in entries: + logger.debug( + "Sending %s notification about %s", + user.id, + json_util.dumps(entry, indent=None), + ) + try: garbage_type = app._( str(GarbageType(entry["garbage_type"]).value), diff --git a/modules/search_name.py b/modules/search_name.py index 8d497be..534d726 100644 --- a/modules/search_name.py +++ b/modules/search_name.py @@ -6,7 +6,7 @@ from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove from classes.location import Location from classes.pyroclient import PyroClient -from modules.database import col_locations +from modules.database_api import col_locations async def search_name(app: PyroClient, message: Message) -> Union[Location, None]: @@ -15,14 +15,16 @@ async def search_name(app: PyroClient, message: Message) -> Union[Location, None location: Union[Location, None] = None await message.reply_text( - app._("location_request_name", "messages", locale=user.locale), + app._("location_name", "messages", locale=user.locale), reply_markup=ForceReply( placeholder=app._("location_name", "force_replies", locale=user.locale) ), ) while location is None: + app.contexts.append(message.from_user.id) answer = await listen_message(app, message.chat.id, 300) + app.contexts.remove(message.from_user.id) if answer is None or answer.text == "/cancel": await message.reply_text( @@ -48,7 +50,7 @@ async def search_name(app: PyroClient, message: Message) -> Union[Location, None locations = await col_locations.find(query).limit(6).to_list() - if len(locations) == 0: + if len(locations) == 0 or locations is None: await message.reply_text( app._("location_name_empty", "messages", locale=user.locale).format( cancel_notice=app._("cancel", "messages", locale=user.locale) @@ -61,6 +63,8 @@ async def search_name(app: PyroClient, message: Message) -> Union[Location, None ) continue + locations.reverse() + keyboard = ReplyKeyboard(resize_keyboard=True, row_width=2) keyboard.add(*[ReplyButton(db_record["name"]) for db_record in locations]) @@ -70,7 +74,9 @@ async def search_name(app: PyroClient, message: Message) -> Union[Location, None ) while True: + app.contexts.append(message.from_user.id) answer = await listen_message(app, message.chat.id, 300) + app.contexts.remove(message.from_user.id) if answer is None or answer.text == "/cancel": await message.reply_text( diff --git a/modules/search_nearby.py b/modules/search_nearby.py index be94d8e..29604a3 100644 --- a/modules/search_nearby.py +++ b/modules/search_nearby.py @@ -6,7 +6,7 @@ from pyrogram.types import Message, ReplyKeyboardRemove from classes.location import Location from classes.pyroclient import PyroClient -from modules.database import col_locations +from modules.database_api import col_locations from modules.search_name import search_name @@ -32,6 +32,8 @@ async def search_nearby(app: PyroClient, message: Message) -> Union[Location, No ) return await search_name(app, message) + locations.reverse() + keyboard = ReplyKeyboard(resize_keyboard=True, row_width=2) keyboard.add(*[ReplyButton(db_record["name"]) for db_record in locations]) @@ -41,7 +43,10 @@ async def search_nearby(app: PyroClient, message: Message) -> Union[Location, No ) while True: + app.contexts.append(message.from_user.id) answer = await listen_message(app, message.chat.id, 300) + app.contexts.remove(message.from_user.id) + location: Union[Location, None] = None if answer is None or answer.text == "/cancel": diff --git a/modules/utils.py b/modules/utils.py index e69de29..aaece4b 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -0,0 +1,38 @@ +from datetime import datetime +from typing import Union + +from pytz import UTC +from pytz import timezone as pytz_timezone + + +def to_utc(date: datetime, timezone: Union[str, None] = None) -> datetime: + """Move timezone unaware datetime object to UTC timezone and return it. + + ### Args: + * date (`datetime`): Datetime to be converted. + * timezone (`Union[str, None] = None`): Timezone name (must be pytz-compatible). Defaults to `None` (UTC). + + ### Returns: + * `datetime`: Timezone unaware datetime in UTC with timezone's offset applied to it. + """ + timezone = "UTC" if timezone is None else timezone + return pytz_timezone(timezone).localize(date).astimezone(UTC).replace(tzinfo=None) + + +def from_utc(date: datetime, timezone: Union[str, None] = None) -> datetime: + """Move timezone unaware datetime object to the timezone specified and return it. + + ### Args: + * date (`datetime`): Datetime to be converted. + * timezone (`Union[str, None] = None`): Timezone name (must be pytz-compatible). Defaults to `None` (UTC). + + ### Returns: + * `datetime`: Timezone unaware datetime in timezone provided with offset from UTC applied to it. + """ + timezone = "UTC" if timezone is None else timezone + return ( + pytz_timezone("UTC") + .localize(date) + .astimezone(pytz_timezone(timezone)) + .replace(tzinfo=None) + ) diff --git a/plugins/commands/checkout.py b/plugins/commands/checkout.py index e5d2528..20210c5 100644 --- a/plugins/commands/checkout.py +++ b/plugins/commands/checkout.py @@ -8,12 +8,13 @@ from pyrogram.types import Message, ReplyKeyboardRemove from ujson import dumps from classes.pyroclient import PyroClient +from modules import custom_filters logger = logging.getLogger(__name__) @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["checkout"], prefixes=["/"]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["checkout"], prefixes=["/"]) & ~custom_filters.context # type: ignore ) async def command_checkout(app: PyroClient, message: Message): user = await app.find_user(message.from_user) @@ -42,7 +43,9 @@ async def command_checkout(app: PyroClient, message: Message): ) while True: + app.contexts.append(message.from_user.id) answer_delete = await listen_message(app, message.chat.id, 300) + app.contexts.remove(message.from_user.id) if answer_delete is None or answer_delete.text == "/cancel": await message.reply_text( @@ -84,7 +87,9 @@ async def command_checkout(app: PyroClient, message: Message): ) while True: + app.contexts.append(message.from_user.id) answer_confirm = await listen_message(app, message.chat.id, 300) + app.contexts.remove(message.from_user.id) if answer_confirm is None or answer_confirm.text == "/cancel": await message.reply_text( diff --git a/plugins/commands/help.py b/plugins/commands/help.py index 6e211e9..f96f56d 100644 --- a/plugins/commands/help.py +++ b/plugins/commands/help.py @@ -2,10 +2,11 @@ from pyrogram import filters from pyrogram.types import Message from classes.pyroclient import PyroClient +from modules import custom_filters @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["help"], prefixes=["/"]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["help"], prefixes=["/"]) & ~custom_filters.context # type: ignore ) async def command_help(app: PyroClient, message: Message): user = await app.find_user(message.from_user) diff --git a/plugins/commands/import.py b/plugins/commands/import.py index df32cb2..9a68c56 100644 --- a/plugins/commands/import.py +++ b/plugins/commands/import.py @@ -1,14 +1,11 @@ -from datetime import datetime -from typing import List, Mapping, Union - from convopyro import listen_message from pyrogram import filters from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove -from ujson import loads +from classes.importer.csv import ImporterCSV +from classes.importer.json import ImporterJSON from classes.pyroclient import PyroClient from modules import custom_filters -from modules.database import col_entries @PyroClient.on_message( @@ -32,7 +29,10 @@ async def command_import(app: PyroClient, message: Message): ) return - if answer.document is None or answer.document.mime_type != "application/json": + if answer.document is None or answer.document.mime_type not in [ + "application/json", + "text/csv", + ]: await answer.reply_text( app._("import_invalid_filetype", "messages", locale=user.locale).format( cancel_notice=app._("cancel", "messages", locale=user.locale) @@ -44,51 +44,36 @@ async def command_import(app: PyroClient, message: Message): file = await app.download_media(answer, in_memory=True) - entries: List[Mapping[str, Union[str, int]]] = loads(bytes(file.getbuffer())) # type: ignore + data: bytes = bytes(file.getbuffer()) # type: ignore - for entry in entries: - if not isinstance(entries, list): - await answer.reply_text( - app._("import_invalid", "messages", locale=user.locale), - reply_markup=ReplyKeyboardRemove(), - ) - return + # I'd like to replace it with switch-case, but 3.9 compatibility + # is still more important to be there. Although refactor may be + # done in the near future as Python 3.9 EOL gets nearer. + if answer.document.mime_type == "application/json": + importer = ImporterJSON() + elif answer.document.mime_type == "text/csv": + importer = ImporterCSV() + else: + await answer.reply_text( + app._("import_invalid_filetype", "messages", locale=user.locale).format( + cancel_notice="" + ), + reply_markup=ReplyKeyboardRemove(), + ) + 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( - app._("import_invalid", "messages", locale=user.locale), - reply_markup=ReplyKeyboardRemove(), - ) - return - if key == "date": - try: - datetime.fromisoformat(str(entry[key])) - except (ValueError, TypeError): - await answer.reply_text( - app._("import_invalid_date", "messages", locale=user.locale), - reply_markup=ReplyKeyboardRemove(), - ) - 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) + try: + import_result = await importer.import_data(data) + except ValueError: + await answer.reply_text( + app._("import_invalid", "messages", locale=user.locale), + reply_markup=ReplyKeyboardRemove(), + ) + return await answer.reply_text( app._("import_finished", "messages", locale=user.locale).format( - count=len(entries_clean) + count=len(import_result) ), reply_markup=ReplyKeyboardRemove(), ) diff --git a/plugins/commands/remove_commands.py b/plugins/commands/remove_commands.py index e15ebd7..5541d3e 100644 --- a/plugins/commands/remove_commands.py +++ b/plugins/commands/remove_commands.py @@ -2,10 +2,11 @@ from pyrogram import filters from pyrogram.types import Message from classes.pyroclient import PyroClient +from modules import custom_filters @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["remove_commands"], prefixes=["/"]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["remove_commands"], prefixes=["/"]) & ~custom_filters.context # type: ignore ) async def command_remove_commands(app: PyroClient, message: Message): user = await app.find_user(message.from_user) diff --git a/plugins/commands/set_offset.py b/plugins/commands/set_offset.py index 042c32c..30fce12 100644 --- a/plugins/commands/set_offset.py +++ b/plugins/commands/set_offset.py @@ -6,16 +6,24 @@ from pyrogram import filters from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove from classes.pyroclient import PyroClient +from modules import custom_filters +from modules.utils import from_utc logger = logging.getLogger(__name__) @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["set_offset"], prefixes=["/"]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["set_offset"], prefixes=["/"]) & ~custom_filters.context # type: ignore ) async def command_set_offset(app: PyroClient, message: Message): user = await app.find_user(message.from_user) + if user.location is None: + await message.reply_text( + app._("location_empty", "messages", locale=user.locale) + ) + return + await message.reply_text( app._("set_offset", "messages", locale=user.locale), reply_markup=ForceReply( @@ -24,7 +32,9 @@ async def command_set_offset(app: PyroClient, message: Message): ) while True: + app.contexts.append(message.from_user.id) answer = await listen_message(app, message.chat.id, 300) + app.contexts.remove(message.from_user.id) if answer is None or answer.text == "/cancel": await message.reply_text( @@ -55,8 +65,9 @@ async def command_set_offset(app: PyroClient, message: Message): logger.info("User %s has set offset to %s", user.id, offset) - garbage_time = datetime( - 1970, 1, 1, hour=user.time_hour, minute=user.time_minute + garbage_time = from_utc( + datetime(1970, 1, 1, user.time_hour, user.time_minute), + None if user.location is None else user.location.timezone.zone, ).strftime(app._("time", "formats")) await answer.reply_text( diff --git a/plugins/commands/set_time.py b/plugins/commands/set_time.py index b4c6e49..166b384 100644 --- a/plugins/commands/set_time.py +++ b/plugins/commands/set_time.py @@ -6,16 +6,24 @@ from pyrogram import filters from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove from classes.pyroclient import PyroClient +from modules import custom_filters +from modules.utils import to_utc logger = logging.getLogger(__name__) @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["set_time"], prefixes=["/"]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["set_time"], prefixes=["/"]) & ~custom_filters.context # type: ignore ) async def command_set_time(app: PyroClient, message: Message): user = await app.find_user(message.from_user) + if user.location is None: + await message.reply_text( + app._("location_empty", "messages", locale=user.locale) + ) + return + await message.reply_text( app._("set_time", "messages", locale=user.locale), reply_markup=ForceReply( @@ -24,7 +32,9 @@ async def command_set_time(app: PyroClient, message: Message): ) while True: + app.contexts.append(message.from_user.id) answer = await listen_message(app, message.chat.id, 300) + app.contexts.remove(message.from_user.id) if answer is None or answer.text == "/cancel": await message.reply_text( @@ -45,7 +55,13 @@ async def command_set_time(app: PyroClient, message: Message): break - user_time = datetime.strptime(answer.text, "%H:%M") + now = datetime.now() + + parsed_time = datetime.strptime(answer.text, "%H:%M").replace( + year=now.year, month=now.month, day=now.day, second=0, microsecond=0 + ) + + user_time = to_utc(parsed_time, user.location.timezone.zone) await user.update_time(hour=user_time.hour, minute=user_time.minute) @@ -55,7 +71,7 @@ async def command_set_time(app: PyroClient, message: Message): user_time.strftime("%H:%M"), ) - garbage_time = user_time.strftime(app._("time", "formats")) + garbage_time = parsed_time.strftime(app._("time", "formats")) await answer.reply_text( app._("set_time_finished", "messages", locale=user.locale).format( diff --git a/plugins/commands/setup.py b/plugins/commands/setup.py index 3b1d2ec..3790831 100644 --- a/plugins/commands/setup.py +++ b/plugins/commands/setup.py @@ -8,14 +8,16 @@ from pyrogram import filters from pyrogram.types import Message, ReplyKeyboardRemove from classes.pyroclient import PyroClient +from modules import custom_filters from modules.search_name import search_name from modules.search_nearby import search_nearby +from modules.utils import from_utc logger = logging.getLogger(__name__) @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["setup"] + i18n.sync.in_all_locales("configure", "buttons"), prefixes=["/", ""]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["setup"] + i18n.sync.in_all_locales("start_configure", "buttons"), prefixes=["/", ""]) & ~custom_filters.context # type: ignore ) async def command_setup(app: PyroClient, message: Message): user = await app.find_user(message.from_user) @@ -34,7 +36,9 @@ async def command_setup(app: PyroClient, message: Message): ) while True: + app.contexts.append(message.from_user.id) answer_type = await listen_message(app, message.chat.id, 300) + app.contexts.remove(message.from_user.id) if answer_type is None or answer_type.text == "/cancel": await message.reply_text( @@ -70,9 +74,10 @@ async def command_setup(app: PyroClient, message: Message): 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) - ) + user_time = from_utc( + datetime(1970, 1, 1, user.time_hour, user.time_minute), + None if user.location is None else user.location.timezone.zone, + ).strftime(app._("time", "formats", locale=user.locale)) await message.reply_text( app._("setup_finished", "messages", locale=user.locale).format( diff --git a/plugins/commands/shutdown.py b/plugins/commands/shutdown.py index aa971c5..470241c 100644 --- a/plugins/commands/shutdown.py +++ b/plugins/commands/shutdown.py @@ -4,12 +4,14 @@ from pyrogram import filters from pyrogram.types import Message from classes.pyroclient import PyroClient +from modules import custom_filters @PyroClient.on_message( ~filters.scheduled & filters.private - & filters.command(["shutdown", "reboot", "restart"], prefixes=["/"]) # type: ignore + & filters.command(["shutdown", "reboot", "restart"], prefixes=["/"]) + & ~custom_filters.context # type: ignore ) async def command_shutdown(app: PyroClient, msg: Message): if msg.from_user.id == app.owner: diff --git a/plugins/commands/start.py b/plugins/commands/start.py index cb5ee98..6cdd43e 100644 --- a/plugins/commands/start.py +++ b/plugins/commands/start.py @@ -10,10 +10,12 @@ from pyrogram.types import ( ) from classes.pyroclient import PyroClient +from modules import custom_filters +from modules.utils import from_utc @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["start"], prefixes=["/"]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["start"], prefixes=["/"]) & ~custom_filters.context # type: ignore ) async def command_start(app: PyroClient, message: Message): user = await app.find_user(message.from_user) @@ -52,7 +54,9 @@ async def command_start(app: PyroClient, message: Message): ) while True: + app.contexts.append(message.from_user.id) answer = await listen_message(app, message.chat.id, 300) + app.contexts.remove(message.from_user.id) if answer is None or answer.text == "/cancel": await message.reply_text( @@ -82,9 +86,10 @@ async def command_start(app: PyroClient, message: Message): 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) - ) + user_time = from_utc( + datetime(1970, 1, 1, user.time_hour, user.time_minute), + None if user.location is None else user.location.timezone.zone, + ).strftime(app._("time", "formats", locale=user.locale)) await answer.reply_text( app._("start_selection_yes", "messages", locale=user.locale).format( name=location.name, offset=user.offset, time=user_time diff --git a/plugins/commands/toggle.py b/plugins/commands/toggle.py index 91263e0..1b67107 100644 --- a/plugins/commands/toggle.py +++ b/plugins/commands/toggle.py @@ -4,10 +4,12 @@ from pyrogram import filters from pyrogram.types import Message from classes.pyroclient import PyroClient +from modules import custom_filters +from modules.utils import from_utc @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["toggle"], prefixes=["/"]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["toggle"], prefixes=["/"]) & ~custom_filters.context # type: ignore ) async def command_toggle(app: PyroClient, message: Message): user = await app.find_user(message.from_user) @@ -20,7 +22,10 @@ async def command_toggle(app: PyroClient, message: Message): ) return - user_time = datetime(1970, 1, 1, user.time_hour, user.time_minute).strftime("%H:%M") + user_time = from_utc( + datetime(1970, 1, 1, user.time_hour, user.time_minute), + None if user.location is None else user.location.timezone.zone, + ).strftime(app._("time", "formats")) if user.location is None: await message.reply_text( diff --git a/plugins/commands/upcoming.py b/plugins/commands/upcoming.py index 262e53b..881958d 100644 --- a/plugins/commands/upcoming.py +++ b/plugins/commands/upcoming.py @@ -2,34 +2,30 @@ 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 +from modules import custom_filters +from modules.database_api import col_entries @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["upcoming"], prefixes=["/"]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["upcoming"], prefixes=["/"]) & ~custom_filters.context # 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( - app._("upcoming_empty_location", "messages", locale=user.locale) + app._("location_empty", "messages", locale=user.locale) ) return date_min = ( - datetime.now(pytz_timezone(user.location.timezone)).replace( - second=0, microsecond=0 - ) + datetime.now(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 - ) + datetime.now(user.location.timezone).replace(second=0, microsecond=0) + timedelta(days=30) ).replace(tzinfo=timezone.utc) @@ -37,7 +33,7 @@ async def command_upcoming(app: PyroClient, message: Message): await GarbageEntry.from_record(entry) async for entry in col_entries.find( { - "location": {"$in": user.location.id}, + "locations": user.location.id, "date": {"$gte": date_min, "$lte": date_max}, } ) @@ -45,7 +41,7 @@ async def command_upcoming(app: PyroClient, message: Message): entries_text = "\n\n".join( [ - f"**{entry.date.strftime(app._('date', 'formats', locale=user.locale))}**:\n{app._(str(entry.garbage_type.value), 'garbage_types')}" + f"**{entry.date.strftime(app._('date', 'formats', locale=user.locale))}**:\n{app._(str(entry.garbage_type.value), 'garbage_types', locale=user.locale)}" for entry in entries ] ) diff --git a/plugins/language.py b/plugins/language.py index 673b72b..54ac366 100644 --- a/plugins/language.py +++ b/plugins/language.py @@ -6,10 +6,11 @@ from pyrogram.types import CallbackQuery, Message from classes.callbacks import CallbackLanguage from classes.pyroclient import PyroClient +from modules import custom_filters @PyroClient.on_message( - ~filters.scheduled & filters.private & filters.command(["language"], prefixes=["/"]) # type: ignore + ~filters.scheduled & filters.private & filters.command(["language"], prefixes=["/"]) & ~custom_filters.context # type: ignore ) async def command_language(app: PyroClient, message: Message): user = await app.find_user(message.from_user) diff --git a/requirements.txt b/requirements.txt index 39040b9..9c10296 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ mongodb-migrations==1.3.0 pykeyboard==0.1.5 tgcrypto==1.2.5 ujson>=5.0.0 -uvloop==0.17.0 +uvloop==0.19.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