10 Commits

Author SHA1 Message Date
3fded13f18 WIP: sponsorship renewal 2023-04-08 01:18:05 +02:00
aa8e77811d Slightly improved context 2023-04-08 01:17:49 +02:00
0bffe9cf97 Just a space removed 2023-04-08 01:17:27 +02:00
177d456e79 Improved entities handling 2023-04-07 22:01:00 +02:00
7218d580bb Bump Pyrogram to 2.0.103 2023-04-07 21:54:09 +02:00
e8541b5160 WIP: analytics 2023-04-06 16:08:33 +02:00
2e7d4aa263 WIP: chat language recognition 2023-04-05 22:31:07 +02:00
79af1bce66 This commit closes #33 2023-04-04 10:37:46 +02:00
f66f8421c3 This commit closes #36 2023-04-03 16:03:33 +02:00
bd040af0cc Sends messages on warning being revoked (#36) 2023-04-03 15:47:17 +02:00
11 changed files with 522 additions and 27 deletions

View File

@@ -39,9 +39,10 @@ from modules.callbacks.sub import *
from modules.callbacks.sus import * from modules.callbacks.sus import *
from modules.callbacks.warnings import * from modules.callbacks.warnings import *
from modules.handlers.analytics_group import *
from modules.handlers.confirmation import * from modules.handlers.confirmation import *
from modules.handlers.contact import * from modules.handlers.contact import *
from modules.handlers.group_join import * from modules.handlers.group_member_update import *
from modules.handlers.voice import * from modules.handlers.voice import *
from modules.handlers.welcome import * from modules.handlers.welcome import *
from modules.handlers.everything import * from modules.handlers.everything import *

View File

@@ -76,12 +76,14 @@
"application_invalid_syntax": "Неправильний синтаксис!\nТреба: `/application ID/NAME/USERNAME`", "application_invalid_syntax": "Неправильний синтаксис!\nТреба: `/application ID/NAME/USERNAME`",
"warned": "Попереджено **{0}** (`{1}`) про порушення правил", "warned": "Попереджено **{0}** (`{1}`) про порушення правил",
"warned_reason": "Попереджено **{0}** (`{1}`)\n\n**Причина:**\n{2}", "warned_reason": "Попереджено **{0}** (`{1}`)\n\n**Причина:**\n{2}",
"warnings_1": "Користувач **{0}** (`{1}`) має **{2}** попередження\n\nОбрати та зняти попередження:\n`/warnings {3} revoke`", "warnings_1": "Користувач **{0}** (`{1}`) має **{2}** попередження\n\n{3}\n\nОбрати та зняти попередження:\n`/warnings {4} revoke`",
"warnings_2": "Користувач **{0}** (`{1}`) має **{2}** попереджень\n\nОбрати та зняти попередження:\n`/warnings {3} revoke`", "warnings_2": "Користувач **{0}** (`{1}`) має **{2}** попереджень\n\n{3}\n\nОбрати та зняти попередження:\n`/warnings {4} revoke`",
"warnings_all": "**Список попереджень**\n\n{0}\n\nДля перегляду попереджень окремо взятого користувача слід використовувати `/warnings ID/NAME/USERNAME`", "warnings_all": "**Список попереджень**\n\n{0}\n\nДля перегляду попереджень окремо взятого користувача слід використовувати `/warnings ID/NAME/USERNAME`",
"warnings_entry": "• {0} (`{1}`)\n Попереджень: {2}", "warnings_entry": "• {0} (`{1}`)\n Попереджень: {2}",
"warnings_empty": "Щось тут порожньо...\nЗ іншого боку, це добре!", "warnings_empty": "Щось тут порожньо...\nЗ іншого боку, це добре!",
"warnings_revoke": "**Попередження {0}:**\n\n{1}\n\nБудь ласка, користуйтесь клавіатурою щоб зняти попередження з відповідним номером.", "warnings_revoke": "**Попередження {0}:**\n\n{1}\n\nБудь ласка, користуйтесь клавіатурою щоб зняти попередження з відповідним номером.",
"warning_revoked": "Попередження від {0} користувачеві `{1}` було скасовано адміном `{2}`",
"warning_revoked_auto": "Попередження від {0} користувачеві `{1}` було автоматично скасовано.",
"no_warnings": "Користувач **{0}** (`{1}`) не має попереджень", "no_warnings": "Користувач **{0}** (`{1}`) не має попереджень",
"no_user_warnings": "Не знайдено користувачів за запитом **{0}**", "no_user_warnings": "Не знайдено користувачів за запитом **{0}**",
"syntax_warnings": "Неправильний синтаксис!\nТреба: `/warnings ID/NAME/USERNAME`", "syntax_warnings": "Неправильний синтаксис!\nТреба: `/warnings ID/NAME/USERNAME`",
@@ -130,6 +132,7 @@
"not_member": "❌ **Дія неможлива**\nУ тебе немає заповненої та схваленої анкети. Заповни таку за допомогою /reapply та спробуй ще раз після її підтвердження.", "not_member": "❌ **Дія неможлива**\nУ тебе немає заповненої та схваленої анкети. Заповни таку за допомогою /reapply та спробуй ще раз після її підтвердження.",
"issue": "**Допоможіть боту**\nЗнайшли баг або помилку? Маєте файну ідею для нової функції? Повідомте нас, створивши нову задачу на гіті.\n\nЗа можливості, опишіть свій запит максимально детально. Якщо є змога, також додайте скріншоти або додаткову відому інформацію.", "issue": "**Допоможіть боту**\nЗнайшли баг або помилку? Маєте файну ідею для нової функції? Повідомте нас, створивши нову задачу на гіті.\n\nЗа можливості, опишіть свій запит максимально детально. Якщо є змога, також додайте скріншоти або додаткову відому інформацію.",
"you_are_banned": "⚠️ **Вас було заблоковано**\nТепер не можна відправити анкету або користуватись командами бота.", "you_are_banned": "⚠️ **Вас було заблоковано**\nТепер не можна відправити анкету або користуватись командами бота.",
"user_left": "Користувач **{0}** залишив чат",
"yes": "Так", "yes": "Так",
"no": "Ні", "no": "Ні",
"voice_message": [ "voice_message": [

View File

@@ -3,7 +3,8 @@ from app import app
from pyrogram import filters from pyrogram import filters
from pyrogram.types import CallbackQuery from pyrogram.types import CallbackQuery
from pyrogram.client import Client from pyrogram.client import Client
from modules.utils import locale from pykeyboard import InlineKeyboard, InlineButton
from modules.utils import configGet, locale
from modules.database import col_warnings from modules.database import col_warnings
from bson import ObjectId from bson import ObjectId
@@ -25,3 +26,34 @@ async def callback_query_warning_revoke(app: Client, clb: CallbackQuery):
text=locale("warning_revoked", "callback", locale=clb.from_user).format(), text=locale("warning_revoked", "callback", locale=clb.from_user).format(),
show_alert=True, show_alert=True,
) )
await app.send_message(
configGet("admin", "groups"),
locale("warning_revoked_auto", "message").format(
warning["user"], warning["date"].strftime("%d.%m.%Y")
),
)
target_id = warning["user"]
if col_warnings.count_documents({"user": target_id, "active": True}) == 0:
await clb.edit_message_text(
locale("no_warnings", "message", locale=clb.from_user).format(
target_id, target_id
)
)
return
keyboard = InlineKeyboard()
buttons = []
warnings = []
for index, warning in enumerate(
list(col_warnings.find({"user": target_id, "active": True}))
):
warnings.append(
f'{index+1}. {warning["date"].strftime("%d.%m.%Y, %H:%M")}\n Адмін: {warning["admin"]}\n Причина: {warning["reason"]}'
)
buttons.append(InlineButton(str(index + 1), f'w_rev_{str(warning["_id"])}'))
keyboard.add(*buttons)
await clb.edit_message_text(
locale("warnings_revoke", "message", locale=clb.from_user).format(
target_id, "\n".join(warnings)
),
)
await clb.edit_message_reply_markup(reply_markup=keyboard)

View File

@@ -63,8 +63,12 @@ async def cmd_message(app: Client, msg: Message):
await msg.reply_text( await msg.reply_text(
locale("message_enter", "message", locale=msg.from_user) locale("message_enter", "message", locale=msg.from_user)
) )
message = await listen_message(app, msg.chat.id, timeout=None) message = await listen_message(app, msg.chat.id)
if message.text is not None and message.text == "/cancel": if (
message is None
or message.text is not None
and message.text == "/cancel"
):
return return
sent = await app.forward_messages( sent = await app.forward_messages(
configGet("admin", "groups"), msg.chat.id, message.id configGet("admin", "groups"), msg.chat.id, message.id

View File

@@ -1,11 +1,31 @@
from datetime import datetime, timedelta
from typing import Union
from app import app from app import app
from pyrogram import filters from pyrogram import filters
from pyrogram.types import InlineKeyboardMarkup, InlineKeyboardButton, Message from pyrogram.types import (
InlineKeyboardMarkup,
InlineKeyboardButton,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
ForceReply,
Message,
)
from pyrogram.client import Client from pyrogram.client import Client
from classes.holo_user import HoloUser from classes.holo_user import HoloUser
from modules import custom_filters from modules import custom_filters
from modules.utils import locale, should_quote from modules.utils import locale, should_quote
from modules.database import col_applications from modules.database import col_sponsorships
from convopyro import listen_message
def is_none_or_cancel(message: Union[Message, None]) -> bool:
if (
message is None
or message.text is not None
and message.text.lower() == "/cancel"
):
return True
return False
@app.on_message( @app.on_message(
@@ -26,6 +46,15 @@ async def cmd_sponsorship(app: Client, msg: Message):
if holo_user.spoiler_state() is True: if holo_user.spoiler_state() is True:
await msg.reply_text(locale("spoiler_in_progress", "message", locale=holo_user)) await msg.reply_text(locale("spoiler_in_progress", "message", locale=holo_user))
return return
existent = col_sponsorships.find_one(
{
"user": msg.from_user.id,
"sponsorship.expires": {"$gt": datetime.now() - timedelta(days=1)},
}
)
if existent is None:
await msg.reply_text( await msg.reply_text(
locale("sponsorship_apply", "message", locale=msg.from_user), locale("sponsorship_apply", "message", locale=msg.from_user),
reply_markup=InlineKeyboardMarkup( reply_markup=InlineKeyboardMarkup(
@@ -42,5 +71,143 @@ async def cmd_sponsorship(app: Client, msg: Message):
), ),
quote=should_quote(msg), quote=should_quote(msg),
) )
return
await msg.reply_text(
f'You have an active membership for **{existent["sponsorship"]["streamer"]}**. Wanna resubmit it once more?',
reply_markup=ReplyKeyboardMarkup(
[["Yep, use old data"], ["Nope, refill it once more"]],
resize_keyboard=True,
one_time_keyboard=True,
),
)
answer_decision = await listen_message(app, msg.chat.id)
if is_none_or_cancel(answer_decision):
return
input_streamer = existent["sponsorship"]["streamer"]
if answer_decision.text.lower() == "yep, use old data":
await answer_decision.reply_text(
"Okay, reusing the old data.\n\nUntil when is your sub?\n\nEnter the date as DD.MM.YYYY",
reply_markup=ForceReply(placeholder="Expiry date as DD.MM.YYYY"),
)
while True:
answer_date = await listen_message(app, msg.chat.id)
if is_none_or_cancel(answer_date):
return
try:
input_dt = datetime.strptime(answer_date.text, "%d.%m.%Y")
break
except ValueError:
await answer_date.reply_text(
"Invalid date! Provide as DD.MM.YYYY",
reply_markup=ForceReply(placeholder="Expiry date as DD.MM.YYYY"),
)
continue
while True:
await answer_date.reply_text(
"Alright. Now provide your proof **as a single screenshot**"
)
answer_proof = await listen_message(app, msg.chat.id)
if is_none_or_cancel(answer_proof):
return
if answer_proof.photo is None:
await answer_proof.reply_text(
"Please, provide proof **as a single screenshot**"
)
continue
input_proof = answer_proof.photo.file_id
break
await msg.reply_text(
f'Almost done. Do you want to keep the label **{existent["sponsorship"]["label"]}** or you want to change it?',
reply_markup=ReplyKeyboardMarkup(
[["Keep the old one"], ["Set a new one instead"]],
resize_keyboard=True,
one_time_keyboard=True,
),
)
while True:
answer_label_decision = await listen_message(app, msg.chat.id)
if is_none_or_cancel(answer_label_decision):
return
if answer_label_decision.text is None:
await answer_label_decision.reply_text(
"Please, choose a valid option.",
reply_markup=ReplyKeyboardMarkup(
[["Keep the old one"], ["Set a new one instead"]],
resize_keyboard=True,
one_time_keyboard=True,
),
)
continue
if answer_label_decision.text.lower() == "keep the old one":
input_label = existent["sponsorship"]["label"]
elif answer_label_decision.text.lower() == "set a new one instead":
await answer_label_decision.reply_text(
"Okay. Please provide a new label up to 16 characters long",
reply_markup=ForceReply(placeholder="New label"),
)
while True:
answer_label = await listen_message(app, msg.chat.id)
if is_none_or_cancel(answer_label_decision):
return
if answer_label.text is None:
await answer_label.reply_text(
"Please provide valid label",
reply_markup=ForceReply(placeholder="New label"),
)
continue
elif len(answer_label.text) > 16:
await answer_label.reply_text(
"Please provide a label not longer than 16 characters long",
reply_markup=ForceReply(placeholder="New label"),
)
continue
input_label = answer_label.text
break
await msg.reply_text(
f"So we did it for streamer **{input_streamer}**, til {input_dt.strftime('%d.%m.%Y')}, proofed by `{input_proof}` and labeled as **{input_label}**.",
reply_markup=ReplyKeyboardRemove(),
)
return
elif answer_decision.text.lower() == "nope, refill it once more":
await msg.reply_text(
locale("sponsorship_apply", "message", locale=msg.from_user),
reply_markup=InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
text=str(
locale("sponsor_apply", "button", locale=msg.from_user)
),
callback_data=f"sponsor_apply_{msg.from_user.id}",
)
]
]
),
quote=should_quote(msg),
)
return
else:
await answer_decision.reply_text(
"Invalid option!", reply_markup=ReplyKeyboardRemove()
)
return
# else: # else:
# await msg.reply_text(locale("sponsorship_application_empty", "message")) # await msg.reply_text(locale("sponsorship_application_empty", "message"))

View File

@@ -123,17 +123,24 @@ async def cmd_warnings(app: Client, msg: Message):
quote=should_quote(msg), quote=should_quote(msg),
) )
else: else:
warnings = []
for index, warning in enumerate(
list(col_warnings.find({"user": target_id, "active": True}))
):
warnings.append(
f'{index+1}. {warning["date"].strftime("%d.%m.%Y, %H:%M")}\n Адмін: {warning["admin"]}\n Причина: {warning["reason"]}'
)
if warns <= 5: if warns <= 5:
await msg.reply_text( await msg.reply_text(
locale("warnings_1", "message", locale=msg.from_user).format( locale("warnings_1", "message", locale=msg.from_user).format(
target_name, target_id, warns, target_id target_name, target_id, warns, "\n".join(warnings), target_id
), ),
quote=should_quote(msg), quote=should_quote(msg),
) )
else: else:
await msg.reply_text( await msg.reply_text(
locale("warnings_2", "message", locale=msg.from_user).format( locale("warnings_2", "message", locale=msg.from_user).format(
target_name, target_id, warns, target_id target_name, target_id, warns, "\n".join(warnings), target_id
), ),
quote=should_quote(msg), quote=should_quote(msg),
) )

