Merge pull request 'Database changes, new translations' (#32) from dev into main

Reviewed-on: #32
This commit is contained in:
Profitroll 2023-11-05 15:37:21 +02:00
commit 3925f66882
32 changed files with 662 additions and 121 deletions

View File

@ -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

64
classes/importer/csv.py Normal file
View File

@ -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

56
classes/importer/json.py Normal file
View File

@ -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

View File

@ -1,9 +1,12 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Union
from bson import ObjectId from bson import ObjectId
from pytz import timezone as pytz_timezone
from pytz.tzinfo import BaseTzInfo, DstTzInfo
from classes.point import Point from classes.point import Point
from modules.database import col_locations from modules.database_api import col_locations
@dataclass @dataclass
@ -22,7 +25,7 @@ class Location:
name: str name: str
location: Point location: Point
country: int country: int
timezone: str timezone: Union[BaseTzInfo, DstTzInfo]
@classmethod @classmethod
async def get(cls, id: int): async def get(cls, id: int):
@ -32,6 +35,7 @@ class Location:
raise ValueError(f"No location with ID {id} found.") raise ValueError(f"No location with ID {id} found.")
db_entry["location"] = Point(*db_entry["location"]) # type: ignore db_entry["location"] = Point(*db_entry["location"]) # type: ignore
db_entry["timezone"] = pytz_timezone(db_entry["timezone"]) # type: ignore
return cls(**db_entry) return cls(**db_entry)
@ -43,6 +47,7 @@ class Location:
raise ValueError(f"No location with name {name} found.") raise ValueError(f"No location with name {name} found.")
db_entry["location"] = Point(*db_entry["location"]) # type: ignore db_entry["location"] = Point(*db_entry["location"]) # type: ignore
db_entry["timezone"] = pytz_timezone(db_entry["timezone"]) # type: ignore
return cls(**db_entry) return cls(**db_entry)
@ -54,5 +59,6 @@ class Location:
raise ValueError(f"No location near {lat}, {lon} found.") raise ValueError(f"No location near {lat}, {lon} found.")
db_entry["location"] = Point(*db_entry["location"]) # type: ignore db_entry["location"] = Point(*db_entry["location"]) # type: ignore
db_entry["timezone"] = pytz_timezone(db_entry["timezone"]) # type: ignore
return cls(**db_entry) return cls(**db_entry)

View File

@ -7,7 +7,7 @@ from pyrogram.types import User
from classes.location import Location from classes.location import Location
from classes.pyrouser import PyroUser from classes.pyrouser import PyroUser
from modules.database import col_locations from modules.database_api import col_locations
from modules.reminder import remind from modules.reminder import remind
@ -18,6 +18,7 @@ class PyroClient(LibPyroClient):
self.scheduler.add_job( self.scheduler.add_job(
remind, CronTrigger.from_crontab("* * * * *"), args=(self,) remind, CronTrigger.from_crontab("* * * * *"), args=(self,)
) )
self.contexts = []
async def start(self, **kwargs): async def start(self, **kwargs):
await col_locations.create_index( await col_locations.create_index(

View File

@ -71,6 +71,15 @@ class PyroUser:
return cls(**db_entry) 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: async def update_locale(self, locale: Union[str, None]) -> None:
"""Change user's locale stored in the database. """Change user's locale stored in the database.

View File

@ -1,5 +1,6 @@
{ {
"locale": "en", "locale": "en",
"debug": false,
"bot": { "bot": {
"owner": 0, "owner": 0,
"api_id": 0, "api_id": 0,
@ -13,7 +14,14 @@
"password": null, "password": null,
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 27017, "port": 27017,
"name": "garbagebot" "name": "garbage_bot"
},
"database_api": {
"user": null,
"password": null,
"host": "127.0.0.1",
"port": 27017,
"name": "garbage_reminder"
}, },
"search": { "search": {
"radius": 0.1 "radius": 0.1

98
locale/de.json Normal file
View File

@ -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}"
}
}

View File

@ -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! 🤗", "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_finished": "You have successfully inserted {count} entries.",
"import_invalid_date": "Entries contain invalid date formats. Use **ISO 8601** date format.", "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_filetype": "Invalid input. Please, send me a JSON or CSV file with entries. {cancel_notice}",
"import_invalid": "This is not a valid garbage collection JSON.", "import_invalid": "This is not a valid garbage collection file.",
"import": "Alright. Send me a valid JSON.", "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.", "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_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_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.", "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_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_enabled": "🔔 Notifications have been enabled {offset} d. before garbage collection at {time}. Use /setup to select your location.",
"toggle": "Execute /toggle to enable notifications.", "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_empty": "No garbage collection entries found for the next 30 days at **{name}**",
"upcoming": "Upcoming garbage collection:\n\n{entries}" "upcoming": "Upcoming garbage collection:\n\n{entries}"
}, },

98
locale/ru.json Normal file
View File

@ -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": "Час у вигляді ГГ:ХХ"
}
}

