diff --git a/bottypes/botclient.py b/bottypes/botclient.py index 885d0e7..4e24f63 100644 --- a/bottypes/botclient.py +++ b/bottypes/botclient.py @@ -86,9 +86,7 @@ def signal_handler(signum, __): task = asyncio.create_task(asyncio.sleep(self.MAINLOOP_TIMEOUT.total_seconds())) try: await task - if not self.logger.is_queue_empty(): - await self.logger.process_queue() - + await self.logger.process_queue() except asyncio.CancelledError: self.is_in_mainloop = False @@ -458,10 +456,10 @@ async def log(self, text: str, *, disable_notification: bool = True, """Sends log to the log channel.""" if instant: # made for specific log messages (e.g. "Bot is shutting down...") - await self.logger.log_instantly(self, text, disable_notification, reply_markup, parse_mode) + await self.logger.send_log(self, text, disable_notification, reply_markup, parse_mode) return - await self.logger.log(self, text, disable_notification, reply_markup, parse_mode) + await self.logger.schedule_system_log(self, text, disable_notification, reply_markup, parse_mode) async def log_message(self, session: UserSession, message: Message): """Sends message log to the log channel.""" @@ -469,7 +467,7 @@ async def log_message(self, session: UserSession, message: Message): if self.test_mode: return - await self.logger.log_message(self, session, message) + await self.logger.schedule_message_log(self, session, message) async def log_callback(self, session: UserSession, callback_query: CallbackQuery): """Sends callback query log to the log channel.""" @@ -477,7 +475,7 @@ async def log_callback(self, session: UserSession, callback_query: CallbackQuery if self.test_mode: return - await self.logger.log_callback(self, session, callback_query) + await self.logger.schedule_callback_log(self, session, callback_query) async def log_inline(self, session: UserSession, inline_query: InlineQuery): """Sends an inline query log to the log channel.""" @@ -485,4 +483,4 @@ async def log_inline(self, session: UserSession, inline_query: InlineQuery): if self.test_mode: return - await self.logger.log_inline(self, session, inline_query) + await self.logger.schedule_inline_log(self, session, inline_query) diff --git a/bottypes/logger.py b/bottypes/logger.py index 9068c12..9856b68 100644 --- a/bottypes/logger.py +++ b/bottypes/logger.py @@ -1,95 +1,114 @@ from __future__ import annotations -import asyncio import typing if typing.TYPE_CHECKING: - from typing import Coroutine from pyrogram.enums import ParseMode from pyrogram.types import (CallbackQuery, InlineQuery, Message, - ReplyKeyboardMarkup) + ReplyKeyboardMarkup, User) from .botclient import BotClient from .sessions import UserSession +class SystemLogPayload(typing.NamedTuple): + client: BotClient + text: str + disable_notification: bool + reply_markup: ReplyKeyboardMarkup + parse_mode: ParseMode + + +class EventLogPayload(typing.NamedTuple): + client: BotClient + user: User + session: UserSession + result_text: str + + +SYSTEM = 'system' + + class BotLogger: """Made to work in a pair with BotClient handling logging stuff.""" - def __init__(self, log_channel_id: int, ): + def __init__(self, log_channel_id: int): self.log_channel_id = log_channel_id - self._logs_queue = asyncio.Queue() + self._logs_queue: dict[str, list[SystemLogPayload | EventLogPayload]] = {} def is_queue_empty(self): - return self._logs_queue.empty() + return not bool(self._logs_queue) + + def put_into_queue(self, _id: str, payload: SystemLogPayload | EventLogPayload): + if self._logs_queue.get(_id) is None: + self._logs_queue[_id] = [] - async def put_into_queue(self, coroutine: Coroutine): - await self._logs_queue.put(coroutine) + self._logs_queue[_id].append(payload) async def process_queue(self): if not self.is_queue_empty(): - coroutine = await self._logs_queue.get() - await coroutine - - async def log(self, client: BotClient, text: str, - disable_notification: bool = True, - reply_markup: ReplyKeyboardMarkup = None, - parse_mode: ParseMode = None): - """Put sending a log into the queue.""" - - await self._logs_queue.put(self.log_instantly(client, text, - disable_notification, - reply_markup, - parse_mode)) - - async def log_instantly(self, client: BotClient, text: str, - disable_notification: bool = True, - reply_markup: ReplyKeyboardMarkup = None, - parse_mode: ParseMode = None): - """Sends log to the log channel instantly.""" + userid = tuple(self._logs_queue)[0] + logged_events = self._logs_queue[userid] + + if userid == SYSTEM: # invoked by system, not user + system_log = logged_events.pop(0) + return await self.send_log(system_log.client, + system_log.text, + system_log.disable_notification, + system_log.reply_markup, + system_log.parse_mode) + + del self._logs_queue[userid] + client = logged_events[-1].client + user = logged_events[-1].user + session = logged_events[-1].session + display_name = f'@{user.username}' if user.username is not None else f'{user.mention} (username hidden)' + + text = [f'👤: {display_name}', + f'ℹ️: {userid}', + f'✈️: {user.language_code}', + f'⚙️: {session.locale.lang_code}', + f'━━━━━━━━━━━━━━━━━━━━━━━'] + [event.result_text for event in logged_events] + return await self.send_log(client, '\n'.join(text), disable_notification=True) + + async def schedule_system_log(self, client: BotClient, text: str, + disable_notification: bool = True, + reply_markup: ReplyKeyboardMarkup = None, + parse_mode: ParseMode = None): + """Put sending a system log into the queue.""" + + self.put_into_queue(SYSTEM, SystemLogPayload(client, text, disable_notification, reply_markup, parse_mode)) + + async def send_log(self, client: BotClient, text: str, + disable_notification: bool = True, + reply_markup: ReplyKeyboardMarkup = None, + parse_mode: ParseMode = None): + """Sends log to the log channel immediately, avoiding the queue.""" await client.send_message(self.log_channel_id, text, disable_notification=disable_notification, reply_markup=reply_markup, parse_mode=parse_mode) - async def log_message(self, client: BotClient, session: UserSession, message: Message): + async def schedule_message_log(self, client: BotClient, session: UserSession, message: Message): """Put sending a message log into the queue.""" - username = message.from_user.username - display_name = f'@{username}' if username is not None else f'{message.from_user.mention} (username hidden)' + user = message.from_user + message_text = message.text if message.text is not None else "" - text = (f'✍️ User: {display_name}\n' - f'ID: {message.from_user.id}\n' - f'Telegram language: {message.from_user.language_code}\n' - f'Chosen language: {session.locale.lang_code}\n' - f'Private message: "{message.text if message.text is not None else ""}"') - await self._logs_queue.put(client.send_message(self.log_channel_id, text, disable_notification=True)) + self.put_into_queue(str(message.from_user.id), EventLogPayload(client, user, session, f'✍️: "{message_text}"')) - async def log_callback(self, client: BotClient, session: UserSession, callback_query: CallbackQuery): + async def schedule_callback_log(self, client: BotClient, session: UserSession, callback_query: CallbackQuery): """Put sending a callback query log into the queue""" - username = callback_query.from_user.username - display_name = f'@{username}' if username is not None \ - else f'{callback_query.from_user.mention} (username hidden)' + user = callback_query.from_user - text = (f'🔀 User: {display_name}\n' - f'ID: {callback_query.from_user.id}\n' - f'Telegram language: {callback_query.from_user.language_code}\n' - f'Chosen language: {session.locale.lang_code}\n' - f'Callback query: {callback_query.data}') - await self._logs_queue.put(client.send_message(self.log_channel_id, text, disable_notification=True)) + self.put_into_queue(str(user.id), EventLogPayload(client, user, session, f'🔀: {callback_query.data}')) - async def log_inline(self, client: BotClient, session: UserSession, inline_query: InlineQuery): + async def schedule_inline_log(self, client: BotClient, session: UserSession, inline_query: InlineQuery): """Put sending an inline query log into the queue.""" - username = inline_query.from_user.username - display_name = f'@{username}' if username is not None else f'{inline_query.from_user.mention} (username hidden)' + user = inline_query.from_user - text = (f'🛰 User: {display_name}\n' - f'ID: {inline_query.from_user.id}\n' - f'Telegram language: {inline_query.from_user.language_code}\n' - f'Chosen language: {session.locale.lang_code}\n' - f'Inline query: "{inline_query.query}"') - await self._logs_queue.put(client.send_message(self.log_channel_id, text, disable_notification=True)) + self.put_into_queue(str(user.id), EventLogPayload(client, user, session, f'🛰: "{inline_query.query}"')) diff --git a/core.py b/core.py index 5e83d5c..55a0379 100644 --- a/core.py +++ b/core.py @@ -46,6 +46,7 @@ ('india', 'mumbai'): 'India Mumbai', ('india', 'chennai'): 'India Chennai', ('india', 'bombay'): 'India Bombay', + ('india', 'madras'): 'India Madras', ('china', 'shanghai'): 'China Shanghai', ('china', 'tianjin'): 'China Tianjin', ('china', 'guangzhou'): 'China Guangzhou', @@ -57,6 +58,27 @@ } execution_start_dt = dt.datetime.now() + +UNUSED_FIELDS = ['csgo_client_version', + 'csgo_server_version', + 'csgo_patch_version', + 'csgo_version_timestamp', + 'sdk_build_id', + 'ds_build_id', + 'valve_ds_changenumber', + 'webapi', + 'sessions_logon', + 'steam_community', + 'matchmaking_scheduler'] + +execution_start_dt = dt.datetime.now() + +execution_cron_hour = execution_start_dt.hour +execution_cron_minute = execution_start_dt.minute + 1 +if execution_cron_minute >= 60: + execution_cron_hour += 1 + execution_cron_minute %= 60 + loc = locale('ru') logging.basicConfig(level=logging.INFO, @@ -72,6 +94,12 @@ workdir=config.SESS_FOLDER) +def clear_from_unused_fields(cache: dict): + for field in UNUSED_FIELDS: + if cache.get(field): + del cache[field] + + def remap_dc(info: dict, dc: Datacenter): api_info_field = DATACENTER_API_FIELDS[dc.id] return info[api_info_field] @@ -122,6 +150,8 @@ async def update_cache_info(): with open(config.CACHE_FILE_PATH, encoding='utf-8') as f: cache = json.load(f) + clear_from_unused_fields(cache) + overall_data = GameServers.request() for key, value in overall_data.asdict().items(): @@ -150,12 +180,12 @@ async def update_cache_info(): cache['player_24h_peak'] = player_24h_peak with open(config.CACHE_FILE_PATH, 'w', encoding='utf-8') as f: - json.dump(cache, f, indent=4) + json.dump(cache, f, indent=4, ensure_ascii=False) except Exception: logging.exception('Caught exception in the main thread!') - -@scheduler.scheduled_job('cron', hour=execution_start_dt.hour, minute=execution_start_dt.minute + 1) + +@scheduler.scheduled_job('cron', hour=execution_cron_hour, minute=execution_cron_minute) async def unique_monthly(): # noinspection PyBroadException try: @@ -173,14 +203,14 @@ async def unique_monthly(): cache['monthly_unique_players'] = data with open(config.CACHE_FILE_PATH, 'w', encoding='utf-8') as f: - json.dump(cache, f, indent=4) + json.dump(cache, f, indent=4, ensure_ascii=False) except Exception: logging.exception('Caught exception while gathering monthly players!') time.sleep(45) return await unique_monthly() -@scheduler.scheduled_job('cron', hour=execution_start_dt.hour, minute=execution_start_dt.minute + 1) +@scheduler.scheduled_job('cron', hour=execution_cron_hour, minute=execution_cron_minute) async def check_currency(): # noinspection PyBroadException try: @@ -193,14 +223,14 @@ async def check_currency(): cache['key_price'] = new_prices with open(config.CACHE_FILE_PATH, 'w', encoding='utf-8') as f: - json.dump(cache, f, indent=4) + json.dump(cache, f, indent=4, ensure_ascii=False) except Exception: logging.exception('Caught exception while gathering key price!') time.sleep(45) return await check_currency() -@scheduler.scheduled_job('cron', hour=execution_start_dt.hour, minute=execution_start_dt.minute + 2) +@scheduler.scheduled_job('cron', hour=execution_cron_hour, minute=execution_cron_minute, second=30) async def fetch_leaderboard(): # noinspection PyBroadException try: @@ -248,10 +278,10 @@ async def send_alert(key, new_value): logging.warning(f'Got wrong key to send alert: {key}') return - if not config.TEST_MODE: - chat_list = [config.INCS2CHAT, config.CSTRACKER] - else: + if config.TEST_MODE: chat_list = [config.AQ] + else: + chat_list = [config.INCS2CHAT, config.CSTRACKER] for chat_id in chat_list: msg = await bot.send_message(chat_id, text) diff --git a/game_coordinator.py b/game_coordinator.py index 97827c3..6a80814 100644 --- a/game_coordinator.py +++ b/game_coordinator.py @@ -2,6 +2,7 @@ import datetime as dt import json import logging +from pathlib import Path import platform import sys import time @@ -254,4 +255,4 @@ async def main(): if __name__ == '__main__': - asyncio.run(main()) + asyncio.run(main()) \ No newline at end of file diff --git a/gc_alerter.py b/gc_alerter.py new file mode 100644 index 0000000..cd5ac89 --- /dev/null +++ b/gc_alerter.py @@ -0,0 +1,102 @@ +import json +import logging +import platform + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from pyrogram import Client +if platform.system() == 'Linux': + # noinspection PyPackageRequirements + import uvloop + + uvloop.install() + +import config +from functions import locale + +loc = locale('ru') + +MONITORING_IDS = ('cs2_app_changenumber', 'cs2_server_changenumber', + 'dprp_build_id', 'dpr_build_id', 'public_build_id') + +available_alerts = {'public_build_id': loc.notifs_build_public, + 'dpr_build_id': loc.notifs_build_dpr, + 'dprp_build_id': loc.notifs_build_dprp, + 'dpr_build_sync_id': f'{loc.notifs_build_dpr} 🔃', + 'dprp_build_sync_id': f'{loc.notifs_build_dprp} 🔃', + 'cs2_app_changenumber': loc.notifs_build_cs2_client, + 'cs2_server_changenumber': loc.notifs_build_cs2_server} + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s | %(message)s", + datefmt="%H:%M:%S — %d/%m/%Y") + +scheduler = AsyncIOScheduler() +bot = Client(config.BOT_GC_MODULE_NAME, + api_id=config.API_ID, + api_hash=config.API_HASH, + bot_token=config.BOT_TOKEN, + no_updates=True, + workdir=config.SESS_FOLDER) + + +@scheduler.scheduled_job('interval', seconds=30) +async def scan_for_gc_update(): + # noinspection PyBroadException + try: + with open(config.GC_PREV_CACHE_FILE_PATH, encoding='utf-8') as f: + prev_cache = json.load(f) + with open(config.CACHE_FILE_PATH, encoding='utf-8') as f: + cache = json.load(f) + + for _id in MONITORING_IDS: + new_value = cache[_id] + if prev_cache.get(_id) != new_value: + prev_cache[_id] = new_value + if new_value == cache['public_build_id']: + if _id == 'dpr_build_id': + await send_alert('dpr_build_sync_id', new_value) + continue + if _id == 'dprp_build_id': + await send_alert('dprp_build_sync_id', new_value) + continue + + await send_alert(_id, new_value) + + with open(config.GC_PREV_CACHE_FILE_PATH, 'w', encoding='utf-8') as f: + json.dump(prev_cache, f, indent=4, ensure_ascii=False) + except Exception: + logging.exception('Caught an exception while scanning GC info!') + + +async def send_alert(key: str, new_value: int): + logging.info(f'Found new change: {key}, sending alert...') + + alert_sample = available_alerts.get(key) + + if alert_sample is None: + logging.warning(f'Got wrong key to send alert: {key}') + return + + text = alert_sample.format(new_value) + + if config.TEST_MODE: + chat_list = [config.AQ] + else: + chat_list = [config.INCS2CHAT, config.CSTRACKER] + + for chat_id in chat_list: + msg = await bot.send_message(chat_id, text, disable_web_page_preview=True) + if chat_id == config.INCS2CHAT: + await msg.pin(disable_notification=True) + + +def main(): + try: + scheduler.start() + bot.run() + except TypeError: # catching TypeError because Pyrogram propogates it at stop for some reason + logging.info('Shutting down the bot...') + + +if __name__ == '__main__': + main() diff --git a/l10n/data/be.json b/l10n/data/be.json index dc375a5..a6efa95 100644 --- a/l10n/data/be.json +++ b/l10n/data/be.json @@ -158,8 +158,9 @@ "dc_india_title": "дата-цэнтраў Індыі", "dc_india_inline_title": "Індыйскія ДЦ", "dc_india_mumbai": "Мумбаі", - "dc_india_bombay": "Bombay", + "dc_india_bombay": "Бамбей", "dc_india_chennai": "Чэннай", + "dc_india_madras": "Мадрас", "dc_japan": "Японія", "dc_japan_title": "дата-цэнтра Японіі", "dc_japan_inline_title": "Японскі ДЦ", @@ -206,7 +207,6 @@ "🇹🇭 THB: ฿ {}", "🇻🇳 VND: {} ₫", "🇰🇷 KRW: ₩ {}", - "🇹🇷 TRY: ₺ {}", "🇺🇦 UAH: {} ₴", "🇲🇽 MXN: Mex$ {}", "🇨🇦 CAD: CDN$ {}", @@ -225,7 +225,6 @@ "🇿🇦 ZAR: R {}", "🇮🇳 INR: ₹ {}", "🇨🇷 CRC: ₡ {}", - "🇦🇷 ARS: ARS$ {}", "🇮🇱 ILS: ₪ {}", "🇰🇼 KWD: {} KD", "🇶🇦 QAR: {} QR", @@ -244,10 +243,10 @@ "(напрыклад, `@INCS2bot price rub` або `@INCS2bot price рубель`)" ], "exchangerate_inline_title_selected": "Паглядзець кошт ключа ў {}", - "exchangerate_inline_description_selected": "1x Ключ ад кейса ў Counter-Strike каштуе {} {}", - "exchangerate_inline_text_selected": "1x Ключ ад кейса ў Counter-Strike каштуе {} {}", + "exchangerate_inline_description_selected": "1x Ключ ад кейса ў Counter-Strike каштуе {} {}.", + "exchangerate_inline_text_selected": "1x Ключ ад кейса ў Counter-Strike каштуе {} {}.", "exchangerate_inline_title_notfound": "Нічога не знойдзена!", - "exchangerate_inline_description_notfound": "Націсніце сюды, каб адправіць усе даступныя валюты", + "exchangerate_inline_description_notfound": "Націсніце сюды, каб адправіць усе даступныя валюты.", "game_status_button_title": "Стан сервераў", "game_status_inline_title": "Стан сервераў", "game_status_inline_description": "Праверыць даступнасць сервераў", diff --git a/l10n/data/en.json b/l10n/data/en.json index 07685b7..823043c 100644 --- a/l10n/data/en.json +++ b/l10n/data/en.json @@ -160,6 +160,7 @@ "dc_india_mumbai": "Mumbai", "dc_india_bombay": "Bombay", "dc_india_chennai": "Chennai", + "dc_india_madras": "Madras", "dc_japan": "Japan", "dc_japan_title": "Japanʼs DC", "dc_japan_inline_title": "Japanese DC", @@ -206,7 +207,6 @@ "🇹🇭 THB: ฿ {}", "🇻🇳 VND: {} ₫", "🇰🇷 KRW: ₩ {}", - "🇹🇷 TRY: ₺ {}", "🇺🇦 UAH: {} ₴", "🇲🇽 MXN: Mex$ {}", "🇨🇦 CAD: CDN$ {}", @@ -224,7 +224,6 @@ "🇭🇰 HKD: HK$ {}", "🇿🇦 ZAR: R {}", "🇮🇳 INR: ₹ {}", - "🇦🇷 ARS: ARS$ {}", "🇨🇷 CRC: ₡ {}", "🇮🇱 ILS: ₪ {}", "🇰🇼 KWD: {} KD", @@ -243,10 +242,10 @@ "(for example, `@INCS2bot price eur` or `@INCS2bot price dollar`)" ], "exchangerate_inline_title_selected": "Check key price in {}", - "exchangerate_inline_description_selected": "1x Counter-Strike case key is {} {}", - "exchangerate_inline_text_selected": "1x Counter-Strike case key is {} {}", + "exchangerate_inline_description_selected": "1x Counter-Strike case key is {} {}.", + "exchangerate_inline_text_selected": "1x Counter-Strike case key is {} {}.", "exchangerate_inline_title_notfound": "Nothing found!", - "exchangerate_inline_description_notfound": "Tap here to send all the available currencies", + "exchangerate_inline_description_notfound": "Tap here to send all the available currencies.", "game_status_button_title": "Server status", "game_status_inline_title": "Server status", "game_status_inline_description": "Check the availability of the servers", diff --git a/l10n/data/fa.json b/l10n/data/fa.json index 2a9df9f..bc9c913 100644 --- a/l10n/data/fa.json +++ b/l10n/data/fa.json @@ -160,6 +160,7 @@ "dc_india_mumbai": "مامبی", "dc_india_bombay": "Bombay", "dc_india_chennai": "چنای", + "dc_india_madras": "Madras", "dc_japan": "ژاپن", "dc_japan_title": "ژاپن", "dc_japan_inline_title": "دیتا سنتر ژاپنی", @@ -206,7 +207,6 @@ "🇹🇭 THB: ฿ {}", "🇻🇳 VND: {} ₫", "🇰🇷 KRW: ₩ {}", - "🇹🇷 TRY: ₺ {}", "🇺🇦 UAH: {} ₴", "🇲🇽 MXN: Mex$ {}", "🇨🇦 CAD: CDN$ {}", @@ -224,7 +224,6 @@ "🇭🇰 HKD: HK$ {}", "🇿🇦 ZAR: R {}", "🇮🇳 INR: ₹ {}", - "🇦🇷 ARS: ARS$ {}", "🇨🇷 CRC: ₡ {}", "🇮🇱 ILS: ₪ {}", "🇰🇼 KWD: {} KD", diff --git a/l10n/data/it.json b/l10n/data/it.json index 32ed6b3..27076eb 100644 --- a/l10n/data/it.json +++ b/l10n/data/it.json @@ -160,6 +160,7 @@ "dc_india_mumbai": "Mumbai", "dc_india_bombay": "Bombay", "dc_india_chennai": "Chennai", + "dc_india_madras": "Madras", "dc_japan": "Giappone", "dc_japan_title": "del CED del Giappone", "dc_japan_inline_title": "CED Giapponese", @@ -206,7 +207,6 @@ "🇹🇭 THB: ฿ {}", "🇻🇳 VND: {} ₫", "🇰🇷 KRW: ₩ {}", - "🇹🇷 TRY: ₺ {}", "🇺🇦 UAH: {} ₴", "🇲🇽 MXN: Mex$ {}", "🇨🇦 CAD: CDN$ {}", @@ -224,7 +224,6 @@ "🇭🇰 HKD: HK$ {}", "🇿🇦 ZAR: R {}", "🇮🇳 INR: ₹ {}", - "🇦🇷 ARS: ARS$ {}", "🇨🇷 CRC: ₡ {}", "🇮🇱 ILS: ₪ {}", "🇰🇼 KWD: {} KD", @@ -243,10 +242,10 @@ "(ad esempio, `@INCS2bot price eur` o `@INCS2bot price dollaro`)" ], "exchangerate_inline_title_selected": "Controlla il prezzo della chiave in {}", - "exchangerate_inline_description_selected": "1x chiave di una cassa di Counter-Strike equivale a {} {}", - "exchangerate_inline_text_selected": "1x chiave di una cassa di Counter-Strike equivale a {} {}", + "exchangerate_inline_description_selected": "1x chiave di una cassa di Counter-Strike equivale a {} {}.", + "exchangerate_inline_text_selected": "1x chiave di una cassa di Counter-Strike equivale a {} {}.", "exchangerate_inline_title_notfound": "Nulla è stato trovato!", - "exchangerate_inline_description_notfound": "Tocca qui per ricevere tutte le valute disponibili", + "exchangerate_inline_description_notfound": "Tocca qui per ricevere tutte le valute disponibili.", "game_status_button_title": "Stato dei server", "game_status_inline_title": "Stato dei server", "game_status_inline_description": "Controlla la disponibilità dei server", diff --git a/l10n/data/ru.json b/l10n/data/ru.json index 81e67cb..305c162 100644 --- a/l10n/data/ru.json +++ b/l10n/data/ru.json @@ -158,8 +158,9 @@ "dc_india_title": "дата-центров Индии", "dc_india_inline_title": "Индийские ДЦ", "dc_india_mumbai": "Мумбаи", - "dc_india_bombay": "Bombay", + "dc_india_bombay": "Бомбей", "dc_india_chennai": "Ченнай", + "dc_india_madras": "Мадрас", "dc_japan": "Япония", "dc_japan_title": "дата-центра Японии", "dc_japan_inline_title": "Японский ДЦ", @@ -206,7 +207,6 @@ "🇹🇭 THB: ฿ {}", "🇻🇳 VND: {} ₫", "🇰🇷 KRW: ₩ {}", - "🇹🇷 TRY: ₺ {}", "🇺🇦 UAH: {} ₴", "🇲🇽 MXN: Mex$ {}", "🇨🇦 CAD: CDN$ {}", @@ -225,7 +225,6 @@ "🇿🇦 ZAR: R {}", "🇮🇳 INR: ₹ {}", "🇨🇷 CRC: ₡ {}", - "🇦🇷 ARS: ARS$ {}", "🇮🇱 ILS: ₪ {}", "🇰🇼 KWD: {} KD", "🇶🇦 QAR: {} QR", @@ -247,7 +246,7 @@ "exchangerate_inline_description_selected": "1x Ключ от кейса в Counter-Strike стоит {} {}", "exchangerate_inline_text_selected": "1x Ключ от кейса в Counter-Strike стоит {} {}", "exchangerate_inline_title_notfound": "Ничего не найдено!", - "exchangerate_inline_description_notfound": "Нажмите сюда, чтобы отправить все доступные валюты", + "exchangerate_inline_description_notfound": "Нажмите сюда, чтобы отправить все доступные валюты.", "game_status_button_title": "Состояние серверов", "game_status_inline_title": "Состояние серверов", "game_status_inline_description": "Проверить доступность серверов", diff --git a/l10n/data/tags.json b/l10n/data/tags.json index 5e57b51..8845d5b 100644 --- a/l10n/data/tags.json +++ b/l10n/data/tags.json @@ -489,13 +489,6 @@ "von", "وان" ], - "currencies_try": [ - "try", - "lira", - "лира", - "ліра", - "لیر" - ], "currencies_uah": [ "uah", "hrywna", @@ -629,13 +622,6 @@ "روپیه", "rupia" ], - "currencies_ars": [ - "ars", - "peso", - "песо", - "песа", - "نکو" - ], "currencies_crc": [ "crc", "colon", @@ -684,4 +670,4 @@ "тэнге", "قزاق" ] -} +} \ No newline at end of file diff --git a/l10n/data/tr.json b/l10n/data/tr.json index c19d390..688bd79 100644 --- a/l10n/data/tr.json +++ b/l10n/data/tr.json @@ -157,9 +157,10 @@ "dc_india": "Hindistan", "dc_india_title": "Hindistan'daki veri merkezleri", "dc_india_inline_title": "Hint DC'leri", - "dc_india_mumbai": "Bombay", + "dc_india_mumbai": "Mumbai", "dc_india_bombay": "Bombay", "dc_india_chennai": "Chennai", + "dc_india_madras": "Madras", "dc_japan": "Japonya", "dc_japan_title": "Japonya'daki veri merkezi", "dc_japan_inline_title": "Japon DC", @@ -206,7 +207,6 @@ "🇹🇭 THB: ฿ {}", "🇻🇳 VND: {} ₫", "🇰🇷 KRW: ₩ {}", - "🇹🇷 TRY: ₺ {}", "🇺🇦 UAH: {} ₴", "🇲🇽 MXN: Mex$ {}", "🇨🇦 CAD: CDN$ {}", @@ -225,7 +225,6 @@ "🇿🇦 ZAR: R {}", "🇮🇳 INR: ₹ {}", "🇨🇷 CRC: ₡ {}", - "🇦🇷 ARS: ARS$ {}", "🇮🇱 ILS: ₪ {}", "🇰🇼 KWD: {} KD", "🇶🇦 QAR: {} QR", @@ -244,10 +243,10 @@ "(ör., `@INCS2bot price rub` veya `@INCS2bot price ruble`)" ], "exchangerate_inline_title_selected": "anahtarın {} maliyetini öğrenin", - "exchangerate_inline_description_selected": "1x Counter-Strike Case Anahtar maliyeti {} {}", - "exchangerate_inline_text_selected": "1x Counter-Strike Case Anahtar maliyeti {} {}", + "exchangerate_inline_description_selected": "1x Counter-Strike Case Anahtar maliyeti {} {}.", + "exchangerate_inline_text_selected": "1x Counter-Strike Case Anahtar maliyeti {} {}.", "exchangerate_inline_title_notfound": "Hiçbirşey bulunamadı!", - "exchangerate_inline_description_notfound": "Mevcut tüm para birimlerini göndermek için burayı basın", + "exchangerate_inline_description_notfound": "Mevcut tüm para birimlerini göndermek için burayı basın.", "game_status_button_title": "Sunucu durumu", "game_status_inline_title": "Sunucu durumu", "game_status_inline_description": "Sunucu kullanılabilirliğini kontrol etmek", diff --git a/l10n/data/uk.json b/l10n/data/uk.json index a6ae700..38c00d8 100644 --- a/l10n/data/uk.json +++ b/l10n/data/uk.json @@ -158,8 +158,9 @@ "dc_india_title": "дата-центрів Індії", "dc_india_inline_title": "Індійські ДЦ", "dc_india_mumbai": "Мумбаї", - "dc_india_bombay": "Bombay", + "dc_india_bombay": "Бомбей", "dc_india_chennai": "Ченнай", + "dc_india_madras": "Мадрас", "dc_japan": "Японія", "dc_japan_title": "дата-центру Японії", "dc_japan_inline_title": "Японський ДЦ", @@ -206,7 +207,6 @@ "🇹🇭 THB: ฿ {}", "🇻🇳 VND: {} ₫", "🇰🇷 KRW: ₩ {}", - "🇹🇷 TRY: ₺ {}", "🇺🇦 UAH: {} ₴", "🇲🇽 MXN: Mex$ {}", "🇨🇦 CAD: CDN$ {}", @@ -225,7 +225,6 @@ "🇿🇦 ZAR: R {}", "🇮🇳 INR: ₹ {}", "🇨🇷 CRC: ₡ {}", - "🇦🇷 ARS: ARS$ {}", "🇮🇱 ILS: ₪ {}", "🇰🇼 KWD: {} KD", "🇶🇦 QAR: {} QR", @@ -244,10 +243,10 @@ "(наприклад, `@INCS2bot price uah гривня` чи `@INCS2bot price гривня`)" ], "exchangerate_inline_title_selected": "Дізнатись ціну ключа в {}", - "exchangerate_inline_description_selected": "1x Ключ від кейсу в Counter-Strike коштує {} {}", - "exchangerate_inline_text_selected": "1x Ключ від кейса в Counter-Strike коштує {} {}", + "exchangerate_inline_description_selected": "1x Ключ від кейсу в Counter-Strike коштує {} {}.", + "exchangerate_inline_text_selected": "1x Ключ від кейса в Counter-Strike коштує {} {}.", "exchangerate_inline_title_notfound": "Нічого не знайдено!", - "exchangerate_inline_description_notfound": "Натисніть сюди, щоб дізнатись усі доступні валюти", + "exchangerate_inline_description_notfound": "Натисніть сюди, щоб дізнатись усі доступні валюти.", "game_status_button_title": "Стан серверів", "game_status_inline_title": "Стан серверів", "game_status_inline_description": "Перевірити доступність серверів", diff --git a/l10n/data/uz.json b/l10n/data/uz.json index 565bfbe..676708a 100644 --- a/l10n/data/uz.json +++ b/l10n/data/uz.json @@ -160,6 +160,7 @@ "dc_india_mumbai": "Mumbay", "dc_india_bombay": "Bombay", "dc_india_chennai": "Chennai", + "dc_india_madras": "Madras", "dc_japan": "Yaponiya", "dc_japan_title": "Yaponiyadagi data-centre", "dc_japan_inline_title": "Yaponiya DC", @@ -206,7 +207,6 @@ "🇹🇭 THB: ฿ {}", "🇻🇳 VND: {} ₫", "🇰🇷 KRW: ₩ {}", - "🇹🇷 TRY: ₺ {}", "🇺🇦 UAH: {} ₴", "🇲🇽 MXN: Mex$ {}", "🇨🇦 CAD: CDN$ {}", @@ -225,7 +225,6 @@ "🇿🇦 ZAR: R {}", "🇮🇳 INR: ₹ {}", "🇨🇷 CRC: ₡ {}", - "🇦🇷 ARS: ARS$ {}", "🇮🇱 ILS: ₪ {}", "🇰🇼 KWD: {} KD", "🇶🇦 QAR: {} QR", @@ -244,10 +243,10 @@ "(masalan, `@INCS2bot price rub` или `@INCS2bot price ruble`)" ], "exchangerate_inline_title_selected": "Kalit narxini ko'ring {}", - "exchangerate_inline_description_selected": "1x Counter-Strike ko'krak kaliti xarajatlari {} {}", - "exchangerate_inline_text_selected": "1x Counter-Strike ko'krak kaliti xarajatlari {} {}", + "exchangerate_inline_description_selected": "1x Counter-Strike ko'krak kaliti xarajatlari {} {}.", + "exchangerate_inline_text_selected": "1x Counter-Strike ko'krak kaliti xarajatlari {} {}.", "exchangerate_inline_title_notfound": "Hech narsa topilmadi!", - "exchangerate_inline_description_notfound": "Barcha mavjud valyutalarni yuborish uchun shu yerni bosing", + "exchangerate_inline_description_notfound": "Barcha mavjud valyutalarni yuborish uchun shu yerni bosing.", "game_status_button_title": "Server holati", "game_status_inline_title": "Server holati", "game_status_inline_description": "Server mavjudligini tekshiring", diff --git a/l10n/l10n.py b/l10n/l10n.py index ae7f03d..1dc7094 100644 --- a/l10n/l10n.py +++ b/l10n/l10n.py @@ -172,6 +172,7 @@ class Locale(SLocale): dc_india_mumbai: str # Mumbai dc_india_bombay: str # Bombay dc_india_chennai: str # Chennai + dc_india_madras: str # Madras dc_japan: str # Japan dc_japan_title: str # Japanʼs DC dc_japan_inline_title: str # Japanese DC diff --git a/l10n/tags.py b/l10n/tags.py index e89eb3e..8ba3841 100644 --- a/l10n/tags.py +++ b/l10n/tags.py @@ -1,210 +1,212 @@ -from __future__ import annotations -import json -from pathlib import Path -from typing import NamedTuple -import warnings - - -__all__ = ('Tags', 'TagsKeys', 'dump_tags') - -# todo: probably rewrite it cuz it's basically a L10n clone - - -class UnexpectedTagKey(UserWarning): - pass - - -class UndefinedTagKey(UserWarning): - pass - - -class PrimaryTagsFileNotFound(UserWarning): - pass - - -class Tags(NamedTuple): - """Object containing all the tags required by Telegram API. Tags - can be accessed as object attributes or by string keys using - get(key) method. Can be converted to dict using to_dict() method.""" - - dc_africa: list # South Africa - dc_australia: list # Australia - - dc_southamerica: list # South America - dc_southamerica_argentina: list # Argentina - dc_southamerica_brazil: list # Brazil - dc_southamerica_chile: list # Chile - dc_southamerica_peru: list # Peru - - dc_europe: list # Europe - dc_europe_austria: list # Austria - dc_europe_finland: list # Finland - dc_europe_germany: list # Germany - dc_europe_netherlands: list # Netherlands - dc_europe_poland: list # Poland - dc_europe_spain: list # Spain - dc_europe_sweden: list # Sweden - - dc_us: list # USA - dc_us_east: list # East - dc_us_west: list # West - - dc_asia: list # Asia - dc_asia_india: list # India - dc_asia_japan: list # Japan - dc_asia_china: list # China - dc_asia_emirates: list # Emirates - dc_asia_singapore: list # Singapore - dc_asia_hongkong: list # Hong Kong - dc_asia_southkorea: list # South Korea - - currencies_usd: list # U.S. Dollar - currencies_gbp: list # British Pound - currencies_eur: list # Euro - currencies_rub: list # Russian Ruble - currencies_brl: list # Brazilian Real - currencies_jpy: list # Japanese Yen - currencies_nok: list # Norwegian Krone - currencies_idr: list # Indonesian Rupiah - currencies_myr: list # Malaysian Ringgit - currencies_php: list # Philippine Peso - currencies_sgd: list # Singapore Dollar - currencies_thb: list # Thai Baht - currencies_vnd: list # Vietnamese Dong - currencies_krw: list # South Korean Won - currencies_try: list # Turkish Lira - currencies_uah: list # Ukrainian Hryvnia - currencies_mxn: list # Mexican Peso - currencies_cad: list # Canadian Dollar - currencies_aud: list # Australian Dollar - currencies_nzd: list # New Zealand Dollar - currencies_pln: list # Polish Zloty - currencies_chf: list # Swiss Franc - currencies_aed: list # U.A.E. Dirham - currencies_clp: list # Chilean Peso - currencies_cny: list # Chinese Yuan - currencies_cop: list # Colombian Peso - currencies_pen: list # Peruvian Sol - currencies_sar: list # Saudi Riyal - currencies_twd: list # Taiwan Dollar - currencies_hkd: list # Hong Kong Dollar - currencies_zar: list # South African Rand - currencies_inr: list # Indian Rupee - currencies_ars: list # Argentine Peso - currencies_crc: list # Costa Rican Colon - currencies_ils: list # Israeli Shekel - currencies_kwd: list # Kuwaiti Dinar - currencies_qar: list # Qatari Riyal - currencies_uyu: list # Uruguayan Peso - currencies_kzt: list # Kazakhstani Tenge - - def to_dict(self) -> dict[str, list]: - """Returns a dict converted from a Tags object.""" - - return self._asdict() - - def to_set(self) -> set: - """Returns a set converted from a Tags object.""" - - result = set() - for tags in self._asdict().values(): - result.update(tags) - return set(result) - - def dcs_to_set(self) -> set: - """Returns a datacenters set of converted from a Tags object.""" - - result = set() - for k, tags in self._asdict().items(): - if k.startswith('dc'): - result.update(tags) - return set(result) - - def to_list(self) -> list: - """Returns a list converted from a Tags object.""" - - result = [] - for tags in self._asdict().values(): - result.extend(tags) - return result - - def currencies_to_list(self) -> list: - """Returns a currency list converted from a Tags object.""" - - result = [] - for k, tags in self._asdict().items(): - if k.startswith('currencies'): - result.extend(tags) - return result - - def currencies_to_dict(self) -> dict: - """Returns a set converted from a Tags object.""" - - result = {} - for k, tags in self._asdict().items(): - if k.startswith('currencies'): - result[tags[0]] = tags - return result - - def get(self, key: str) -> str: - """Returns tags list associated with the given key (if such - key exists, otherwise returns the key itself).""" - - if key not in self._fields: - warnings.warn(f'Got unexpected key "{key}", returned the key', UnexpectedTagKey, stacklevel=2) - return key - return getattr(self, key) - - @classmethod - def sample(cls) -> Tags: - """Returns a sample Tags object with key names as values""" - return cls(**{field: field for field in cls._fields}) - - -TagsKeys = Tags.sample() - - -def dump_tags() -> Tags: - """Dumps "tags.json" and returns Tags object, containing all defined tags lists. - """ - path = Path(__file__).parent / 'data' / 'tags.json' - if not path.exists(): - warnings.warn(f"Can't find tags.json, generating a file...", PrimaryTagsFileNotFound) - sample = Tags.sample() - with open(path, 'w', encoding='utf-8') as f: - json.dump({k: [v] for k, v in sample.to_dict().items()}, f, indent=4, ensure_ascii=False) - - with open(path, encoding='utf-8') as f: - data = json.load(f) - - # Add undefined fields - found_undefined_fields = False - for field in Tags._fields: - if field not in data: - warnings.warn(f'Found undefined tags field "{field}" in "tags.json"', UndefinedTagKey, stacklevel=2) - data[field] = [field] - found_undefined_fields = True - - # Find unexpected fields - unexpected_fields = [] - for field in tuple(data): - if field not in Tags._fields: - warnings.warn(f'Got unexpected tags field "{field}" in "tags.json"', UnexpectedTagKey, stacklevel=2) - unexpected_fields.append(field) - - # Dump data with undefined and unexpected fields - if found_undefined_fields or unexpected_fields: - with open(path, 'w', encoding='utf-8') as f: - data = {field: data[field] for field in Tags._fields + tuple(unexpected_fields)} # fixing pairs order - json.dump(data, f, indent=4, ensure_ascii=False) - - # Remove unexpected fields - for field in unexpected_fields: - del data[field] - - for og_field, og_tags in data.items(): - for field, tags in data.items(): - if og_field != field and field.startswith(og_field): - data[field].extend(og_tags) - - return Tags(**data) +from __future__ import annotations +import json +from pathlib import Path +from typing import NamedTuple +import warnings + + +__all__ = ('Tags', 'TagsKeys', 'dump_tags') + +# todo: probably rewrite it cuz it's basically a L10n clone + + +class UnexpectedTagKey(UserWarning): + pass + + +class UndefinedTagKey(UserWarning): + pass + + +class PrimaryTagsFileNotFound(UserWarning): + pass + + +class Tags(NamedTuple): + """Object containing all the tags required by Telegram API. Tags + can be accessed as object attributes or by string keys using + get(key) method. Can be converted to dict using to_dict() method.""" + + dc_africa: list # South Africa + dc_australia: list # Australia + + dc_southamerica: list # South America + dc_southamerica_argentina: list # Argentina + dc_southamerica_brazil: list # Brazil + dc_southamerica_chile: list # Chile + dc_southamerica_peru: list # Peru + + dc_europe: list # Europe + dc_europe_austria: list # Austria + dc_europe_finland: list # Finland + dc_europe_germany: list # Germany + dc_europe_netherlands: list # Netherlands + dc_europe_poland: list # Poland + dc_europe_spain: list # Spain + dc_europe_sweden: list # Sweden + + dc_us: list # USA + dc_us_east: list # East + dc_us_west: list # West + + dc_asia: list # Asia + dc_asia_india: list # India + dc_asia_japan: list # Japan + dc_asia_china: list # China + dc_asia_emirates: list # Emirates + dc_asia_singapore: list # Singapore + dc_asia_hongkong: list # Hong Kong + dc_asia_southkorea: list # South Korea + + currencies_usd: list # U.S. Dollar + currencies_gbp: list # British Pound + currencies_eur: list # Euro + currencies_rub: list # Russian Ruble + currencies_brl: list # Brazilian Real + currencies_jpy: list # Japanese Yen + currencies_nok: list # Norwegian Krone + currencies_idr: list # Indonesian Rupiah + currencies_myr: list # Malaysian Ringgit + currencies_php: list # Philippine Peso + currencies_sgd: list # Singapore Dollar + currencies_thb: list # Thai Baht + currencies_vnd: list # Vietnamese Dong + currencies_krw: list # South Korean Won + currencies_uah: list # Ukrainian Hryvnia + currencies_mxn: list # Mexican Peso + currencies_cad: list # Canadian Dollar + currencies_aud: list # Australian Dollar + currencies_nzd: list # New Zealand Dollar + currencies_pln: list # Polish Zloty + currencies_chf: list # Swiss Franc + currencies_aed: list # U.A.E. Dirham + currencies_clp: list # Chilean Peso + currencies_cny: list # Chinese Yuan + currencies_cop: list # Colombian Peso + currencies_pen: list # Peruvian Sol + currencies_sar: list # Saudi Riyal + currencies_twd: list # Taiwan Dollar + currencies_hkd: list # Hong Kong Dollar + currencies_zar: list # South African Rand + currencies_inr: list # Indian Rupee + currencies_crc: list # Costa Rican Colon + currencies_ils: list # Israeli Shekel + currencies_kwd: list # Kuwaiti Dinar + currencies_qar: list # Qatari Riyal + currencies_uyu: list # Uruguayan Peso + currencies_kzt: list # Kazakhstani Tenge + + def to_dict(self) -> dict[str, list]: + """Returns a dict converted from a Tags object.""" + + return self._asdict() + + def to_set(self) -> set: + """Returns a set converted from a Tags object.""" + + result = set() + for tags in self._asdict().values(): + result.update(tags) + return set(result) + + def dcs_to_set(self) -> set: + """Returns a datacenters set of converted from a Tags object.""" + + result = set() + for k, tags in self._asdict().items(): + if k.startswith('dc'): + result.update(tags) + return set(result) + + def to_list(self) -> list: + """Returns a list converted from a Tags object.""" + + result = [] + for tags in self._asdict().values(): + result.extend(tags) + return result + + def currencies_to_list(self) -> list: + """Returns a currency list converted from a Tags object.""" + + result = [] + for k, tags in self._asdict().items(): + if k.startswith('currencies'): + result.extend(tags) + return result + + def currencies_to_dict(self) -> dict: + """Returns a set converted from a Tags object.""" + + result = {} + for k, tags in self._asdict().items(): + if k.startswith('currencies'): + result[tags[0]] = tags + return result + + def get(self, key: str) -> str: + """Returns tags list associated with the given key (if such + key exists, otherwise returns the key itself).""" + + if key not in self._fields: + warnings.warn(f'Got unexpected key "{key}", returned the key', UnexpectedTagKey, stacklevel=2) + return key + return getattr(self, key) + + @classmethod + def sample(cls) -> Tags: + """Returns a sample Tags object with key names as values""" + return cls(**{field: field for field in cls._fields}) + + +TagsKeys = Tags.sample() + + +def dump_tags() -> Tags: + """Dumps "tags.json" and returns Tags object, containing all defined tags lists.""" + + path = Path(__file__).parent / 'data' / 'tags.json' + if not path.exists(): + warnings.warn(f"Can't find tags.json, generating a file...", PrimaryTagsFileNotFound) + sample = Tags.sample() + with open(path, 'w', encoding='utf-8') as f: + json.dump({k: [v] for k, v in sample.to_dict().items()}, f, indent=4, ensure_ascii=False) + + with open(path, encoding='utf-8') as f: + data = json.load(f) + + # Add undefined fields + found_undefined_fields = False + for field in Tags._fields: + if field not in data: + warnings.warn(f'Found undefined tags field "{field}" in "tags.json"', UndefinedTagKey, stacklevel=2) + data[field] = [field] + found_undefined_fields = True + + # Find unexpected fields + unexpected_fields = [] + for field in tuple(data): + if field not in Tags._fields: + warnings.warn(f'Got unexpected tags field "{field}" in "tags.json"', UnexpectedTagKey, stacklevel=2) + unexpected_fields.append(field) + + # Dump data with undefined and unexpected fields + if found_undefined_fields or unexpected_fields: + with open(path, 'w', encoding='utf-8') as f: + data = {field: data[field] for field in Tags._fields + tuple(unexpected_fields)} # fixing pairs order + json.dump(data, f, indent=4, ensure_ascii=False) + + # Remove unexpected fields + for field in unexpected_fields: + del data[field] + + for og_field, og_tags in data.items(): + for field, tags in data.items(): + if og_field != field and field.startswith(og_field): + data[field].extend(og_tags) + + return Tags(**data) + + +if __name__ == '__main__': + dump_tags() diff --git a/l10n/test.py b/l10n/test.py index ca8bfa3..5d2b9f7 100644 --- a/l10n/test.py +++ b/l10n/test.py @@ -2,7 +2,7 @@ from sl10n.pimpl import JSONImpl -from l10n import SL10n, Locale +from l10n import SL10n, Locale, dump_tags def test_l10n(recwarn): @@ -16,3 +16,15 @@ def test_l10n(recwarn): for r in recwarn: print(f'{r.category.__name__}: {r.message}') assert len(recwarn) == 0, 'SL10n process raised some warnings.' + + +def test_tags(recwarn): + """ + Test to check any missing or unexpected tags keys. + """ + + dump_tags() + + for r in recwarn: + print(f'{r.category.__name__}: {r.message}') + assert len(recwarn) == 0, 'Tags process raised some warnings.' diff --git a/main.py b/main.py index 651174d..7ba2f77 100644 --- a/main.py +++ b/main.py @@ -59,7 +59,7 @@ async def handle_exceptions_in_callback(client: BotClient, session: UserSession, bot_message: Message, exc: Exception): logging.exception('Caught exception!', exc_info=exc) await client.log(f'❗️ {"".join(traceback.format_exception(exc))}', - disable_notification=True, parse_mode=ParseMode.DISABLED) + disable_notification=False, parse_mode=ParseMode.DISABLED) return await something_went_wrong(client, session, bot_message) @@ -305,7 +305,7 @@ async def profile_info(client: BotClient, session: UserSession, bot_message: Mes with open(config.CACHE_FILE_PATH, encoding='utf-8') as f: cache_file = json.load(f) - if cache_file.get('webapi') != 'normal': + if cache_file.get('webapi_state') != 'normal': return await send_about_maintenance(client, session, bot_message) await bot_message.edit(session.locale.bot_choose_cmd, @@ -932,18 +932,17 @@ async def main(): scheduler.add_job(regular_stats_report, 'interval', hours=8, args=(bot,)) - scheduler.start() - try: await db_session.init(config.USER_DB_FILE_PATH) await bot.start() + scheduler.start() await bot.log('Bot started.', instant=True) await bot.mainloop() except Exception as e: logging.exception('The bot got terminated because of exception!') await bot.log(f'Bot got terminated because of exception!\n' f'\n' - f'❗️ {e.__traceback__}', disable_notification=False) + f'❗️ {e.__traceback__}', disable_notification=True, instant=True) finally: logging.info('Shutting down the bot...') await bot.log('Bot is shutting down...', instant=True) diff --git a/online_players_graph.py b/online_players_graph.py index 6521b38..75d7e55 100644 --- a/online_players_graph.py +++ b/online_players_graph.py @@ -115,7 +115,7 @@ def graph_maker(): cache['graph_url'] = image_url with open(config.CACHE_FILE_PATH, 'w', encoding='utf-8') as f: - json.dump(cache, f, indent=4) + json.dump(cache, f, indent=4, ensure_ascii=False) except Exception: logging.exception('Caught exception in graph maker!') time.sleep(MINUTE) diff --git a/plugins/incs2chat.py b/plugins/incs2chat.py index c01b1d0..3ecc178 100644 --- a/plugins/incs2chat.py +++ b/plugins/incs2chat.py @@ -1,15 +1,28 @@ import asyncio +import json import logging import random +import re +from io import BytesIO -import requests from pyrogram import Client, filters -from pyrogram.enums import ChatMembersFilter, MessageEntityType -from pyrogram.types import Message, MessageEntity +from pyrogram.enums import ChatMembersFilter +from pyrogram.types import Chat, Message, MessageEntity, User +import requests +from tgentity import to_md import config +DISCORD_MESSAGE_LENGTH_LIMIT = 2000 + + +async def is_administrator(chat: Chat, user: User) -> bool: + admins = {admin.user async for admin in chat.get_members(filter=ChatMembersFilter.ADMINISTRATORS)} + + return user in admins + + def correct_message_entities(entities: list[MessageEntity] | None, original_text: str, new_text: str) -> list[MessageEntity] | None: """Correct message entities (a.k.a. Markdown formatting) for edited text.""" @@ -32,53 +45,59 @@ def correct_message_entities(entities: list[MessageEntity] | None, return entities -def convert_message_to_markdown_text(message: Message) -> str: - """Convert Telegram formatting to Markdown (primarily for Discord).""" - - entity_type_to_markdown = { # only includes simple closing entities - MessageEntityType.BOLD: '**', - MessageEntityType.ITALIC: '*', - MessageEntityType.UNDERLINE: '__', - MessageEntityType.STRIKETHROUGH: '~~', - MessageEntityType.SPOILER: '||', - MessageEntityType.CODE: '`' - } - - text = message.text or message.caption or '' - - if message.entities is None: - return text - - text = list(text) - text_shift = 0 - for entity in message.entities: - # special cases first - if entity.type is MessageEntityType.TEXT_LINK: - text.insert(entity.offset + text_shift, '[') - text.insert(entity.offset + entity.length + text_shift + 1, f']({entity.url})') - text_shift += 2 - if entity.type is MessageEntityType.PRE: - text.insert(entity.offset + text_shift, f'```{entity.language}\n') - text.insert(entity.offset + entity.length + text_shift + 1, f'\n```') - text_shift += 2 - if entity.type is MessageEntityType.BLOCKQUOTE: - text.insert(entity.offset + text_shift, '> ') - text_shift += 1 - - symbol = entity_type_to_markdown.get(entity.type) - if symbol: - previous_symbol = text[entity.offset + text_shift - 2] - if previous_symbol in entity_type_to_markdown.values(): # todo: still fails on 3+ styles in the same chunk - text.insert(entity.offset + text_shift - 1, symbol) - text.insert(entity.offset + entity.length + text_shift, symbol) - else: - text.insert(entity.offset + text_shift, symbol) - text.insert(entity.offset + entity.length + text_shift + 1, symbol) - text_shift += 2 - return ''.join(text) - - -def translate_text(text: str, source_lang: str = 'RU', target_lang: str = 'EN'): +def to_discord_markdown(message: Message) -> str: + text = (to_md(message) + .replace(r'\.', '.') + .replace(r'\(', '(') # god bless this feels awful + .replace(r'\)', ')')) + text = re.sub(r'~([^~]+)~', r'~~\1~~', text) + + return text + + +def wrap_text(text: str, max_length: int) -> list[str]: + """ + Wraps the given text into multiple sections with a length <= ``max_length``. + + Prioritises wrapping by newlines, then spaces. + """ + + if len(text) <= max_length: + return [text] + + text_parts = [] + while len(text) > max_length: + longest_possible_part = text[:max_length] + last_char_index = longest_possible_part.rfind('\n') + if last_char_index == -1: + last_char_index = longest_possible_part.rfind(' ') + if last_char_index == -1: + last_char_index = max_length + + new_part = text[:last_char_index] + text_parts.append(new_part) + if text[last_char_index].isspace(): + last_char_index += 1 + text = text[last_char_index:] + + text_parts.append(text) + + return text_parts + + +def process_discord_text(message: Message) -> list[str]: + text = (to_discord_markdown(message) if message.entities + else message.caption if message.caption + else message.text if message.text + else '') + + # fixme: can break formatting if wrapping happens in the middle of formatted section + # fixme: (e.g. "**some [split] wise words**") + # fixme severity: low + return wrap_text(text, DISCORD_MESSAGE_LENGTH_LIMIT) + + +def translate_text(text: str, source_lang: str = 'RU', target_lang: str = 'EN') -> str | None: headers = {'Authorization': f'DeepL-Auth-Key {config.DEEPL_TOKEN}', 'Content-Type': 'application/json'} data = {'text': [text], 'source_lang': source_lang, 'target_lang': target_lang} @@ -91,15 +110,55 @@ def translate_text(text: str, source_lang: str = 'RU', target_lang: str = 'EN'): logging.error(f'{r.status_code=}, {r.reason=}') -def post_to_discord_webhook(url: str, text: str): # todo: attachments support? - headers = {'Content-Type': 'application/json'} +def post_to_discord_webhook(url: str, text: str, attachment: BytesIO = None): # todo: attachments support? payload = {'content': text} - r = requests.post(url, json=payload, headers=headers) - if r.status_code != 204: # Discord uses 204 as a success code (yikes) + if attachment: + payload = {'payload_json': (None, json.dumps(payload)), 'files[0]': (attachment.name, attachment.getbuffer())} + r = requests.post(url, files=payload) + else: + headers = {'Content-Type': 'application/json'} + r = requests.post(url, payload, headers=headers) + + if r.status_code not in [200, 204]: # Discord uses 204 as a success code (yikes) logging.error('Failed to post to Discord webhook.') - logging.error(f'{text=}') - logging.error(f'{r.status_code=}, {r.reason=}') + logging.error(f'{payload=}') + logging.error(f'{r.status_code=}, {r.reason=}, {r.text=}') + + +async def forward_to_discord(client: Client, message: Message): + texts = process_discord_text(message) + logging.info(texts) + + attachment: BytesIO | None + try: + # fixme: for every attachment this ^ function will be called multiple times + # fixme: this could be fixed if we keep track of media groups but too lazy to implement it properly + attachment = await client.download_media(message, in_memory=True) + except ValueError: + attachment = None + + for text in texts: + if attachment: + post_to_discord_webhook(config.DS_WEBHOOK_URL, text, attachment) + post_to_discord_webhook(config.DS_WEBHOOK_URL_EN, translate_text(text, 'RU', 'EN'), attachment) + attachment = None + else: + post_to_discord_webhook(config.DS_WEBHOOK_URL, text) + post_to_discord_webhook(config.DS_WEBHOOK_URL_EN, translate_text(text, 'RU', 'EN')) + + +async def cs_l10n_update(message: Message): + has_the_l10n_line = ((message.text and "Обновлены файлы локализации" in message.text) + or (message.caption and "Обновлены файлы локализации" in message.caption)) + + if has_the_l10n_line: + await message.reply_sticker('CAACAgIAAxkBAAID-l_9tlLJhZQSgqsMUAvLv0r8qhxSAAIKAwAC-p_xGJ-m4XRqvoOzHgQ') + + +async def filter_message(sender: Chat | User, message: Message): + if sender and sender.id in config.FILTERED_SENDERS: + await message.delete() @Client.on_message(filters.chat(config.INCS2CHAT) & filters.command("ban")) @@ -113,33 +172,29 @@ async def ban(client: Client, message: Message): return await message.reply("Эта команда недоступна, Вы не являетесь разработчиком Valve.") if message.reply_to_message: - og_msg = message.reply_to_message - await chat.ban_member(og_msg.from_user.id) - await message.reply(f"{og_msg.from_user.first_name} получил(а) VAC бан.") + original_msg = message.reply_to_message + await chat.ban_member(original_msg.from_user.id) + await message.reply(f"{original_msg.from_user.first_name} получил(а) VAC бан.") @Client.on_message(filters.chat(config.INCS2CHAT) & filters.command("unban")) async def unban(client: Client, message: Message): chat = await client.get_chat(config.INCS2CHAT) - admins = chat.get_members(filter=ChatMembersFilter.ADMINISTRATORS) - admins = {admin.user.id async for admin in admins} - if message.from_user.id not in admins: + if not await is_administrator(chat, message.from_user): return await message.reply("Эта команда недоступна, Вы не являетесь разработчиком Valve.") if message.reply_to_message: - og_msg = message.reply_to_message - await chat.unban_member(og_msg.from_user.id) - await message.reply(f"VAC бан у {og_msg.from_user.first_name} был удалён.") + original_msg = message.reply_to_message + await chat.unban_member(original_msg.from_user.id) + await message.reply(f"VAC бан у {original_msg.from_user.first_name} был удалён.") @Client.on_message(filters.chat(config.INCS2CHAT) & filters.command("warn")) async def warn(client: Client, message: Message): chat = await client.get_chat(config.INCS2CHAT) - admins = chat.get_members(filter=ChatMembersFilter.ADMINISTRATORS) - admins = {admin.user.id async for admin in admins} - if message.from_user.id not in admins: + if not await is_administrator(chat, message.from_user): return await message.reply("Эта команда недоступна, Вы не являетесь разработчиком Valve.") if message.reply_to_message: @@ -151,11 +206,9 @@ async def warn(client: Client, message: Message): @Client.on_message(filters.chat(config.INCS2CHAT) & filters.command('echo')) async def echo(client: Client, message: Message): chat = await client.get_chat(config.INCS2CHAT) - admins = chat.get_members(filter=ChatMembersFilter.ADMINISTRATORS) - admins = {admin.user.id async for admin in admins} await message.delete() - if message.from_user.id not in admins: + if not await is_administrator(chat, message.from_user): return if message.reply_to_message: @@ -205,33 +258,25 @@ async def echo(client: Client, message: Message): return await reply_to.reply_voice(voice, quote=should_reply, caption=caption, caption_entities=entities) -@Client.on_message(filters.channel & filters.chat(config.INCS2CHANNEL)) -async def forward_to_discord(_, message: Message): - text = convert_message_to_markdown_text(message) - logging.info(text) - - if text: - post_to_discord_webhook(config.DS_WEBHOOK_URL, text) - post_to_discord_webhook(config.DS_WEBHOOK_URL_EN, translate_text(text, 'RU', 'EN')) - - message.continue_propagation() - - @Client.on_message(filters.linked_channel & filters.chat(config.INCS2CHAT)) -async def cs_l10n_update(_, message: Message): +async def handle_new_post(client: Client, message: Message): is_sent_by_correct_chat = (message.sender_chat and message.sender_chat.id == config.INCS2CHANNEL) is_forwarded_from_correct_chat = (message.forward_from_chat and message.forward_from_chat.id == config.INCS2CHANNEL) - has_the_l10n_line = ((message.text and "Обновлены файлы локализации" in message.text) - or (message.caption and "Обновлены файлы локализации" in message.caption)) - if is_sent_by_correct_chat and is_forwarded_from_correct_chat and has_the_l10n_line: - await message.reply_sticker('CAACAgIAAxkBAAID-l_9tlLJhZQSgqsMUAvLv0r8qhxSAAIKAwAC-p_xGJ-m4XRqvoOzHgQ') + if is_sent_by_correct_chat and is_forwarded_from_correct_chat: + await cs_l10n_update(message) + await forward_to_discord(client, message) @Client.on_message(filters.chat(config.INCS2CHAT) & filters.forwarded) async def filter_forwards(_, message: Message): - if message.forward_from_chat and message.forward_from_chat.id in config.FILTER_FORWARDS: - await message.delete() + forward_from = message.forward_from_chat if message.forward_from_chat else message.forward_from + await filter_message(forward_from, message) + + +@Client.on_message(filters.chat(config.INCS2CHAT) & filters.via_bot) +async def filter_via_bot(_, message: Message): + await filter_message(message.via_bot, message) @Client.on_message(filters.chat(config.INCS2CHAT) & filters.sticker) @@ -240,8 +285,3 @@ async def meow_meow_meow_meow(_, message: Message): if message.sticker.file_unique_id == 'AgADtD0AAu4r4Ug' and chance < 5: await message.reply('мяу мяу мяу мяу') - - -# @Client.on_message(filters.chat(config.INCS2CHAT) & filters.animation) -# async def debugging_gifs(_, message: Message): -# print(message.animation) diff --git a/requirements.txt b/requirements.txt index d86ee4a..5168d16 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/utypes/datacenters.py b/utypes/datacenters.py index 90c74f2..f705df2 100644 --- a/utypes/datacenters.py +++ b/utypes/datacenters.py @@ -278,17 +278,21 @@ class DatacenterAtlas: "india", [ Datacenter( - "mumbai", - l10n_key_title=LK.dc_india_mumbai + "bombay", + l10n_key_title=LK.dc_india_bombay ), Datacenter( "chennai", l10n_key_title=LK.dc_india_chennai ), Datacenter( - "bombay", - l10n_key_title=LK.dc_india_bombay - ) + "madras", + l10n_key_title=LK.dc_india_madras + ), + Datacenter( + "mumbai", + l10n_key_title=LK.dc_india_mumbai + ), ], "🇮🇳", LK.dc_india, diff --git a/utypes/game_data.py b/utypes/game_data.py index 0bd11eb..8c9c420 100644 --- a/utypes/game_data.py +++ b/utypes/game_data.py @@ -77,7 +77,6 @@ class ExchangeRateData(NamedTuple): THB: float VND: float KRW: float - TRY: float UAH: float MXN: float CAD: float @@ -95,7 +94,6 @@ class ExchangeRateData(NamedTuple): HKD: float ZAR: float INR: float - ARS: float CRC: float ILS: float KWD: float @@ -180,8 +178,7 @@ def request(cls): cs2_client_version = int(options['ClientVersion']) - 2000000 cs2_server_version = int(options['ServerVersion']) - 2000000 cs2_patch_version = options['PatchVersion'] - cs2_version_timestamp = version_datetime.timestamp() # todo: maybe should use unix timestamp - # todo: instead of this piece of crap? + cs2_version_timestamp = version_datetime.timestamp() return GameVersionData(cs2_client_version, cs2_server_version, @@ -199,11 +196,7 @@ def cached_data(filename: str | Path): cs2_server_version = cache_file.get('cs2_client_version', 'unknown') cs2_patch_version = cache_file.get('cs2_patch_version', 'unknown') - # Timestamp could be saved as ISO-8601, so we need to address that cs2_version_timestamp = cache_file.get('cs2_version_timestamp', 0) - if isinstance(cs2_version_timestamp, str): - cs2_version_timestamp = dt.datetime.fromisoformat(cs2_version_timestamp).timestamp() - return GameVersionData(cs2_client_version, cs2_server_version, cs2_patch_version, @@ -211,25 +204,27 @@ def cached_data(filename: str | Path): class ExchangeRate: - __slots__ = () GET_KEY_PRICES_API = f'https://api.steampowered.com/ISteamEconomy/GetAssetPrices/v1/' \ f'?appid=730&key={config.STEAM_API_KEY}' CURRENCIES_SYMBOLS = {"USD": "$", "GBP": "£", "EUR": "€", "RUB": "₽", "BRL": "R$", "JPY": "¥", "NOK": "kr", "IDR": "Rp", "MYR": "RM", "PHP": "₱", "SGD": "S$", "THB": "฿", - "VND": "₫", "KRW": "₩", "TRY": "₺", "UAH": "₴", - "MXN": "Mex$", "CAD": "CDN$", "AUD": "A$", - "NZD": "NZ$", "PLN": "zł", "CHF": "CHF", "AED": "AED", - "CLP": "CLP$", "CNY": "¥", "COP": "COL$", "PEN": "S/.", - "SAR": "SR", "TWD": "NT$", "HKD": "HK$", "ZAR": "R", - "INR": "₹", "ARS": "ARS$", "CRC": "₡", "ILS": "₪", - "KWD": "KD", "QAR": "QR", "UYU": "$U", "KZT": "₸"} + "VND": "₫", "KRW": "₩", "UAH": "₴", "MXN": "Mex$", + "CAD": "CDN$", "AUD": "A$", "NZD": "NZ$", "PLN": "zł", + "CHF": "CHF", "AED": "AED", "CLP": "CLP$", "CNY": "¥", + "COP": "COL$", "PEN": "S/.", "SAR": "SR", "TWD": "NT$", + "HKD": "HK$", "ZAR": "R", "INR": "₹", "CRC": "₡", + "ILS": "₪", "KWD": "KD", "QAR": "QR", "UYU": "$U", + "KZT": "₸"} + UNDEFINED_CURRENCIES = ('Unknown', 'ARS', 'BYN', 'TRY') @classmethod def request(cls): r = requests.get(cls.GET_KEY_PRICES_API, timeout=15).json()['result']['assets'] key_price = [item for item in r if item['classid'] == '1544098059'][0]['prices'] - del key_price['Unknown'], key_price['BYN'] #, key_price['ARS'], key_price['TRY'] # undefined values + + for currency in cls.UNDEFINED_CURRENCIES: + del key_price[currency] prices = {k: v / 100 for k, v in key_price.items()} formatted_prices = {k: f'{v:.0f}' if v % 1 == 0 else f'{v:.2f}' @@ -247,6 +242,10 @@ def cached_data(filename: str | Path): if key_prices is None: return {} + for cur in ('ARS', 'TRY'): # these values could be left in the cache + if key_prices.get(cur): # todo: remove later + del key_prices[cur] + return ExchangeRateData(**key_prices) @@ -296,15 +295,11 @@ def cached_server_status(filename: str | Path): if game_server_dt == States.UNKNOWN: return States.UNKNOWN - gc_state = States.sget(cache_file.get('game_coordinator')) - sl_state = States.sget(cache_file.get('sessions_logon')) - ms_state = States.sget(cache_file.get('matchmaking_scheduler')) - sc_state = States.sget(cache_file.get('steam_community')) - webapi_state = States.sget(cache_file.get('webapi')) - - now = utime.utcnow() - is_maintenance = ((now.weekday() == 1 and now.hour > 21) or (now.weekday() == 2 and now.hour < 4)) \ - and not (gc_state == States.NORMAL and sl_state == States.NORMAL) + gc_state = States.get_or_unknown(cache_file.get('game_coordinator_state')) + sl_state = States.get_or_unknown(cache_file.get('sessions_logon_state')) + ms_state = States.get_or_unknown(cache_file.get('matchmaking_scheduler_state')) + sc_state = States.get_or_unknown(cache_file.get('steam_community_state')) + webapi_state = States.get_or_unknown(cache_file.get('webapi_state')) return ServerStatusData(game_server_dt, gc_state, sl_state, ms_state, sc_state, webapi_state) @@ -318,8 +313,8 @@ def cached_matchmaking_stats(filename: str | Path): if game_server_dt is States.UNKNOWN: return States.UNKNOWN - gc_state = States.sget(cache_file.get('game_coordinator')) - sl_state = States.sget(cache_file.get('sessions_logon')) + gc_state = States.get_or_unknown(cache_file.get('game_coordinator_state')) + sl_state = States.get_or_unknown(cache_file.get('sessions_logon_state')) graph_url = cache_file.get('graph_url', '') online_players = cache_file.get('online_players', 0) @@ -347,7 +342,7 @@ def latest_info_update(filename: str | Path): if cache_file.get('api_timestamp', 'unknown') == 'unknown': return States.UNKNOWN - return dt.datetime.fromtimestamp(cache_file.get('api_timestamp', 0), dt.UTC) + return dt.datetime.fromtimestamp(cache_file['api_timestamp'], dt.UTC) class LeaderboardStats(NamedTuple): @@ -386,7 +381,7 @@ def from_json(cls, data): losses = entry.val elif entry.tag == 19: for map_id, map_name in MAPS.items(): - last_wins[map_name] = ((entry.val << (4 * map_id)) & 0xF0000000) >> 4*7 + last_wins[map_name] = ((entry.val << (4 * map_id)) & 0xF0000000) >> 4 * 7 elif entry.tag == 20: timestamp = entry.val elif entry.tag == 21: diff --git a/utypes/states.py b/utypes/states.py index b570634..a337a5a 100644 --- a/utypes/states.py +++ b/utypes/states.py @@ -32,18 +32,13 @@ class States: UNKNOWN = State('unknown', LK.states_unknown) @classmethod - def get(cls, data: str) -> State | None: - data = data.replace(' ', '_').upper() - try: - return getattr(cls, data) - except KeyError: - return + def get(cls, data, default=None) -> State | None: + data = str(data).replace(' ', '_').upper() + + return getattr(cls, data, default) @classmethod - def sget(cls, data: str | None) -> State: - """Same as States.get(), but returns States.UNKNOWN if there's no such state or `data is None`.""" - if data is None: - return States.UNKNOWN - st = cls.get(data) + def get_or_unknown(cls, data: str | None) -> State: + """Same as ``States.get()``, but returns ``States.UNKNOWN`` if there's no such state.""" - return st if st is not None else States.UNKNOWN + return cls.get(data, States.UNKNOWN)