View File

@@ -37,6 +37,8 @@ for collection in [
"warnings", "warnings",
"applications", "applications",
"sponsorships", "sponsorships",
"analytics_group",
"analytics_users"
]: ]:
if not collection in collections: if not collection in collections:
db.create_collection(collection) db.create_collection(collection)
@@ -51,5 +53,7 @@ col_messages = db.get_collection("messages")
col_warnings = db.get_collection("warnings") col_warnings = db.get_collection("warnings")
col_applications = db.get_collection("applications") col_applications = db.get_collection("applications")
col_sponsorships = db.get_collection("sponsorships") col_sponsorships = db.get_collection("sponsorships")
col_analytics_group = db.get_collection("analytics_group")
col_analytics_users = db.get_collection("analytics_users")
col_applications.create_index([("application.3.location", GEOSPHERE)]) col_applications.create_index([("application.3.location", GEOSPHERE)])

View File

@@ -0,0 +1,255 @@
from datetime import datetime
from polyglot.detect import Detector
from pyrogram import filters
from pyrogram.client import Client
from pyrogram.enums import MessageEntityType, PollType
from pyrogram.types import Message
from app import app
from modules import custom_filters
from modules.database import col_analytics_group
from modules.logging import logWrite
from modules.utils import configGet
@app.on_message(
custom_filters.enabled_general
& ~filters.scheduled
& filters.chat(configGet("users", "groups"))
)
async def msg_destination_group(app: Client, msg: Message):
analytics_entry = {
"id": msg.id,
"user": msg.from_user.id,
"date": datetime.now(),
"reply": {
"id": msg.reply_to_message_id,
"top_id": msg.reply_to_top_message_id,
"user": None
if msg.reply_to_message is None
else msg.reply_to_message.from_user.id,
},
"forward": {
"id": msg.forward_from_message_id,
"chat": None if msg.forward_from_chat is None else msg.forward_from_chat.id,
"user": None if msg.forward_from is None else msg.forward_from.id,
"date": msg.forward_date,
},
"media_spoilered": msg.has_media_spoiler,
"entities": {"links": [], "mentions": []},
"text": None,
"language": None,
"language_confidence": None,
"animation": None,
"audio": None,
"contact": None,
"document": None,
"location": None,
"photo": None,
"poll": None,
"sticker": None,
"venue": None,
"video": None,
"videonote": None,
"voice": None,
}
if msg.text is not None or msg.caption is not None:
text = msg.text if msg.text is not None else msg.caption
analytics_entry["text"] = text
if msg.entities is not None or msg.caption_entities is not None:
entities = (
msg.entities if msg.entities is not None else msg.caption_entities
)
for entity in entities:
if entity.type == MessageEntityType.TEXT_LINK:
analytics_entry["entities"]["links"].append(entity.url)
elif entity.type == MessageEntityType.URL:
analytics_entry["entities"]["links"].append(
text[entity.offset : entity.offset + entity.length]
)
elif entity.type == MessageEntityType.TEXT_MENTION:
analytics_entry["entities"]["mentions"].append(entity.user.id)
elif entity.type == MessageEntityType.MENTION:
analytics_entry["entities"]["mentions"].append(
text[entity.offset : entity.offset + entity.length]
)
lang = Detector(text, quiet=True).language
analytics_entry["language"] = lang.code
analytics_entry["language_confidence"] = lang.confidence
if lang.code == "ru":
logWrite(
f"Message '{text}' from {msg.from_user.first_name} ({msg.from_user.id}) is fucking russian [confidence {lang.confidence}]"
)
if msg.animation is not None:
analytics_entry["animation"] = {
"id": msg.animation.file_id,
"duration": msg.animation.duration,
"height": msg.animation.height,
"width": msg.animation.width,
"file_name": msg.animation.file_name,
"mime_type": msg.animation.mime_type,
}
if msg.audio is not None:
analytics_entry["audio"] = {
"id": msg.audio.file_id,
"title": msg.audio.title,
"performer": msg.audio.performer,
"duration": msg.audio.duration,
"file_name": msg.audio.file_name,
"file_size": msg.audio.file_size,
"mime_type": msg.audio.mime_type,
}
if msg.contact is not None:
analytics_entry["contact"] = {
"id": msg.contact.user_id,
"first_name": msg.contact.first_name,
"last_name": msg.contact.last_name,
"phone_number": msg.contact.phone_number,
"vcard": msg.contact.vcard,
}
if msg.document is not None:
analytics_entry["document"] = {
"id": msg.document.file_id,
"file_name": msg.document.file_name,
"file_size": msg.document.file_size,
"mime_type": msg.document.mime_type,
}
if msg.location is not None:
analytics_entry["location"] = {
"longitude": msg.location.longitude,
"latitude": msg.location.latitude,
}
if msg.photo is not None:
thumbnails = []
for thumbail in msg.photo.thumbs:
thumbnails.append(
{
"id": thumbail.file_id,
"height": thumbail.height,
"width": thumbail.width,
"file_size": thumbail.file_size,
}
)
analytics_entry["photo"] = {
"id": msg.photo.file_id,
"height": msg.photo.height,
"width": msg.photo.width,
"file_size": msg.photo.file_size,
"thumbnails": thumbnails,
}
if msg.poll is not None:
options = []
for option in msg.poll.options:
options.append(option.text)
analytics_entry["poll"] = {
"id": msg.poll.id,
"question": msg.poll.question,
"open_period": msg.poll.open_period,
"close_date": msg.poll.close_date,
"options": options,
"correct_option": msg.poll.correct_option_id,
"explanation": msg.poll.explanation,
"anonymous": msg.poll.is_anonymous,
"multiple_answers": msg.poll.allows_multiple_answers,
"quiz": True if msg.poll.type == PollType.QUIZ else False,
}
if msg.sticker is not None:
thumbnails = []
for thumbail in msg.sticker.thumbs:
thumbnails.append(
{
"id": thumbail.file_id,
"height": thumbail.height,
"width": thumbail.width,
"file_size": thumbail.file_size,
}
)
analytics_entry["sticker"] = {
"id": msg.sticker.file_id,
"emoji": msg.sticker.emoji,
"set_name": msg.sticker.set_name,
"animated": msg.sticker.is_animated,
"video": msg.sticker.is_video,
"height": msg.sticker.height,
"width": msg.sticker.width,
"file_name": msg.sticker.file_name,
"file_size": msg.sticker.file_size,
"mime_type": msg.sticker.mime_type,
"thumbnails": thumbnails,
}
if msg.venue is not None:
analytics_entry["venue"] = {
"title": msg.venue.title,
"address": msg.venue.address,
"longitude": msg.venue.location.longitude,
"latitude": msg.venue.location.latitude,
"foursquare_id": msg.venue.foursquare_id,
"foursquare_type": msg.venue.foursquare_type,
}
if msg.video is not None:
thumbnails = []
for thumbail in msg.video.thumbs:
thumbnails.append(
{
"id": thumbail.file_id,
"height": thumbail.height,
"width": thumbail.width,
"file_size": thumbail.file_size,
}
)
analytics_entry["video"] = {
"id": msg.video.file_id,
"duration": msg.video.duration,
"height": msg.video.height,
"width": msg.video.width,
"file_name": msg.video.file_name,
"file_size": msg.video.file_size,
"mime_type": msg.video.mime_type,
"thumbnails": thumbnails,
}
if msg.video_note is not None:
thumbnails = []
for thumbail in msg.video_note.thumbs:
thumbnails.append(
{
"id": thumbail.file_id,
"height": thumbail.height,
"width": thumbail.width,
"file_size": thumbail.file_size,
}
)
analytics_entry["video_note"] = {
"id": msg.video_note.file_id,
"duration": msg.video_note.duration,
"length": msg.video_note.length,
"file_size": msg.video_note.file_size,
"mime_type": msg.video_note.mime_type,
"thumbnails": thumbnails,
}
if msg.voice is not None:
analytics_entry["voice"] = {
"id": msg.voice.file_id,
"duration": msg.voice.duration,
"file_size": msg.voice.file_size,
"mime_type": msg.voice.mime_type,
}
col_analytics_group.insert_one(analytics_entry)