View File

@ -21,27 +21,78 @@
}, },
"commands": { "commands": {
"help": "Показати меню допомоги", "help": "Показати меню допомоги",
"setup": "Обрати місто та район", "setup": "Обрати місце розташування",
"toggle": "Вимкнути/вимкнути повідомлення", "toggle": "Увімкнути/вимкнути сповіщення",
"set_time": "Встановити час повідомлень", "set_time": "Встановити час сповіщень",
"set_offset": "Встановити дні випередження", "set_offset": "Встановити зміщення сповіщень",
"upcoming": "Дати збирання на наступні 30 днів", "upcoming": "Дати збору на наступні 30 днів",
"language": "Змінити мову повідомлень бота", "language": "Змінити мову повідомлень бота",
"checkout": "Експортувати чи видалити дані", "checkout": "Експортувати або видалити дані",
"import": "Завантажити з JSON до бази даних", "import": "Завантажити з JSON до бази даних",
"shutdown": "Вимкнути бота", "shutdown": "Вимкнути бота",
"remove_commands": "Видалити всі команди" "remove_commands": "Видалити всі команди"
}, },
"messages": { "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Тепер офіційна частина закінчена, тож Ви можете зануритися в бота.", "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": { "buttons": {
"configure": "Давайте налаштуємо бота" "delete_confirm": "Я погоджуюсь і хочу продовжити",
"delete_no": "❌ Ні, я не хочу видаляти їх",
"delete_yes": "✅ Так, я хочу видалити їх",
"search_name": "🔎 Пошук за назвою місця",
"search_nearby": "📍 Пошук найближчих локацій",
"start_code_no": "❌ Ні, я не хочу її використовувати",
"start_code_yes": "✅ Так, я хочу її використовувати",
"start_configure": "⚙️ Налаштуймо бота"
}, },
"callbacks": { "callbacks": {
"locale_set": "Встановлено мову: {locale}" "locale_set": "Встановлено мову: {locale}"
}, },
"force_replies": {} "force_replies": {
} "import": "JSON із записами вивозу сміття",
"location_name": "Назва локації",
"set_offset": "Кількість днів",
"set_time": "Час у вигляді ГГ:ХХ"
}
}

View File

@ -12,7 +12,7 @@ from modules.migrator import migrate_database
from modules.scheduler import scheduler from modules.scheduler import scheduler
logging.basicConfig( 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", format="%(name)s.%(funcName)s | %(levelname)s | %(message)s",
datefmt="[%X]", datefmt="[%X]",
) )

View File

@ -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 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) owner = filters.create(_owner_func)
context = filters.create(_context_func)

View File

@ -1,4 +1,4 @@
"""Module that provides all database collections""" """Module that provides bot's database collections."""
from typing import Any, Mapping from typing import Any, Mapping
@ -24,5 +24,3 @@ db_client = AsyncClient(con_string)
db: AsyncDatabase = db_client.get_database(name=db_config["name"]) db: AsyncDatabase = db_client.get_database(name=db_config["name"])
col_users: AsyncCollection = db.get_collection("users") col_users: AsyncCollection = db.get_collection("users")
col_entries: AsyncCollection = db.get_collection("entries")
col_locations: AsyncCollection = db.get_collection("locations")

