def main() -> None: # Logging logging.basicConfig( level=logging.DEBUG if config.DEBUG else logging.INFO, format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", ) # Bot bot = Bot(token=os.environ.get("TOKEN")) dp = Dispatcher(bot, storage=MemoryStorage()) handlers.setup(dp) middleware.setup(dp) # Server app = web.Application() app.add_routes(uniforms.routes) # Run everything loop = asyncio.get_event_loop() loop.run_until_complete(dp.skip_updates()) loop.create_task(dp.start_polling()) web.run_app( app=app, host="0.0.0.0", port=config.TGBOT_PORT, )
async def on_startup(): bot = Bot(token=os.getenv("API_TOKEN"), parse_mode="Markdown") dp = Dispatcher() loop = asyncio.get_event_loop() include_routers(dp) app.state.bot = bot loop.create_task(dp.start_polling(bot))
async def main(): bot = Bot(token=settings.telegram_token) dp = Dispatcher(bot, storage=MemoryStorage()) dp.middleware.setup(LoggingMiddleware()) dp.middleware.setup(AccessMiddleware(settings.telegram_chat_id)) register_handlers_notify(dp) register_handlers_bonds(dp) await set_commands(bot) notify_listener = RedisListener() try: await asyncio.gather(dp.start_polling(), notify_listener.start(bot=bot, queue=settings.redis_notification_queue)) finally: await dp.storage.close() await dp.storage.wait_closed() await bot.close()
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()
icon = "http://openweathermap.org/img/wn/" + ( r["weather"][0]["icon"]) + "@4x.png" emoji2 = "" if r["main"]["feels_like"] < 0: emoji2 = "🤧" elif r["main"]["feels_like"] <= 0 and r["main"]["feels_like"] < r["main"][ "temp"]: emoji2 = "😟" elif r["main"]["feels_like"] > r["main"]["temp"]: emoji2 = "😌" id = "1" warmIcon = "https://memepedia.ru/wp-content/uploads/2018/07/cover-3-1.jpg" coldIcon = "https://risovach.ru/upload/2016/01/mem/kot_102256910_orig_.jpg" title = "Температура в місті " + city + ": " + temperature + emoji inputTextMessageContent = InputTextMessageContent( "Зараз температура у місті " + city + " : " + temperature + emoji + description + feelsLike + emoji2 + tempMin + tempMax + pressure + humidity + windSpeed + windDeg + visibility) item = InlineQueryResultArticle( id=id, title=title, input_message_content=inputTextMessageContent, thumb_url=icon, description="Твій персональний бот-синоптик") await bot.answer_inline_query(inlineQuery.id, results=[item], cache_time=1) asyncio.run(dispatcher.start_polling())
class TelegramBotService: def __init__( self, redis_host, redis_port, redis_db, redis_password, config: TelegramBotServiceConfig = default_telegram_bot_service_config, loop: Optional[asyncio.AbstractEventLoop] = None): self._config = config self.loop = loop or asyncio.get_event_loop() self.redis_host = redis_host self.redis_port = int(redis_port) self.redis_db = int(redis_db) self.redis_password = redis_password self._storage = RedisStorage(host=self.redis_host, port=self.redis_port, db=self.redis_db, password=self.redis_password) self._bot = Bot(token=self._config.token, proxy=self._config.proxy, loop=self.loop) self._dispatcher = Dispatcher(self._bot, loop=self.loop, storage=self._storage) self._executor = Executor(self._dispatcher, skip_updates=True, loop=self.loop) async def run_bot_task(self): logger.info('Bot polling started') self._dispatcher.register_message_handler(self._bot_start, commands=['start']) self._dispatcher.register_message_handler(self._show_menu, commands=['menu'], state='*') self._dispatcher.register_message_handler(self._registration_step_1, state=Registration.step_1) self._dispatcher.register_callback_query_handler(self._show_links, text='links') self._dispatcher.register_callback_query_handler(self._book, text='book') # self._dispatcher.register_callback_query_handler(self._edit_order, text='show_orders') self._dispatcher.register_callback_query_handler(self._book_step_1, state=Book.step_1) self._dispatcher.register_callback_query_handler(self._book_step_2_1, text='done', state=Book.step_2) self._dispatcher.register_callback_query_handler(self._book_step_2_2, text='cancel', state=Book.step_2) self._executor._prepare_polling() await self._executor._startup_polling() self.loop.create_task( self._dispatcher.start_polling(reset_webhook=True)) async def _bot_start(self, message: Message): telegram_id = message.chat.id check_admin = await Admin.query.where( Admin.telegram_id == str(telegram_id)).gino.first() if check_admin: await message.answer(f'Здравствуй, {check_admin.name}') await self._bot.send_message( telegram_id, 'Введите команду /menu посмотреть список доступных функций') return check_user = await User.query.where( User.telegram_id == str(telegram_id)).gino.first() if check_user: await message.answer(f'Здравствуйте, {check_user.name}') await self._bot.send_message( telegram_id, 'Введите команду /menu посмотреть список доступных функций') if all([(not check_user), (not check_admin)]): await message.answer( 'Здравствуйте, мы с Вами не знакомы. \n' 'Введите ваше ФИО, номер телефона, рост и вес.') example_registration = ' '.join([ 'Пример:', 'Иванов Иван Иванович', '+79020007126', '175', '80' ]) await self._bot.send_message(chat_id=telegram_id, text=example_registration) await Registration.step_1.set() async def _show_menu(self, message: Message): telegram_id = message.chat.id state = self._dispatcher.current_state(user=telegram_id) await state.reset_state() check_admin = await Admin.query.where( Admin.telegram_id == str(telegram_id)).gino.first() if check_admin: inline_menu = await get_kb_menu_for_admin() await message.answer( 'Здравствуйте, админстратор. Выберите интересующий вас пункт из меню ниже:', reply_markup=inline_menu) check_customer = await User.query.where( User.telegram_id == str(telegram_id)).gino.first() if check_customer: inline_menu = await get_kb_menu_for_customer() await message.answer( 'Выберите интересующий вас пункт из меню ниже:', reply_markup=inline_menu) if all([(not check_customer), (not check_admin)]): await message.answer( 'Здравствуйте, мы с Вами не знакомы. \n' 'Введите ваше ФИО, номер телефона, рост и вес.') example_registration = ' '.join([ 'Пример:', 'Иванов Иван Иванович', '+79020007126', '175', '80' ]) await self._bot.send_message(chat_id=telegram_id, text=example_registration) await Registration.step_1.set() # async def _edit_order(self, callback_query: CallbackQuery): # telegram_id = callback_query.id # # await callback_query.answer(cache_time=60) # await self._bot.answer_callback_query(callback_query.id) # await callback_query.message.edit_reply_markup(reply_markup=None) # # inline_menu = await get_kb_orders_menu() # await self._bot.send_message(telegram_id, 'Выберите интересующую вас заявку:', # reply_markup=inline_menu) async def _registration_step_1(self, message: Message, state: FSMContext): telegram_id = message.chat.id message_user = message.text.split(' ') if len(message_user) != 6: await message.answer('Введите как показано в примере') return name_message_user = message_user[0] + ' ' + message_user[ 1] + ' ' + message_user[2] phone_number_message_user = message_user[3] height_message_user = message_user[4] weight_message_user = message_user[5] new_user = await User.create(name=name_message_user, phone_number=phone_number_message_user, height=height_message_user, weight=weight_message_user, telegram_id=str(telegram_id)) result = f""" Данные успешно сохранены! Ваше ФИО: {new_user.name} Ваш номер телефона: {new_user.phone_number} Ваш вес: {new_user.weight} кг Ваш рост: {new_user.height} см""" await self._bot.send_message(telegram_id, result) await self._bot.send_message( telegram_id, 'Введите команду /menu посмотреть список доступных функций') await state.finish() async def _show_links(self, callback_query: CallbackQuery): telegram_id = callback_query.from_user.id await callback_query.answer(cache_time=60) await self._bot.answer_callback_query(callback_query.id) await callback_query.message.edit_reply_markup(reply_markup=None) inline_kb = await get_kb_out_links() await self._bot.send_message(telegram_id, 'Мы в соцсетях!', reply_markup=inline_kb) async def _book(self, callback_query: CallbackQuery): telegram_id = callback_query.from_user.id await callback_query.answer(cache_time=60) await self._bot.answer_callback_query(callback_query.id) await callback_query.message.edit_reply_markup(reply_markup=None) inline_kb = await get_kb_items_to_book() await self._bot.send_message(telegram_id, 'Выберите интересующий вас инвентарь:', reply_markup=inline_kb) await Book.step_1.set() async def _book_step_1(self, callback_query: CallbackQuery): call = callback_query.data telegram_id = callback_query.from_user.id await callback_query.answer(cache_time=60) await self._bot.answer_callback_query(callback_query.id) await callback_query.message.edit_reply_markup(reply_markup=None) inline_kb = await get_kb_order() item_data = await Item.query.where(Item.data == str(call)).gino.first() item_name = item_data.name item_price = item_data.price await Order.create(telegram_id=str(telegram_id), ordered_item=item_name, status='in treatment') await self._bot.send_message( telegram_id, f'Вы выбрали: {item_name}.\n' f'Стоимость бронирования этого инвентаря: {item_price}.\n' f'Хотите подать заявку?', reply_markup=inline_kb) await Book.step_2.set() async def _book_step_2_1(self, callback_query: CallbackQuery, state: FSMContext): telegram_id = callback_query.from_user.id await callback_query.answer(cache_time=60) await self._bot.answer_callback_query(callback_query.id) await callback_query.message.edit_reply_markup(reply_markup=None) right_order = await Order.query.where( Order.telegram_id == str(telegram_id)).gino.first() user_data = await User.query.where(User.telegram_id == str(telegram_id) ).gino.first() await self._bot.send_message(telegram_id, 'Заявка подана. Ожидайте звонка!') order_text = f""" Поступила заявка. Информация о заказчике: Имя: {user_data.name}. Номер телефона: {user_data.phone_number}. Заявка на следующий инвентарь: {right_order.ordered_item}. Заказчик ждет вашего звонка!""" await self._bot.send_message(227448700, order_text) await state.finish() async def _book_step_2_2(self, callback_query: CallbackQuery, state: FSMContext): telegram_id = callback_query.from_user.id await callback_query.answer(cache_time=60) await self._bot.answer_callback_query(callback_query.id) await callback_query.message.edit_reply_markup(reply_markup=None) await self._bot.send_message(telegram_id, 'Заявка сброшена.') await self._bot.send_message( telegram_id, 'Введите команду /menu посмотреть список доступных функций') wrong_order = await Order.query.where( Order.telegram_id == str(telegram_id)).gino.first() wrong_order.delete() await state.finish()
async def _on_startup_polling(dp: Dispatcher, pool: Pool = init_pool()): if config.SKIP_UPDATES: await dp.skip_updates() await on_startup(dp, pool) loop = asyncio.get_event_loop() loop.create_task(dp.start_polling())
class InlineManager: def __init__(self, client, db, allmodules) -> None: """Initialize InlineManager to create forms""" self._client = client self._db = db self._allmodules = allmodules self._token = db.get("geektg.inline", "bot_token", False) self._forms = {} self._galleries = {} self._custom_map = {} self.fsm = {} self._markup_ttl = 60 * 60 * 24 self.init_complete = False def ss(self, user: Union[str, int], state: Union[str, bool]) -> bool: if not isinstance(user, (str, int)): logger.error( f"Invalid type for `user` in `ss` (expected `str or int` got `{type(user)}`)" ) return False if not isinstance(state, (str, bool)): logger.error( f"Invalid type for `state` in `ss` (expected `str or bool` got `{type(state)}`)" ) return False if state: self.fsm[str(user)] = state elif str(user) in self.fsm: del self.fsm[str(user)] return True def gs(self, user: Union[str, int]) -> Union[bool, str]: if not isinstance(user, (str, int)): logger.error( f"Invalid type for `user` in `gs` (expected `str or int` got `{type(user)}`)" ) return False return self.fsm.get(str(user), False) def check_inline_security(self, func, user): """Checks if user with id `user` is allowed to run function `func`""" allow = user in [ self._me ] + self._client.dispatcher.security._owner # skipcq: PYL-W0212 if not hasattr(func, "__doc__") or not func.__doc__ or allow: return allow doc = func.__doc__ for line in doc.splitlines(): line = line.strip() if line.startswith("@allow:"): allow_line = line.split(":")[1].strip() # First we check for possible group limits # like `sudo`, `support`, `all`. Then check # for the occurrence of user in overall string # This allows dev to use any delimiter he wants if ("all" in allow_line or "sudo" in allow_line and user in self._client.dispatcher.security._sudo or "support" in allow_line and user in self._client.dispatcher.security._support or str(user) in allow_line): allow = True # But don't hurry to return value, we need to check, # if there are any limits for line in doc.splitlines(): line = line.strip() if line.startswith("@restrict:"): restrict = line.split(":")[1].strip() if ("all" in restrict or "sudo" in restrict and user in self._client.dispatcher.security._sudo or "support" in restrict and user in self._client.dispatcher.security._support or str(user) in restrict): allow = True return allow async def _create_bot(self) -> None: # This is called outside of conversation, so we can start the new one # We create new bot logger.info("User don't have bot, attempting creating new one") async with self._client.conversation("@BotFather", exclusive=False) as conv: m = await conv.send_message("/newbot") r = await conv.get_response() if "20" in r.raw_text: return False await m.delete() await r.delete() # Set its name to user's name + GeekTG Userbot m = await conv.send_message(f"🤖 GeekTG Userbot of {self._name}") r = await conv.get_response() await m.delete() await r.delete() # Generate and set random username for bot uid = rand(6) username = f"GeekTG_{uid}_Bot" m = await conv.send_message(username) r = await conv.get_response() await m.delete() await r.delete() # Set bot profile pic m = await conv.send_message("/setuserpic") r = await conv.get_response() await m.delete() await r.delete() m = await conv.send_message(username) r = await conv.get_response() await m.delete() await r.delete() try: m = await conv.send_file(photo) r = await conv.get_response() except Exception: # In case user was not able to send photo to # BotFather, it is not a critical issue, so # just ignore it m = await conv.send_message("/cancel") r = await conv.get_response() await m.delete() await r.delete() # Re-attempt search. If it won't find newly created (or not created?) bot # it will return `False`, that's why `init_complete` will be `False` return await self._assert_token(False) async def _assert_token(self, create_new_if_needed=True, revoke_token=False) -> None: # If the token is set in db if self._token: # Just return `True` return True logger.info( "Bot token not found in db, attempting search in BotFather") # Start conversation with BotFather to attempt search async with self._client.conversation("@BotFather", exclusive=False) as conv: # Wrap it in try-except in case user banned BotFather try: # Try sending command m = await conv.send_message("/token") except YouBlockedUserError: # If user banned BotFather, unban him await self._client(UnblockRequest(id="@BotFather")) # And resend message m = await conv.send_message("/token") r = await conv.get_response() await m.delete() await r.delete() # User do not have any bots yet, so just create new one if not hasattr(r, "reply_markup") or not hasattr( r.reply_markup, "rows"): # Cancel current conversation (search) # bc we don't need it anymore await conv.cancel_all() return await self._create_bot( ) if create_new_if_needed else False for row in r.reply_markup.rows: for button in row.buttons: if re.search(r"@geektg_[0-9a-zA-Z]{6}_bot", button.text, re.I): m = await conv.send_message(button.text) r = await conv.get_response() if revoke_token: await m.delete() await r.delete() m = await conv.send_message("/revoke") r = await conv.get_response() await m.delete() await r.delete() m = await conv.send_message(button.text) r = await conv.get_response() token = r.raw_text.splitlines()[1] # Save token to database, now this bot is ready-to-use self._db.set("geektg.inline", "bot_token", token) self._token = token await m.delete() await r.delete() # Enable inline mode or change its # placeholder in case it is not set m = await conv.send_message("/setinline") r = await conv.get_response() await m.delete() await r.delete() m = await conv.send_message(button.text) r = await conv.get_response() await m.delete() await r.delete() m = await conv.send_message("GeekQuery") r = await conv.get_response() await m.delete() await r.delete() m = await conv.send_message("/setinlinefeedback") r = await conv.get_response() await m.delete() await r.delete() m = await conv.send_message(button.text) r = await conv.get_response() await m.delete() await r.delete() m = await conv.send_message("Enabled") r = await conv.get_response() await m.delete() await r.delete() # Set bot profile pic m = await conv.send_message("/setuserpic") r = await conv.get_response() await m.delete() await r.delete() m = await conv.send_message(button.text) r = await conv.get_response() await m.delete() await r.delete() m = await conv.send_file(photo) r = await conv.get_response() await m.delete() await r.delete() # Return `True` to say, that everything is okay return True # And we are not returned after creation return await self._create_bot() if create_new_if_needed else False async def _cleaner(self) -> None: """Cleans outdated _forms""" while True: for form_uid, form in self._forms.copy().items(): if form["ttl"] < time.time(): del self._forms[form_uid] await asyncio.sleep(10) async def _reassert_token(self) -> None: is_token_asserted = await self._assert_token(revoke_token=True) if not is_token_asserted: self.init_complete = False else: await self._register_manager(ignore_token_checks=True) async def _dp_revoke_token(self, inited=True) -> None: if inited: await self._stop() logger.error( "Got polling conflict. Attempting token revocation...") self._db.set("geektg.inline", "bot_token", None) self._token = None if inited: asyncio.ensure_future(self._reassert_token()) else: return await self._reassert_token() async def _register_manager(self, after_break=False, ignore_token_checks=False) -> None: # Get info about user to use it in this class me = await self._client.get_me() self._me = me.id self._name = get_display_name(me) if not ignore_token_checks: # Assert that token is set to valid, and if not, # set `init_complete` to `False` and return is_token_asserted = await self._assert_token() if not is_token_asserted: self.init_complete = False return # We successfully asserted token, so set `init_complete` to `True` self.init_complete = True # Create bot instance and dispatcher self.bot = Bot(token=self._token) self._bot = self.bot # This is a temporary alias so the # developers can adapt their code self._dp = Dispatcher(self.bot) # Get bot username to call inline queries try: self.bot_username = (await self.bot.get_me()).username self._bot_username = self.bot_username # This is a temporary alias so the # developers can adapt their code except Unauthorized: logger.critical("Token expired, revoking...") return await self._dp_revoke_token(False) # Start the bot in case it can send you messages try: m = await self._client.send_message(self.bot_username, "/start") except (InputUserDeactivatedError, ValueError): self._db.set("geektg.inline", "bot_token", None) self._token = False if not after_break: return await self._register_manager(True) self.init_complete = False return False except Exception: self.init_complete = False logger.critical("Initialization of inline manager failed!") logger.exception("due to") return False await self._client.delete_messages(self.bot_username, m) # Register required event handlers inside aiogram self._dp.register_inline_handler(self._inline_handler, lambda inline_query: True) self._dp.register_callback_query_handler(self._callback_query_handler, lambda query: True) self._dp.register_chosen_inline_handler( self._chosen_inline_handler, lambda chosen_inline_query: True) self._dp.register_message_handler(self._message_handler, lambda *args: True, content_types=["any"]) old = self.bot.get_updates revoke = self._dp_revoke_token async def new(*args, **kwargs): nonlocal revoke, old try: return await old(*args, **kwargs) except aiogram.utils.exceptions.TerminatedByOtherGetUpdates: await revoke() except aiogram.utils.exceptions.Unauthorized: logger.critical("Got Unauthorized") await self._stop() self.bot.get_updates = new # Start polling as the separate task, just in case we will need # to force stop this coro. It should be cancelled only by `stop` # because it stops the bot from getting updates self._task = asyncio.ensure_future(self._dp.start_polling()) self._cleaner_task = asyncio.ensure_future(self._cleaner()) async def _message_handler(self, message: AiogramMessage) -> None: """Processes incoming messages""" if message.chat.type != "private": return for mod in self._allmodules.modules: if not hasattr(mod, "aiogram_watcher"): continue setattr(message, "answer", functools.partial(answer, mod=self, message=message)) try: await mod.aiogram_watcher(message) except BaseException: logger.exception("Error on running aiogram watcher!") async def _stop(self) -> None: self._task.cancel() self._dp.stop_polling() self._cleaner_task.cancel() def _generate_markup(self, form_uid: Union[str, list]) -> InlineKeyboardMarkup: """Generate markup for form""" markup = InlineKeyboardMarkup() for row in (self._forms[form_uid]["buttons"] if isinstance( form_uid, str) else form_uid): for button in row: if "callback" in button and "_callback_data" not in button: button["_callback_data"] = rand(30) if "input" in button and "_switch_query" not in button: button["_switch_query"] = rand(10) for row in (self._forms[form_uid]["buttons"] if isinstance( form_uid, str) else form_uid): line = [] for button in row: try: if "url" in button: line += [ InlineKeyboardButton(button["text"], url=button.get("url", None)) ] elif "callback" in button: line += [ InlineKeyboardButton( button["text"], callback_data=button["_callback_data"]) ] elif "input" in button: line += [ InlineKeyboardButton( button["text"], switch_inline_query_current_chat=button[ "_switch_query"] + " ", ) ] elif "data" in button: line += [ InlineKeyboardButton(button["text"], callback_data=button["data"]) ] else: logger.warning("Button have not been added to " "form, because it is not structured " f"properly. {button}") except KeyError: logger.exception( "Error while forming markup! Probably, you " "passed wrong type combination for button. " "Contact developer of module.") return False markup.row(*line) return markup async def _inline_handler(self, inline_query: InlineQuery) -> None: """Inline query handler (forms' calls)""" # Retrieve query from passed object query = inline_query.query # If we didn't get any query, return help if not query: _help = "" for mod in self._allmodules.modules: if (not hasattr(mod, "inline_handlers") or not isinstance(mod.inline_handlers, dict) or not mod.inline_handlers): continue _ihandlers = dict(mod.inline_handlers.items()) for name, fun in _ihandlers.items(): # If user doesn't have enough permissions # to run this inline command, do not show it # in help if not self.check_inline_security( fun, inline_query.from_user.id): continue # Retrieve docs from func doc = utils.escape_html("\n".join([ line.strip() for line in inspect.getdoc(fun).splitlines() if not line.strip().startswith("@") ])) _help += f"🎹 <code>@{self.bot_username} {name}</code> - {doc}\n" await inline_query.answer( [ InlineQueryResultArticle( id=rand(20), title="Show available inline commands", description= f"You have {len(_help.splitlines())} available command(-s)", input_message_content=InputTextMessageContent( f"<b>ℹ️ Available inline commands:</b>\n\n{_help}", "HTML", disable_web_page_preview=True, ), thumb_url= "https://img.icons8.com/fluency/50/000000/info-squared.png", thumb_width=128, thumb_height=128, ) ], cache_time=0, ) return # First, dispatch all registered inline handlers for mod in self._allmodules.modules: if (not hasattr(mod, "inline_handlers") or not isinstance(mod.inline_handlers, dict) or not mod.inline_handlers): continue instance = GeekInlineQuery(inline_query) for query_text, query_func in mod.inline_handlers.items(): if inline_query.query.split()[0].lower( ) == query_text.lower() and self.check_inline_security( query_func, inline_query.from_user.id): try: await query_func(instance) except BaseException: logger.exception("Error on running inline watcher!") # Process forms for form in self._forms.copy().values(): for button in array_sum(form.get("buttons", [])): if ("_switch_query" in button and "input" in button and button["_switch_query"] == query.split()[0] and inline_query.from_user.id in [self._me] + self. _client.dispatcher.security._owner # skipcq: PYL-W0212 + form["always_allow"]): await inline_query.answer( [ InlineQueryResultArticle( id=rand(20), title=button["input"], description= "⚠️ Please, do not remove identifier!", input_message_content=InputTextMessageContent( "🔄 <b>Transferring value to userbot...</b>\n" "<i>This message is gonna be deleted...</i>", "HTML", disable_web_page_preview=True, ), ) ], cache_time=60, ) return # Process galleries for gallery in self._galleries.copy().values(): if (inline_query.from_user.id in [ self._me ] + self._client.dispatcher.security._owner # skipcq: PYL-W0212 + gallery["always_allow"] and query == gallery["uid"]): markup = InlineKeyboardMarkup() markup.add( InlineKeyboardButton( "Next ➡️", callback_data=gallery["btn_call_data"])) caption = gallery["caption"] caption = caption() if callable(caption) else caption await inline_query.answer( [ InlineQueryResultPhoto( id=rand(20), title="Toss a coin", photo_url=gallery["photo_url"], thumb_url=gallery["photo_url"], caption=caption, description=caption, reply_markup=markup, parse_mode="HTML", ) ], cache_time=0, ) return # If we don't know, what this query is for, just ignore it if query not in self._forms: return # Otherwise, answer it with templated form await inline_query.answer( [ InlineQueryResultArticle( id=rand(20), title="GeekTG", input_message_content=InputTextMessageContent( self._forms[query]["text"], "HTML", disable_web_page_preview=True, ), reply_markup=self._generate_markup(query), ) ], cache_time=60, ) async def _callback_query_handler( self, query: CallbackQuery, reply_markup: List[List[dict]] = None) -> None: """Callback query handler (buttons' presses)""" if reply_markup is None: reply_markup = [] # First, dispatch all registered callback handlers for mod in self._allmodules.modules: if (not hasattr(mod, "callback_handlers") or not isinstance(mod.callback_handlers, dict) or not mod.callback_handlers): continue for query_func in mod.callback_handlers.values(): if self.check_inline_security(query_func, query.from_user.id): try: await query_func(query) except Exception: logger.exception("Error on running callback watcher!") await query.answer( "Error occured while processing request. More info in logs", show_alert=True, ) return for form_uid, form in self._forms.copy().items(): for button in array_sum(form.get("buttons", [])): if button.get("_callback_data", None) == query.data: if (form["force_me"] and query.from_user.id != self._me and query.from_user.id not in self._client. dispatcher.security._owner # skipcq: PYL-W0212 and query.from_user.id not in form["always_allow"]): await query.answer( "You are not allowed to press this button!") return query.delete = functools.partial(delete, self=self, form=form, form_uid=form_uid) query.unload = functools.partial(unload, self=self, form_uid=form_uid) query.edit = functools.partial(edit, self=self, query=query, form=form, form_uid=form_uid) query.form = {"id": form_uid, **form} try: return await button["callback"]( query, *button.get("args", []), **button.get("kwargs", {}), ) except Exception: logger.exception("Error on running callback watcher!") await query.answer( "Error occurred while " "processing request. " "More info in logs", show_alert=True, ) return del self._forms[form_uid] if query.data in self._custom_map: if (self._custom_map[query.data]["force_me"] and query.from_user.id != self._me and query.from_user.id not in self._client.dispatcher. security._owner # skipcq: PYL-W0212 and query.from_user.id not in self._custom_map[query.data]["always_allow"]): await query.answer("You are not allowed to press this button!") return await self._custom_map[query.data]["handler"](query) return async def _chosen_inline_handler( self, chosen_inline_query: ChosenInlineResult) -> None: query = chosen_inline_query.query for form_uid, form in self._forms.copy().items(): for button in array_sum(form.get("buttons", [])): if ("_switch_query" in button and "input" in button and button["_switch_query"] == query.split()[0] and chosen_inline_query.from_user.id in [self._me] + self. _client.dispatcher.security._owner # skipcq: PYL-W0212 + form["always_allow"]): query = query.split( maxsplit=1)[1] if len(query.split()) > 1 else "" call = InlineCall() call.delete = functools.partial(delete, self=self, form=form, form_uid=form_uid) call.unload = functools.partial(unload, self=self, form_uid=form_uid) call.edit = functools.partial( edit, self=self, query=chosen_inline_query, form=form, form_uid=form_uid, ) try: return await button["handler"]( call, query, *button.get("args", []), **button.get("kwargs", {}), ) except Exception: logger.exception( "Exception while running chosen query watcher!") return async def form( self, text: str, message: Union[Message, int], reply_markup: List[List[dict]] = None, force_me: bool = True, always_allow: List[int] = None, ttl: Union[int, bool] = False, ) -> Union[str, bool]: """Creates inline form with callback Args: text Content of inline form. HTML markup support message Where to send inline. Can be either `Message` or `int` reply_markup List of buttons to insert in markup. List of dicts with keys: text, callback force_me Either this form buttons must be pressed only by owner scope or no always_allow Users, that are allowed to press buttons in addition to previous rules ttl Time, when the form is going to be unloaded. Unload means, that the form buttons with inline queries and callback queries will become unusable, but buttons with type url will still work as usual. Pay attention, that ttl can't be bigger, than default one (1 day) and must be either `int` or `False` """ if reply_markup is None: reply_markup = [] if always_allow is None: always_allow = [] if not isinstance(text, str): logger.error("Invalid type for `text`") return False if not isinstance(message, (Message, int)): logger.error("Invalid type for `message`") return False if not isinstance(reply_markup, list): logger.error("Invalid type for `reply_markup`") return False if not all( all(isinstance(button, dict) for button in row) for row in reply_markup): logger.error( "Invalid type for one of the buttons. It must be `dict`") return False if not all( all("url" in button or "callback" in button or "input" in button or "data" in button for button in row) for row in reply_markup): logger.error("Invalid button specified. " "Button must contain one of the following fields:\n" " - `url`\n" " - `callback`\n" " - `input`\n" " - `data`") return False if not isinstance(force_me, bool): logger.error("Invalid type for `force_me`") return False if not isinstance(always_allow, list): logger.error("Invalid type for `always_allow`") return False if not isinstance(ttl, int) and ttl: logger.error("Invalid type for `ttl`") return False if isinstance(ttl, int) and (ttl > self._markup_ttl or ttl < 10): ttl = self._markup_ttl logger.debug("Defaulted ttl, because it breaks out of limits") form_uid = rand(30) self._forms[form_uid] = { "text": text, "buttons": reply_markup, "ttl": round(time.time()) + ttl or self._markup_ttl, "force_me": force_me, "always_allow": always_allow, "chat": None, "message_id": None, "uid": form_uid, } try: q = await self._client.inline_query(self.bot_username, form_uid) m = await q[0].click( utils.get_chat_id(message) if isinstance(message, Message) else message, reply_to=message.reply_to_msg_id if isinstance( message, Message) else None, ) except Exception: msg = ("🚫 <b>A problem occurred with inline bot " "while processing query. Check logs for " "further info.</b>") del self._forms[form_uid] if isinstance(message, Message): await (message.edit if message.out else message.respond)(msg) else: await self._client.send_message(message, msg) return False self._forms[form_uid]["chat"] = utils.get_chat_id(m) self._forms[form_uid]["message_id"] = m.id if isinstance(message, Message): await message.delete() return form_uid async def gallery( self, caption: Union[str, FunctionType], message: Union[Message, int], next_handler: FunctionType, force_me: bool = False, always_allow: bool = False, ttl: int = False, ) -> Union[bool, str]: # sourcery skip: raise-specific-error """ Processes inline gallery caption Caption for photo, or callable, returning caption message Where to send inline. Can be either `Message` or `int` next_handler Callback function, which must return url for next photo force_me Either this form buttons must be pressed only by owner scope or no always_allow Users, that are allowed to press buttons in addition to previous rules ttl Time, when the form is going to be unloaded. Unload means, that the form buttons with inline queries and callback queries will become unusable, but buttons with type url will still work as usual. Pay attention, that ttl can't be bigger, than default one (1 day) and must be either `int` or `False` """ if not isinstance(caption, str) and not callable(caption): logger.error("Invalid type for `caption`") return False if not isinstance(message, (Message, int)): logger.error("Invalid type for `message`") return False if not isinstance(force_me, bool): logger.error("Invalid type for `force_me`") return False if always_allow and not isinstance(always_allow, list): logger.error("Invalid type for `always_allow`") return False if not always_allow: always_allow = [] if not isinstance(ttl, int) and ttl: logger.error("Invalid type for `ttl`") return False if isinstance(ttl, int) and (ttl > self._markup_ttl or ttl < 10): ttl = self._markup_ttl logger.debug("Defaulted ttl, because it breaks out of limits") gallery_uid = rand(30) btn_call_data = rand(16) try: photo_url = await next_handler() if not isinstance(photo_url, str): raise Exception( f"Got invalid result from `next_handler`. Expected `str`, got `{type(photo_url)}`" ) except Exception: logger.exception("Error while parsing first photo in gallery") return False self._galleries[gallery_uid] = { "caption": caption, "ttl": round(time.time()) + ttl or self._markup_ttl, "force_me": force_me, "always_allow": always_allow, "chat": None, "message_id": None, "uid": gallery_uid, "photo_url": photo_url, "next_handler": next_handler, "btn_call_data": btn_call_data, } self._custom_map[btn_call_data] = { "handler": asyncio.coroutine( functools.partial( custom_next_handler, func=next_handler, self=self, btn_call_data=btn_call_data, caption=caption, )), "always_allow": always_allow, "force_me": force_me, } try: q = await self._client.inline_query(self.bot_username, gallery_uid) m = await q[0].click( utils.get_chat_id(message) if isinstance(message, Message) else message, reply_to=message.reply_to_msg_id if isinstance( message, Message) else None, ) except Exception: msg = ("🚫 <b>A problem occurred with inline bot " "while processing query. Check logs for " "further info.</b>") del self._galleries[gallery_uid] if isinstance(message, Message): await (message.edit if message.out else message.respond)(msg) else: await self._client.send_message(message, msg) return False self._galleries[gallery_uid]["chat"] = utils.get_chat_id(m) self._galleries[gallery_uid]["message_id"] = m.id if isinstance(message, Message): await message.delete() return gallery_uid