View File

@@ -149,3 +149,16 @@ async def filter_join(app: Client, member: ChatMemberUpdated):
can_send_polls=False, can_send_polls=False,
), ),
) )
return
if member.new_chat_member is None:
await app.send_message(
configGet("users", "groups"),
locale("user_left", "message").format(
member.old_chat_member.user.first_name
),
)
logWrite(
f"User {member.old_chat_member.user.first_name} ({member.old_chat_member.user.id}) left the destination group"
)
return

View File

@@ -224,6 +224,12 @@ if configGet("enabled", "features", "warnings") is True:
logWrite( logWrite(
f'Revoked warning {str(warning["_id"])} of user {warning["user"]} because no active warnings for the last 90 days found.' f'Revoked warning {str(warning["_id"])} of user {warning["user"]} because no active warnings for the last 90 days found.'
) )
await app.send_message(
configGet("admin", "groups"),
locale("warning_revoked_auto", "message").format(
warning["user"], warning["date"].strftime("%d.%m.%Y")
),
)
# Register all bot commands # Register all bot commands

View File

@@ -5,8 +5,11 @@ convopyro==0.5
fastapi~=0.95.0 fastapi~=0.95.0
ftfy~=6.1.1 ftfy~=6.1.1
psutil==5.9.4 psutil==5.9.4
polyglot~=16.7.4
PyICU==2.10.2
pycld2==0.41
pymongo==4.3.3 pymongo==4.3.3
Pyrogram~=2.0.102 Pyrogram~=2.0.103
python_dateutil==2.8.2 python_dateutil==2.8.2
pykeyboard==0.1.5 pykeyboard==0.1.5
requests==2.28.2 requests==2.28.2