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