29
modules/database_api.py Normal file
View File

@ -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")

View File

@ -1,26 +1,38 @@
import logging 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 libbot.pyrogram.classes import PyroClient
from pytz import timezone as pytz_timezone
from classes.enums import GarbageType from classes.enums import GarbageType
from classes.location import Location from classes.location import Location
from classes.pyrouser import PyroUser 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__) logger = logging.getLogger(__name__)
async def remind(app: PyroClient) -> None: 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( users = await col_users.find(
{"time_hour": now.hour, "time_minute": now.minute} {"time_hour": utcnow.hour, "time_minute": utcnow.minute}
).to_list() ).to_list()
logger.debug(
"Found following reminders for %s (UTC NOW): %s",
utcnow,
json_util.dumps(users, indent=None),
)
for user_db in users: 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: if not user.enabled or user.location is None:
continue continue
@ -30,23 +42,27 @@ async def remind(app: PyroClient) -> None:
except ValueError: except ValueError:
continue continue
user_date = ( user_date = from_utc(
datetime.now(pytz_timezone(location.timezone)).replace( datetime.utcnow() + timedelta(days=user.offset),
second=0, microsecond=0 user.location.timezone.zone,
) ).replace(hour=0, minute=0, second=0, microsecond=0)
+ timedelta(days=user.offset)
).replace(tzinfo=timezone.utc)
entries = await col_entries.find( entries = await col_entries.find(
{ {
"location": {"$in": location.id}, "locations": location.id,
"date": user_date.replace(hour=0, minute=0), "date": user_date,
} }
).to_list() ).to_list()
logger.info("Entries of %s for %s: %s", user.id, user_date, entries) logger.info("Entries of %s for %s: %s", user.id, user_date, entries)
for entry in entries: for entry in entries:
logger.debug(
"Sending %s notification about %s",
user.id,
json_util.dumps(entry, indent=None),
)
try: try:
garbage_type = app._( garbage_type = app._(
str(GarbageType(entry["garbage_type"]).value), str(GarbageType(entry["garbage_type"]).value),

View File

@ -6,7 +6,7 @@ from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove
from classes.location import Location from classes.location import Location
from classes.pyroclient import PyroClient 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]: 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 location: Union[Location, None] = None
await message.reply_text( await message.reply_text(
app._("location_request_name", "messages", locale=user.locale), app._("location_name", "messages", locale=user.locale),
reply_markup=ForceReply( reply_markup=ForceReply(
placeholder=app._("location_name", "force_replies", locale=user.locale) placeholder=app._("location_name", "force_replies", locale=user.locale)
), ),
) )
while location is None: while location is None:
app.contexts.append(message.from_user.id)
answer = await listen_message(app, message.chat.id, 300) answer = await listen_message(app, message.chat.id, 300)
app.contexts.remove(message.from_user.id)
if answer is None or answer.text == "/cancel": if answer is None or answer.text == "/cancel":
await message.reply_text( 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() 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( await message.reply_text(
app._("location_name_empty", "messages", locale=user.locale).format( app._("location_name_empty", "messages", locale=user.locale).format(
cancel_notice=app._("cancel", "messages", locale=user.locale) cancel_notice=app._("cancel", "messages", locale=user.locale)
@ -61,6 +63,8 @@ async def search_name(app: PyroClient, message: Message) -> Union[Location, None
) )
continue continue
locations.reverse()
keyboard = ReplyKeyboard(resize_keyboard=True, row_width=2) keyboard = ReplyKeyboard(resize_keyboard=True, row_width=2)
keyboard.add(*[ReplyButton(db_record["name"]) for db_record in locations]) 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: while True:
app.contexts.append(message.from_user.id)
answer = await listen_message(app, message.chat.id, 300) answer = await listen_message(app, message.chat.id, 300)
app.contexts.remove(message.from_user.id)
if answer is None or answer.text == "/cancel": if answer is None or answer.text == "/cancel":
await message.reply_text( await message.reply_text(

View File

@ -6,7 +6,7 @@ from pyrogram.types import Message, ReplyKeyboardRemove
from classes.location import Location from classes.location import Location
from classes.pyroclient import PyroClient 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 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) return await search_name(app, message)
locations.reverse()
keyboard = ReplyKeyboard(resize_keyboard=True, row_width=2) keyboard = ReplyKeyboard(resize_keyboard=True, row_width=2)
keyboard.add(*[ReplyButton(db_record["name"]) for db_record in locations]) 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: while True:
app.contexts.append(message.from_user.id)
answer = await listen_message(app, message.chat.id, 300) answer = await listen_message(app, message.chat.id, 300)
app.contexts.remove(message.from_user.id)
location: Union[Location, None] = None location: Union[Location, None] = None
if answer is None or answer.text == "/cancel": if answer is None or answer.text == "/cancel":

View File

@ -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)
)

View File

@ -8,12 +8,13 @@ from pyrogram.types import Message, ReplyKeyboardRemove
from ujson import dumps from ujson import dumps
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@PyroClient.on_message( @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): async def command_checkout(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) user = await app.find_user(message.from_user)
@ -42,7 +43,9 @@ async def command_checkout(app: PyroClient, message: Message):
) )
while True: while True:
app.contexts.append(message.from_user.id)
answer_delete = await listen_message(app, message.chat.id, 300) 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": if answer_delete is None or answer_delete.text == "/cancel":
await message.reply_text( await message.reply_text(
@ -84,7 +87,9 @@ async def command_checkout(app: PyroClient, message: Message):
) )
while True: while True:
app.contexts.append(message.from_user.id)
answer_confirm = await listen_message(app, message.chat.id, 300) 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": if answer_confirm is None or answer_confirm.text == "/cancel":
await message.reply_text( await message.reply_text(

View File

@ -2,10 +2,11 @@ from pyrogram import filters
from pyrogram.types import Message from pyrogram.types import Message
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
@PyroClient.on_message( @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): async def command_help(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) user = await app.find_user(message.from_user)

View File

@ -1,14 +1,11 @@
from datetime import datetime
from typing import List, Mapping, Union
from convopyro import listen_message from convopyro import listen_message
from pyrogram import filters from pyrogram import filters
from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove 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 classes.pyroclient import PyroClient
from modules import custom_filters from modules import custom_filters
from modules.database import col_entries
@PyroClient.on_message( @PyroClient.on_message(
@ -32,7 +29,10 @@ async def command_import(app: PyroClient, message: Message):
) )
return 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( await answer.reply_text(
app._("import_invalid_filetype", "messages", locale=user.locale).format( app._("import_invalid_filetype", "messages", locale=user.locale).format(
cancel_notice=app._("cancel", "messages", locale=user.locale) 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) 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: # I'd like to replace it with switch-case, but 3.9 compatibility
if not isinstance(entries, list): # is still more important to be there. Although refactor may be
await answer.reply_text( # done in the near future as Python 3.9 EOL gets nearer.
app._("import_invalid", "messages", locale=user.locale), if answer.document.mime_type == "application/json":
reply_markup=ReplyKeyboardRemove(), importer = ImporterJSON()
) elif answer.document.mime_type == "text/csv":
return 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"): try:
if ( import_result = await importer.import_data(data)
key not in entry except ValueError:
or (key == "garbage_type" and not isinstance(entry[key], int)) await answer.reply_text(
or (key == "locations" and not isinstance(entry[key], list)) app._("import_invalid", "messages", locale=user.locale),
): reply_markup=ReplyKeyboardRemove(),
await answer.reply_text( )
app._("import_invalid", "messages", locale=user.locale), return
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)
await answer.reply_text( await answer.reply_text(
app._("import_finished", "messages", locale=user.locale).format( app._("import_finished", "messages", locale=user.locale).format(
count=len(entries_clean) count=len(import_result)
), ),
reply_markup=ReplyKeyboardRemove(), reply_markup=ReplyKeyboardRemove(),
) )

View File

@ -2,10 +2,11 @@ from pyrogram import filters
from pyrogram.types import Message from pyrogram.types import Message
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
@PyroClient.on_message( @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): async def command_remove_commands(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) user = await app.find_user(message.from_user)

View File

@ -6,16 +6,24 @@ from pyrogram import filters
from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
from modules.utils import from_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@PyroClient.on_message( @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): async def command_set_offset(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) 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( await message.reply_text(
app._("set_offset", "messages", locale=user.locale), app._("set_offset", "messages", locale=user.locale),
reply_markup=ForceReply( reply_markup=ForceReply(
@ -24,7 +32,9 @@ async def command_set_offset(app: PyroClient, message: Message):
) )
while True: while True:
app.contexts.append(message.from_user.id)
answer = await listen_message(app, message.chat.id, 300) answer = await listen_message(app, message.chat.id, 300)
app.contexts.remove(message.from_user.id)
if answer is None or answer.text == "/cancel": if answer is None or answer.text == "/cancel":
await message.reply_text( 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) logger.info("User %s has set offset to %s", user.id, offset)
garbage_time = datetime( garbage_time = from_utc(
1970, 1, 1, hour=user.time_hour, minute=user.time_minute 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")) ).strftime(app._("time", "formats"))
await answer.reply_text( await answer.reply_text(

View File

@ -6,16 +6,24 @@ from pyrogram import filters
from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
from modules.utils import to_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@PyroClient.on_message( @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): async def command_set_time(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) 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( await message.reply_text(
app._("set_time", "messages", locale=user.locale), app._("set_time", "messages", locale=user.locale),
reply_markup=ForceReply( reply_markup=ForceReply(
@ -24,7 +32,9 @@ async def command_set_time(app: PyroClient, message: Message):
) )
while True: while True:
app.contexts.append(message.from_user.id)
answer = await listen_message(app, message.chat.id, 300) answer = await listen_message(app, message.chat.id, 300)
app.contexts.remove(message.from_user.id)
if answer is None or answer.text == "/cancel": if answer is None or answer.text == "/cancel":
await message.reply_text( await message.reply_text(
@ -45,7 +55,13 @@ async def command_set_time(app: PyroClient, message: Message):
break 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) 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"), 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( await answer.reply_text(
app._("set_time_finished", "messages", locale=user.locale).format( app._("set_time_finished", "messages", locale=user.locale).format(

View File

@ -8,14 +8,16 @@ from pyrogram import filters
from pyrogram.types import Message, ReplyKeyboardRemove from pyrogram.types import Message, ReplyKeyboardRemove
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
from modules.search_name import search_name from modules.search_name import search_name
from modules.search_nearby import search_nearby from modules.search_nearby import search_nearby
from modules.utils import from_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@PyroClient.on_message( @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): async def command_setup(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) user = await app.find_user(message.from_user)
@ -34,7 +36,9 @@ async def command_setup(app: PyroClient, message: Message):
) )
while True: while True:
app.contexts.append(message.from_user.id)
answer_type = await listen_message(app, message.chat.id, 300) 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": if answer_type is None or answer_type.text == "/cancel":
await message.reply_text( await message.reply_text(
@ -70,9 +74,10 @@ async def command_setup(app: PyroClient, message: Message):
await user.update_location(location.id) await user.update_location(location.id)
user_time = datetime(1970, 1, 1, user.time_hour, user.time_minute).strftime( user_time = from_utc(
app._("time", "formats", locale=user.locale) 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( await message.reply_text(
app._("setup_finished", "messages", locale=user.locale).format( app._("setup_finished", "messages", locale=user.locale).format(

View File

@ -4,12 +4,14 @@ from pyrogram import filters
from pyrogram.types import Message from pyrogram.types import Message
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
@PyroClient.on_message( @PyroClient.on_message(
~filters.scheduled ~filters.scheduled
& filters.private & 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): async def command_shutdown(app: PyroClient, msg: Message):
if msg.from_user.id == app.owner: if msg.from_user.id == app.owner:

View File

@ -10,10 +10,12 @@ from pyrogram.types import (
) )
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
from modules.utils import from_utc
@PyroClient.on_message( @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): async def command_start(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) user = await app.find_user(message.from_user)
@ -52,7 +54,9 @@ async def command_start(app: PyroClient, message: Message):
) )
while True: while True:
app.contexts.append(message.from_user.id)
answer = await listen_message(app, message.chat.id, 300) answer = await listen_message(app, message.chat.id, 300)
app.contexts.remove(message.from_user.id)
if answer is None or answer.text == "/cancel": if answer is None or answer.text == "/cancel":
await message.reply_text( await message.reply_text(
@ -82,9 +86,10 @@ async def command_start(app: PyroClient, message: Message):
await user.update_location(location.id) await user.update_location(location.id)
user_time = datetime(1970, 1, 1, user.time_hour, user.time_minute).strftime( user_time = from_utc(
app._("time", "formats", locale=user.locale) 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( await answer.reply_text(
app._("start_selection_yes", "messages", locale=user.locale).format( app._("start_selection_yes", "messages", locale=user.locale).format(
name=location.name, offset=user.offset, time=user_time name=location.name, offset=user.offset, time=user_time

View File

@ -4,10 +4,12 @@ from pyrogram import filters
from pyrogram.types import Message from pyrogram.types import Message
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
from modules.utils import from_utc
@PyroClient.on_message( @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): async def command_toggle(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) user = await app.find_user(message.from_user)
@ -20,7 +22,10 @@ async def command_toggle(app: PyroClient, message: Message):
) )
return 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: if user.location is None:
await message.reply_text( await message.reply_text(

View File

@ -2,34 +2,30 @@ from datetime import datetime, timedelta, timezone
from pyrogram import filters from pyrogram import filters
from pyrogram.types import Message from pyrogram.types import Message
from pytz import timezone as pytz_timezone
from classes.garbage_entry import GarbageEntry from classes.garbage_entry import GarbageEntry
from classes.pyroclient import PyroClient 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( @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): async def command_upcoming(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) user = await app.find_user(message.from_user)
if user.location is None: if user.location is None:
await message.reply_text( await message.reply_text(
app._("upcoming_empty_location", "messages", locale=user.locale) app._("location_empty", "messages", locale=user.locale)
) )
return return
date_min = ( date_min = (
datetime.now(pytz_timezone(user.location.timezone)).replace( datetime.now(user.location.timezone).replace(second=0, microsecond=0)
second=0, microsecond=0
)
).replace(tzinfo=timezone.utc) ).replace(tzinfo=timezone.utc)
date_max = ( date_max = (
datetime.now(pytz_timezone(user.location.timezone)).replace( datetime.now(user.location.timezone).replace(second=0, microsecond=0)
second=0, microsecond=0
)
+ timedelta(days=30) + timedelta(days=30)
).replace(tzinfo=timezone.utc) ).replace(tzinfo=timezone.utc)
@ -37,7 +33,7 @@ async def command_upcoming(app: PyroClient, message: Message):
await GarbageEntry.from_record(entry) await GarbageEntry.from_record(entry)
async for entry in col_entries.find( async for entry in col_entries.find(
{ {
"location": {"$in": user.location.id}, "locations": user.location.id,
"date": {"$gte": date_min, "$lte": date_max}, "date": {"$gte": date_min, "$lte": date_max},
} }
) )
@ -45,7 +41,7 @@ async def command_upcoming(app: PyroClient, message: Message):
entries_text = "\n\n".join( 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 for entry in entries
] ]
) )

View File

@ -6,10 +6,11 @@ from pyrogram.types import CallbackQuery, Message
from classes.callbacks import CallbackLanguage from classes.callbacks import CallbackLanguage
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters
@PyroClient.on_message( @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): async def command_language(app: PyroClient, message: Message):
user = await app.find_user(message.from_user) user = await app.find_user(message.from_user)

View File

@ -5,7 +5,7 @@ mongodb-migrations==1.3.0
pykeyboard==0.1.5 pykeyboard==0.1.5
tgcrypto==1.2.5 tgcrypto==1.2.5
ujson>=5.0.0 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 --extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple
async_pymongo==0.1.4 async_pymongo==0.1.4
libbot[speed,pyrogram]==2.0.1 libbot[speed,pyrogram]==2.0.1