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