class OdesliBot: """Odesli Telegram bot.""" #: If this string is in an incoming message, the message won't be processed SKIP_MARK = '!skip' #: Time to wait before retrying and API call if 429 code was returned API_RETRY_TIME = 5 #: Max retries count API_MAX_RETRIES = 5 #: Telegram API retry time TG_RETRY_TIME = 1 #: Max reties count in case of Telegram API connection error (None is #: unlimited) TG_MAX_RETRIES = None #: Welcome message template WELCOME_MSG_TEMPLATE = ( "I'm an (unofficial) Odesli Bot. You can send me a link to a song on " 'any supported music streaming platform and I will reply with links ' 'from all the other platforms. I work in group chats as well. In a ' 'group chat I will also try to delete original message so that the ' 'chat remains tidy (you must promote me to admin to enable this).\n' '\n' '<b>Supported platforms:</b> {supported_platforms}.\n' '\n' 'The bot is open source. More info on ' '<a href="https://github.com/9dogs/tg-odesli-bot">GitHub</a>.\n' 'Powered by a great <a href="https://odesli.co/">Odesli</a> service.') def __init__(self, config: Config = None, *, loop=None): """Initialize the bot. :param config: configuration :param loop: event loop """ #: Configuration self.config = config or Config.load() #: Logger self.logger = structlog.get_logger('tg_odesli_bot') self.logger_var = contextvars.ContextVar('logger', default=self.logger) #: Event loop self._loop = loop or asyncio.get_event_loop() #: Cache self.cache = caches.get('default') #: Telegram connect retries count self._tg_retries = 0 async def init(self): """Initialize the bot (async part).""" #: HTTP session self.session = aiohttp.ClientSession(connector=TCPConnector(limit=10)) #: aiogram bot instance self.bot = Bot(token=self.config.TG_API_TOKEN) #: Bot's dispatcher self.dispatcher = Dispatcher(self.bot) #: API ready event (used for requests throttling) self._api_ready = asyncio.Event() self._api_ready.set() # Setup logging middleware self._logging_middleware = LoggingMiddleware(self.logger_var) self.dispatcher.middleware.setup(self._logging_middleware) # Add handlers self._add_handlers() def _add_handlers(self): """Add messages and commands handlers.""" self.dispatcher.message_handler(commands=['start', 'help'])( self.send_welcome) self.dispatcher.message_handler()(self.handle_message) self.dispatcher.inline_handler()(self.handle_inline_query) async def send_welcome(self, message: types.Message): """Send a welcome message. :param message: incoming message """ _logger = self.logger_var.get() _logger.debug('Sending a welcome message') supported_platforms = [] for platform in PLATFORMS.values(): supported_platforms.append(platform.name) welcome_msg = self.WELCOME_MSG_TEMPLATE.format( supported_platforms=' | '.join(supported_platforms)) await message.reply(text=welcome_msg, parse_mode='HTML', reply=False) def _replace_urls_with_footnotes(self, message: str, song_infos: Tuple[SongInfo, ...]) -> str: """Replace song URLs in message with footnotes. E.g. "this song is awesome <link>" will be transformed to "this song is awesome [1]". :param message: original message text :param song_infos: list of SongInfo metadata objects :return: transformed message """ # Check if message consists only of a song URL and return empty string # if so _test_message = message for song_info in song_infos: for url in song_info.urls_in_text: _test_message = _test_message.replace(url, '') if not _test_message.strip(): return '' # Else replace song URLs with [1], [2] etc for index, song_info in enumerate(song_infos, start=1): for url in song_info.urls_in_text: message = message.replace(url, f'[{index}]') return message def extract_song_urls(self, text: str) -> List[SongUrl]: """Extract song URLs from text for each registered platform. :param text: message text :return: list of SongURLs """ urls = [] for platform_key, platform in PLATFORMS.items(): for match in platform.url_re.finditer(text): platform_url = SongUrl( platform_key=platform_key, platform_name=platform.name, url=match.group(0), ) urls.append(platform_url) return urls def _merge_same_songs( self, song_infos: Tuple[SongInfo, ...]) -> Tuple[SongInfo, ...]: """Merge SongInfo objects if two or more links point to the same song. Use identifiers provided by Odesli API to find identical song even though they can be linked from different platforms. :param song_infos: tuple of SongInfo objects found in a message :return: tuple of merged SongInfo objects """ merged_song_info_indexes: Set[int] = set() for idx1, song_info1 in enumerate(song_infos): # Skip empty SongInfos if not song_info1.ids: continue if idx1 in merged_song_info_indexes: continue ids1 = song_info1.ids for idx2, song_info2 in enumerate(song_infos): if (song_info2 is song_info1 or idx2 in merged_song_info_indexes or not song_info2.ids): continue ids2 = song_info2.ids if ids1 & ids2: song_info1.ids = ids1 | ids2 if song_info1.urls and song_info2.urls: song_info1.urls = { **song_info1.urls, **song_info2.urls, } song_info1.urls_in_text = (song_info1.urls_in_text | song_info2.urls_in_text) merged_song_info_indexes.add(idx2) merged_song_infos = tuple(song_info for idx, song_info in enumerate(song_infos) if idx not in merged_song_info_indexes) return merged_song_infos async def _find_songs(self, text: str) -> Tuple[SongInfo, ...]: """Find song info based on given text. :param text: message text :return: tuple of SongInfo instances :raise NotFound: if no song info found in message or via Odesli API """ # Extract song URLs from the message song_urls = self.extract_song_urls(text) if not song_urls: raise NotFound('No song URLs found in message') # Get songs information by its URLs via Odesli service API song_infos = await asyncio.gather( *[self.find_song_by_url(song_url) for song_url in song_urls]) # Do not reply to the message if all song infos are empty if not any(song_info.ids for song_info in song_infos): raise NotFound('Cannot find info for any song') # Merge song infos if different platform links point to the same song song_infos = self._merge_same_songs(tuple(song_infos)) return song_infos def _format_urls(self, song_info: SongInfo, separator: str = ' | ') -> Tuple[str, str]: """Format platform URLs into a single HTML string. :param song_info: SongInfo metadata :param separator: separator for platform URLs :return: HTML string e.g. <a href="1">Deezer</a> | <a href="2">Google Music</a> ... """ platform_urls = song_info.urls or {} reply_urls, platform_names = [], [] for platform_name, url in platform_urls.items(): reply_urls.append(f'<a href="{url}">{platform_name}</a>') platform_names.append(platform_name) formatted_urls = separator.join(reply_urls) formatted_platforms = separator.join(platform_names) return formatted_urls, formatted_platforms def _compose_reply( self, song_infos: Tuple[SongInfo, ...], message_text: str, message: Message, append_index: bool, ) -> str: """Compose a reply. For group chats original message is included in reply with song URLs replaces with its indexes. If original message consists only of a single link the index is omitted. <b>@test_user wrote:</b> check this one [1] 1. Artist - Song <a href="url1">Deezer</a> ... :param song_infos: list of songs metadata :param message: incoming message :param message_text: incoming message text with song URLs replaced with its indexes :param append_index: append index :return: reply text """ # Quote the original message for group chats if message.chat.type != ChatType.PRIVATE: reply_list = [ f'<b>@{message.from_user.username} wrote:</b> {message_text}' ] else: reply_list = [message_text] for index, song_info in enumerate(song_infos, start=1): # Use original URL if we failed to find that song via Odesli API if not song_info.ids: urls_in_text = song_info.urls_in_text.pop() reply_list.append(f'{index}. {urls_in_text}') continue if append_index: reply_list.append( f'{index}. {song_info.artist} - {song_info.title}') else: reply_list.append(f'{song_info.artist} - {song_info.title}') platform_urls, __ = self._format_urls(song_info) reply_list.append(platform_urls) reply = '\n'.join(reply_list).strip() return reply async def handle_inline_query(self, inline_query: InlineQuery): """Handle inline query. :param inline_query: query """ logger = self.logger_var.get() query = inline_query.query logger.info('Inline request', query=query) if not query: await self.bot.answer_inline_query(inline_query.id, results=[]) return try: song_infos = await self._find_songs(query) except NotFound: await self.bot.answer_inline_query(inline_query.id, results=[]) return articles = [] for song_info in song_infos: # Use hashed concatenated IDs as a result id id_ = ''.join(song_info.ids) result_id = hashlib.md5(id_.encode()).hexdigest() title = f'{song_info.artist} - {song_info.title}' platform_urls, platform_names = self._format_urls(song_info) reply_text = f'{title}\n{platform_urls}' reply = InputTextMessageContent(reply_text, parse_mode='HTML') article = InlineQueryResultArticle( id=result_id, title=title, input_message_content=reply, thumb_url=song_info.thumbnail_url, description=platform_names, ) articles.append(article) await self.bot.answer_inline_query(inline_query.id, results=articles) async def handle_message(self, message: types.Message): """Handle incoming message. :param message: incoming message """ logger = self.logger_var.get() # Check if message should be processed if self.SKIP_MARK in message.text: logger.debug('Message is skipped due to skip mark') return try: song_infos = await self._find_songs(message.text) except NotFound as exc: logger.debug(exc) return # Replace original URLs in message with footnotes (e.g. [1], [2], ...) prepared_message_text = self._replace_urls_with_footnotes( message.text, song_infos) if prepared_message_text: prepared_message_text += '\n' # Compose reply text append_index = bool(len(song_infos) > 1 or prepared_message_text) reply_text = self._compose_reply( song_infos=song_infos, message_text=prepared_message_text, message=message, append_index=append_index, ) await message.reply(text=reply_text, parse_mode='HTML', reply=False) # In group chat try to delete original message if message.chat.type != ChatType.PRIVATE: try: await message.delete() except MessageCantBeDeleted as exc: logger.warning('Cannot delete message', exc_info=exc) @staticmethod def normalize_url(url): """Strip "utm_" parameters from URL. Used in caching to increase cache density. :param url: url :return: normalized URL """ parsed = urlparse(url) query_dict = parse_qs(parsed.query, keep_blank_values=True) filtered_params = { k: v for k, v in query_dict.items() if not k.startswith('utm_') } normalized_url = urlunparse([ parsed.scheme, parsed.netloc, parsed.path, parsed.params, urlencode(filtered_params, doseq=True), parsed.fragment, ]) return normalized_url async def find_song_by_url(self, song_url: SongUrl): """Make an API call to Odesli service and return song data for supported services. :param song_url: SongURL object :return: Odesli response """ logger = self.logger_var.get() # Normalize URL for cache querying normalized_url = self.normalize_url(song_url.url) params = {'url': normalized_url} if self.config.ODESLI_API_KEY: params['api_key'] = self.config.ODESLI_API_KEY logger = logger.bind(url=self.config.ODESLI_API_URL, params=params) # Create empty SongInfo to use in case of API error song_info = SongInfo(set(), None, None, None, None, {song_url.url}) _retries = 0 while _retries < self.API_MAX_RETRIES: # Try to get data from cache. Do it inside a loop in case other # task retrieves the data and sets cache cached = await self.cache.get(normalized_url) if cached: logger.debug('Returning data from cache') song_info = SongInfo( ids=cached.ids, title=cached.title, artist=cached.artist, thumbnail_url=cached.thumbnail_url, urls=cached.urls, urls_in_text={song_url.url}, ) return song_info try: # Wait if requests are being throttled if not self._api_ready.is_set(): logger.info('Waiting for the API') await self._api_ready.wait() # Query the API async with self.session.get(self.config.ODESLI_API_URL, params=params) as resp: if resp.status != HTTPStatus.OK: # Throttle requests and retry if 429 if resp.status == HTTPStatus.TOO_MANY_REQUESTS: logger.warning( 'Too many requests, retrying in %d sec', self.API_RETRY_TIME, ) # Stop all requests and wait before retry self._api_ready.clear() await asyncio.sleep(self.API_RETRY_TIME) self._api_ready.set() continue # Return empty response if API error else: logger.error('API error', status_code=resp.status) break response = await resp.json() logger.debug('Got Odesli API response', response=response) schema = ApiResponseSchema(unknown='EXCLUDE') try: data = schema.load(response) except ValidationError as exc: logger.error('Invalid response data', exc_info=exc) else: song_info = self.process_api_response( data, song_url.url) # Cache processed data await self.cache.set(normalized_url, song_info) break except ClientConnectionError as exc: _retries += 1 logger.error( 'Connection error, retrying in %d sec', self.API_RETRY_TIME, exc_info=exc, retries=_retries, ) await asyncio.sleep(self.API_RETRY_TIME) return song_info def _filter_platform_urls(self, platform_urls: dict) -> dict: """Filter and reorder platform URLs according to `PLATFORMS` registry. :param platform_urls: dictionary of {platform_key: platform_urls} :return: dictionary of filtered and ordered platform URLs """ logger = self.logger_var.get() logger = logger.bind(data=platform_urls) urls = [] for platform_key, platform in PLATFORMS.items(): if platform_key not in platform_urls: logger.info('No URL for platform in data', platform_key=platform_key) continue urls.append( (platform.order, platform.name, platform_urls[platform_key])) # Reorder platform URLs platform_urls = { name: url for order, name, url in sorted(urls, key=lambda x: x[0]) } return platform_urls def process_api_response(self, data: dict, url: str) -> SongInfo: """Process Odesli API data creating SongInfo metadata object. :param data: deserialized Odesli data :param url: original URL in message text :return: song info object """ #: Set of song identifiers ids = set() titles, artists = [], [] thumbnail_url = None for song_entity in data['songs'].values(): ids.add(song_entity['id']) titles.append(song_entity['title']) artists.append(song_entity['artist']) # Pick the first thumbnail URL if song_entity.get('thumbnail_url') and not thumbnail_url: thumbnail_url = song_entity['thumbnail_url'] platform_urls = {} for platform_key, link_entity in data['links'].items(): platform_urls[platform_key] = link_entity['url'] platform_urls = self._filter_platform_urls(platform_urls) # Pick most common title and artist titles_counter = Counter(titles) title = titles_counter.most_common(1)[0][0] artist_counter = Counter(artists) artist = artist_counter.most_common(1)[0][0] song_info = SongInfo( ids=ids, title=title, artist=artist, thumbnail_url=thumbnail_url, urls=platform_urls, urls_in_text={url}, ) return song_info async def _start(self): """Start polling. Retry if cannot connect to Telegram servers. Mimics `aiogram.executor.start_polling` functionality. """ await self.init() try: await self.dispatcher.skip_updates() self._loop.create_task(self.dispatcher.start_polling()) except ( ConnectionResetError, NetworkError, ClientConnectionError, ) as exc: self.logger.info( 'Connection error, retrying in %d sec', self.TG_RETRY_TIME, exc_info=exc, retries=self._tg_retries, ) if (self.TG_MAX_RETRIES is None or self._tg_retries < self.TG_MAX_RETRIES): self._tg_retries += 1 await asyncio.sleep(self.TG_RETRY_TIME) asyncio.create_task(self._start()) else: self.logger.info('Max retries count reached, exiting') await self.stop() self._loop.stop() else: self.logger.info('Bot started') def start(self): """Start the bot.""" self.logger.info('Starting polling...') self._loop.create_task(self._start()) try: self._loop.run_forever() except KeyboardInterrupt: self._loop.create_task(self.stop()) async def stop(self): """Stop the bot.""" self.logger.info('Stopping...') await self.cache.clear() await self.session.close() await self.bot.close()
from aiogram import Bot, Dispatcher, types, executor from aiogram import * from aiogram.types import * import config boty = Bot(token=config.token) dp = Dispatcher(boty) inline_kb_full = InlineKeyboardMarkup() inline_kb_full.add(InlineKeyboardButton('Правила чата', url='https://telegra.ph/Pravila-igry-11-09')) dp.message_handler(commands=['start']) async def start_command(message: types.Message): if message['from'].id in config.admins_id: await message.answer(f"Привет Админ!") elif message['from'].id in config.creator_id: await message.answer(f'Ку создатель ))))') else: await message.answer(f"Привет, {message['from'].first_name}!\nВас приветствует Mafia Bonds!😜\nРады видеть Вас в чате, оставайтесь с нами.🍷 Прочитайте, пожалуйста, правила чата!🤓", reply_markup=inline_kb_full) @dp.message_handler(content_types=['new_chat_members']) async def start_command(message: types.Message): if message['from'].id in config.admins_id: await message.answer(f"Привет Админ!") elif message['from'].id in config.creator_id: await message.answer(f'Ку создатель ))))') else: await message.answer(f"Привет, {message['from'].first_name}!\nДобро пожаловать в Mafia Bonds!", reply_markup=inline_kb_full)
if cont: await Install.next() else: if install not in ["backend", "frontend", "all"] and states[pos + skip + 1] == "api_domain": skip += 3 for _ in range(skip + 1): await Install.next() await bot.send_message(message.chat.id, texts[pos + skip + 1]) process.__name__ = name return process pos = 0 for var, text in list(core.texts.items())[:-1]: dp.message_handler(state=getattr(Install, var))(make_func(f"process_{var}", pos)) pos += 1 @dp.message_handler(state=Install.start_bitcart) async def process_start(message: types.Message, state: FSMContext): async with state.proxy() as data: data["start_bitcart"] = core.defaults["start_bitcart"] if message.text == "default" else message.text try: await bot.send_message(message.chat.id, "Starting installation, please wait...") kwargs = {key: data[key] for key in core.texts if key in data} kwargs["print_func"] = functools.partial(print, file=open(os.devnull, "w")) kwargs.pop("onedomain_mode", None) core.connect(**kwargs) if core.verify_install_bitcart(data["start_bitcart"]): await bot.send_message(message.chat.id, "Successfully started bitcart!")
def to_dispatcher(self, bot: Bot) -> Dispatcher: dp = Dispatcher(bot) for func, args, kwargs in self._message_handlers: dp.message_handler(*args, **kwargs)(func) return dp