Initial commit

This commit is contained in:
Profitroll 2023-08-27 22:43:16 +02:00
commit 502ed0406e
Signed by: profitroll
GPG Key ID: FA35CAB49DACD3B2
40 changed files with 1854 additions and 0 deletions

164
.gitignore vendored Normal file
View 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
View 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
View 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)

View File

@ -0,0 +1 @@
from .garbage_type import GarbageType

View 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
View 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"],
)

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()

0
modules/utils.py Normal file
View File

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

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

View 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."
)

View 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())

View 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(),
)

View 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
View 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(),
# )

View 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
View 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,
),
)

View 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}**."
)

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