diff --git a/.github/workflows/setup_python.yml b/.github/workflows/setup_python.yml index 5518ff2f3..670251511 100644 --- a/.github/workflows/setup_python.yml +++ b/.github/workflows/setup_python.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.9', 'pypy-3.10'] name: ${{ matrix.python-version }} and tests steps: - uses: actions/checkout@v2 diff --git a/README.md b/README.md index 10694e4b0..22be0a24c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

A simple, but extensible Python implementation for the Telegram Bot API.

Both synchronous and asynchronous.

-##

Supported Bot API version: 7.5! +##

Supported Bot API version: 7.6!

Official documentation

Official ru documentation

diff --git a/docs/source/conf.py b/docs/source/conf.py index f2b1deb6b..cfa926801 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,7 +22,7 @@ copyright = f'2022-{datetime.now().year}, {author}' # The full version, including alpha/beta/rc tags -release = '4.20.0' +release = '4.21.0' # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 75b695f44..0095c3f21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyTelegramBotAPI" -version = "4.20.0" +version = "4.21.0" description = "Python Telegram bot api." authors = [{name = "eternnoir", email = "eternnoir@gmail.com"}] license = {text = "GPL2"} diff --git a/telebot/__init__.py b/telebot/__init__.py index 8fe414252..31370d3e3 100644 --- a/telebot/__init__.py +++ b/telebot/__init__.py @@ -1830,6 +1830,9 @@ def copy_message( show_caption_above_media: Optional[bool]=None) -> types.MessageID: """ Use this method to copy messages of any kind. + Service messages, paid media messages, giveaway messages, giveaway winners messages, and invoice messages can't be copied. + A quiz poll can be copied only if the value of the field correct_option_id is known to the bot. The method is analogous to the method + forwardMessage, but the copied message doesn't have a link to the original message. Returns the MessageId of the sent message on success. Telegram documentation: https://core.telegram.org/bots/api#copymessage @@ -2007,47 +2010,47 @@ def forward_messages(self, chat_id: Union[str, int], from_chat_id: Union[str, in def copy_messages(self, chat_id: Union[str, int], from_chat_id: Union[str, int], message_ids: List[int], disable_notification: Optional[bool] = None, message_thread_id: Optional[int] = None, protect_content: Optional[bool] = None, remove_caption: Optional[bool] = None) -> List[types.MessageID]: - """ - Use this method to copy messages of any kind. If some of the specified messages can't be found or copied, they are skipped. - Service messages, giveaway messages, giveaway winners messages, and invoice messages can't be copied. - A quiz poll can be copied only if the value of the field correct_option_id is known to the bot. - The method is analogous to the method forwardMessages, but the copied messages don't have a link to the original message. - Album grouping is kept for copied messages. On success, an array of MessageId of the sent messages is returned. + """ + Use this method to copy messages of any kind. + If some of the specified messages can't be found or copied, they are skipped. Service messages, paid media messages, giveaway messages, giveaway winners messages, + and invoice messages can't be copied. A quiz poll can be copied only if the value of the field correct_option_id is known to the bot. The method is analogous + to the method forwardMessages, but the copied messages don't have a link to the original message. Album grouping is kept for copied messages. On success, an array + of MessageId of the sent messages is returned. - Telegram documentation: https://core.telegram.org/bots/api#copymessages + Telegram documentation: https://core.telegram.org/bots/api#copymessages - :param chat_id: Unique identifier for the target chat or username of the target channel (in the format @channelusername) - :type chat_id: :obj:`int` or :obj:`str` + :param chat_id: Unique identifier for the target chat or username of the target channel (in the format @channelusername) + :type chat_id: :obj:`int` or :obj:`str` - :param from_chat_id: Unique identifier for the chat where the original message was sent (or channel username in the format @channelusername) - :type from_chat_id: :obj:`int` or :obj:`str` + :param from_chat_id: Unique identifier for the chat where the original message was sent (or channel username in the format @channelusername) + :type from_chat_id: :obj:`int` or :obj:`str` - :param message_ids: Message identifiers in the chat specified in from_chat_id - :type message_ids: :obj:`list` of :obj:`int` + :param message_ids: Message identifiers in the chat specified in from_chat_id + :type message_ids: :obj:`list` of :obj:`int` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound - :type disable_notification: :obj:`bool` + :param disable_notification: Sends the message silently. Users will receive a notification with no sound + :type disable_notification: :obj:`bool` - :param message_thread_id: Identifier of a message thread, in which the messages will be sent - :type message_thread_id: :obj:`int` + :param message_thread_id: Identifier of a message thread, in which the messages will be sent + :type message_thread_id: :obj:`int` - :param protect_content: Protects the contents of the forwarded message from forwarding and saving - :type protect_content: :obj:`bool` + :param protect_content: Protects the contents of the forwarded message from forwarding and saving + :type protect_content: :obj:`bool` - :param remove_caption: Pass True to copy the messages without their captions - :type remove_caption: :obj:`bool` + :param remove_caption: Pass True to copy the messages without their captions + :type remove_caption: :obj:`bool` - :return: On success, an array of MessageId of the sent messages is returned. - :rtype: :obj:`list` of :class:`telebot.types.MessageID` - """ - disable_notification = self.disable_notification if disable_notification is None else disable_notification - protect_content = self.protect_content if protect_content is None else protect_content + :return: On success, an array of MessageId of the sent messages is returned. + :rtype: :obj:`list` of :class:`telebot.types.MessageID` + """ + disable_notification = self.disable_notification if disable_notification is None else disable_notification + protect_content = self.protect_content if protect_content is None else protect_content - result = apihelper.copy_messages( - self.token, chat_id, from_chat_id, message_ids, disable_notification=disable_notification, - message_thread_id=message_thread_id, protect_content=protect_content, remove_caption=remove_caption) - return [types.MessageID.de_json(message_id) for message_id in result] + result = apihelper.copy_messages( + self.token, chat_id, from_chat_id, message_ids, disable_notification=disable_notification, + message_thread_id=message_thread_id, protect_content=protect_content, remove_caption=remove_caption) + return [types.MessageID.de_json(message_id) for message_id in result] def send_dice( @@ -2606,6 +2609,10 @@ def send_document( logger.warning('The parameter "thumb" is deprecated. Use "thumbnail" instead.') thumbnail = thumb + if isinstance(document, types.InputFile) and visible_file_name: + # inputfile name ignored, warn + logger.warning('Cannot use both InputFile and visible_file_name. InputFile name will be ignored.') + return types.Message.de_json( apihelper.send_data( self.token, chat_id, document, 'document', @@ -3120,6 +3127,61 @@ def send_video_note( protect_content=protect_content, message_thread_id=message_thread_id, reply_parameters=reply_parameters, business_connection_id=business_connection_id, message_effect_id=message_effect_id) ) + + def send_paid_media( + self, chat_id: Union[int, str], star_count: int, media: List[types.InputPaidMedia], + caption: Optional[str]=None, parse_mode: Optional[str]=None, caption_entities: Optional[List[types.MessageEntity]]=None, + show_caption_above_media: Optional[bool]=None, disable_notification: Optional[bool]=None, + protect_content: Optional[bool]=None, reply_parameters: Optional[types.ReplyParameters]=None, + reply_markup: Optional[REPLY_MARKUP_TYPES]=None) -> types.Message: + """ + Use this method to send paid media to channel chats. On success, the sent Message is returned. + + Telegram documentation: https://core.telegram.org/bots/api#sendpaidmedia + + :param chat_id: Unique identifier for the target chat or username of the target channel (in the format @channelusername) + :type chat_id: :obj:`int` or :obj:`str` + + :param star_count: The number of Telegram Stars that must be paid to buy access to the media + :type star_count: :obj:`int` + + :param media: A JSON-serialized array describing the media to be sent; up to 10 items + :type media: :obj:`list` of :class:`telebot.types.InputPaidMedia` + + :param caption: Media caption, 0-1024 characters after entities parsing + :type caption: :obj:`str` + + :param parse_mode: Mode for parsing entities in the media caption + :type parse_mode: :obj:`str` + + :param caption_entities: List of special entities that appear in the caption, which can be specified instead of parse_mode + :type caption_entities: :obj:`list` of :class:`telebot.types.MessageEntity` + + :param show_caption_above_media: Pass True, if the caption must be shown above the message media + :type show_caption_above_media: :obj:`bool` + + :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :type disable_notification: :obj:`bool` + + :param protect_content: Protects the contents of the sent message from forwarding and saving + :type protect_content: :obj:`bool` + + :param reply_parameters: Description of the message to reply to + :type reply_parameters: :class:`telebot.types.ReplyParameters` + + :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove a reply keyboard or to force a reply from the user + :type reply_markup: :class:`telebot.types.InlineKeyboardMarkup` or :class:`telebot.types.ReplyKeyboardMarkup` or :class:`telebot.types.ReplyKeyboardRemove` or :class:`telebot.types.ForceReply` + + :return: On success, the sent Message is returned. + :rtype: :class:`telebot.types.Message` + """ + return types.Message.de_json( + apihelper.send_paid_media( + self.token, chat_id, star_count, media, caption=caption, parse_mode=parse_mode, + caption_entities=caption_entities, show_caption_above_media=show_caption_above_media, + disable_notification=disable_notification, protect_content=protect_content, + reply_parameters=reply_parameters, reply_markup=reply_markup) + ) def send_media_group( @@ -4731,7 +4793,7 @@ def edit_message_text( parse_mode=parse_mode, entities=entities, reply_markup=reply_markup, link_preview_options=link_preview_options, business_connection_id=business_connection_id, timeout=timeout) - if type(result) == bool: # if edit inline message return is bool not Message. + if isinstance(result, bool): # if edit inline message return is bool not Message. return result return types.Message.de_json(result) @@ -4778,7 +4840,7 @@ def edit_message_media( self.token, media, chat_id=chat_id, message_id=message_id, inline_message_id=inline_message_id, reply_markup=reply_markup, business_connection_id=business_connection_id, timeout=timeout) - if type(result) == bool: # if edit inline message return is bool not Message. + if isinstance(result, bool): # if edit inline message return is bool not Message. return result return types.Message.de_json(result) @@ -4820,7 +4882,7 @@ def edit_message_reply_markup( self.token, chat_id=chat_id, message_id=message_id, inline_message_id=inline_message_id, reply_markup=reply_markup, business_connection_id=business_connection_id, timeout=timeout) - if type(result) == bool: + if isinstance(result, bool): return result return types.Message.de_json(result) @@ -4954,7 +5016,7 @@ def set_game_score( self.token, user_id, score, force=force, disable_edit_message=disable_edit_message, chat_id=chat_id, message_id=message_id, inline_message_id=inline_message_id) - if type(result) == bool: + if isinstance(result, bool): return result return types.Message.de_json(result) @@ -5618,7 +5680,7 @@ def edit_message_caption( show_caption_above_media=show_caption_above_media, business_connection_id=business_connection_id, timeout=timeout) - if type(result) == bool: + if isinstance(result, bool): return result return types.Message.de_json(result) diff --git a/telebot/apihelper.py b/telebot/apihelper.py index 54ce9f691..0bcb80a96 100644 --- a/telebot/apihelper.py +++ b/telebot/apihelper.py @@ -94,7 +94,7 @@ def _make_request(token, method_name, method='get', params=None, files=None): # process types.InputFile for key, value in files_copy.items(): if isinstance(value, types.InputFile): - files[key] = value.file + files[key] = (value.file_name, value.file) elif isinstance(value, tuple) and (len(value) == 2) and isinstance(value[1], types.InputFile): files[key] = (value[0], value[1].file) @@ -525,6 +525,34 @@ def send_photo( if show_caption_above_media is not None: payload['show_caption_above_media'] = show_caption_above_media return _make_request(token, method_url, params=payload, files=files, method='post') + +def send_paid_media( + token, chat_id, star_count, media, + caption=None, parse_mode=None, caption_entities=None, show_caption_above_media=None, + disable_notification=None, protect_content=None, reply_parameters=None, reply_markup=None): + method_url = r'sendPaidMedia' + media_json, files = convert_input_media_array(media) + payload = {'chat_id': chat_id, 'star_count': star_count, 'media': media_json} + if caption: + payload['caption'] = caption + if parse_mode: + payload['parse_mode'] = parse_mode + if caption_entities: + payload['caption_entities'] = json.dumps(types.MessageEntity.to_list_of_dicts(caption_entities)) + if show_caption_above_media is not None: + payload['show_caption_above_media'] = show_caption_above_media + if disable_notification is not None: + payload['disable_notification'] = disable_notification + if protect_content is not None: + payload['protect_content'] = protect_content + if reply_parameters is not None: + payload['reply_parameters'] = reply_parameters.to_json() + if reply_markup: + payload['reply_markup'] = _convert_markup(reply_markup) + return _make_request( + token, method_url, params=payload, + method='post' if files else 'get', + files=files if files else None) def send_media_group( @@ -2117,7 +2145,7 @@ def convert_input_media_array(array): media = [] files = {} for input_media in array: - if isinstance(input_media, types.InputMedia): + if isinstance(input_media, types.InputMedia) or isinstance(input_media, types.InputPaidMedia): media_dict = input_media.to_dict() if media_dict['media'].startswith('attach://'): key = media_dict['media'].replace('attach://', '') diff --git a/telebot/async_telebot.py b/telebot/async_telebot.py index 224856416..d8dd91e05 100644 --- a/telebot/async_telebot.py +++ b/telebot/async_telebot.py @@ -246,7 +246,7 @@ def _setup_change_detector(self, path_to_watch: str) -> None: self.event_observer.schedule(self.event_handler, path, recursive=True) self.event_observer.start() - async def polling(self, non_stop: bool=False, skip_pending=False, interval: int=0, timeout: int=20, + async def polling(self, non_stop: bool=True, skip_pending=False, interval: int=0, timeout: int=20, request_timeout: Optional[int]=None, allowed_updates: Optional[List[str]]=None, none_stop: Optional[bool]=None, restart_on_change: Optional[bool]=False, path_to_watch: Optional[str]=None): """ @@ -257,11 +257,6 @@ async def polling(self, non_stop: bool=False, skip_pending=False, interval: int= Always gets updates. - .. note:: - - Set non_stop=True if you want your bot to continue receiving updates - if there is an error. - .. note:: Install watchdog and psutil before using restart_on_change option. @@ -393,6 +388,15 @@ def __hide_token(self, message: str) -> str: return message.replace(code, "*" * len(code)) else: return message + + async def _handle_error_interval(self, error_interval: float): + logger.debug('Waiting for %s seconds before retrying', error_interval) + await asyncio.sleep(error_interval) + if error_interval * 2 < 60: # same logic as sync + error_interval *= 2 + else: + error_interval = 60 + return error_interval async def _process_polling(self, non_stop: bool=False, interval: int=0, timeout: int=20, request_timeout: int=None, allowed_updates: Optional[List[str]]=None): @@ -426,16 +430,18 @@ async def _process_polling(self, non_stop: bool=False, interval: int=0, timeout: self._polling = True + error_interval = 0.25 + try: while self._polling: try: - updates = await self.get_updates(offset=self.offset, allowed_updates=allowed_updates, timeout=timeout, request_timeout=request_timeout) if updates: self.offset = updates[-1].update_id + 1 # noinspection PyAsyncCall asyncio.create_task(self.process_new_updates(updates)) # Seperate task for processing updates if interval: await asyncio.sleep(interval) + error_interval = 0.25 # drop error_interval if no errors except KeyboardInterrupt: return @@ -446,9 +452,11 @@ async def _process_polling(self, non_stop: bool=False, interval: int=0, timeout: if not handled: logger.error('Unhandled exception (full traceback for debug level): %s', self.__hide_token(str(e))) logger.debug(self.__hide_token(traceback.format_exc())) + + if non_stop: + error_interval = await self._handle_error_interval(error_interval) if non_stop or handled: - await asyncio.sleep(2) continue else: return @@ -458,6 +466,9 @@ async def _process_polling(self, non_stop: bool=False, interval: int=0, timeout: logger.error('Unhandled exception (full traceback for debug level): %s', self.__hide_token(str(e))) logger.debug(self.__hide_token(traceback.format_exc())) + if non_stop: + error_interval = await self._handle_error_interval(error_interval) + if non_stop or handled: continue else: @@ -468,6 +479,9 @@ async def _process_polling(self, non_stop: bool=False, interval: int=0, timeout: logger.error('Unhandled exception (full traceback for debug level): %s', str(e)) logger.debug(traceback.format_exc()) + if non_stop: + error_interval = await self._handle_error_interval(error_interval) + if non_stop or handled: continue else: @@ -3248,6 +3262,10 @@ async def copy_message( show_caption_above_media: Optional[bool]=None) -> types.MessageID: """ Use this method to copy messages of any kind. + If some of the specified messages can't be found or copied, they are skipped. Service messages, paid media messages, giveaway messages, giveaway winners messages, + and invoice messages can't be copied. A quiz poll can be copied only if the value of the field correct_option_id is known to the bot. The method is analogous + to the method forwardMessages, but the copied messages don't have a link to the original message. Album grouping is kept for copied messages. On success, an array + of MessageId of the sent messages is returned. Telegram documentation: https://core.telegram.org/bots/api#copymessage @@ -3415,44 +3433,43 @@ async def forward_messages(self, chat_id: Union[str, int], from_chat_id: Union[s async def copy_messages(self, chat_id: Union[str, int], from_chat_id: Union[str, int], message_ids: List[int], disable_notification: Optional[bool] = None, message_thread_id: Optional[int] = None, protect_content: Optional[bool] = None, remove_caption: Optional[bool] = None) -> List[types.MessageID]: - """ - Use this method to copy messages of any kind. If some of the specified messages can't be found or copied, they are skipped. - Service messages, giveaway messages, giveaway winners messages, and invoice messages can't be copied. A quiz poll can be copied - only if the value of the field correct_option_id is known to the bot. The method is analogous to the method forwardMessages, but - the copied messages don't have a link to the original message. Album grouping is kept for copied messages. - On success, an array of MessageId of the sent messages is returned. + """ + Use this method to copy messages of any kind. + Service messages, paid media messages, giveaway messages, giveaway winners messages, and invoice messages can't be copied. + A quiz poll can be copied only if the value of the field correct_option_id is known to the bot. The method is analogous to the method + forwardMessage, but the copied message doesn't have a link to the original message. Returns the MessageId of the sent message on success. - Telegram documentation: https://core.telegram.org/bots/api#copymessages + Telegram documentation: https://core.telegram.org/bots/api#copymessages - :param chat_id: Unique identifier for the target chat or username of the target channel (in the format @channelusername) - :type chat_id: :obj:`int` or :obj:`str` + :param chat_id: Unique identifier for the target chat or username of the target channel (in the format @channelusername) + :type chat_id: :obj:`int` or :obj:`str` - :param from_chat_id: Unique identifier for the chat where the original message was sent (or channel username in the format @channelusername) - :type from_chat_id: :obj:`int` or :obj:`str` + :param from_chat_id: Unique identifier for the chat where the original message was sent (or channel username in the format @channelusername) + :type from_chat_id: :obj:`int` or :obj:`str` - :param message_ids: Message identifiers in the chat specified in from_chat_id - :type message_ids: :obj:`list` of :obj:`int` + :param message_ids: Message identifiers in the chat specified in from_chat_id + :type message_ids: :obj:`list` of :obj:`int` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound - :type disable_notification: :obj:`bool` + :param disable_notification: Sends the message silently. Users will receive a notification with no sound + :type disable_notification: :obj:`bool` - :param message_thread_id: Identifier of a message thread, in which the messages will be sent - :type message_thread_id: :obj:`int` + :param message_thread_id: Identifier of a message thread, in which the messages will be sent + :type message_thread_id: :obj:`int` - :param protect_content: Protects the contents of the forwarded message from forwarding and saving - :type protect_content: :obj:`bool` + :param protect_content: Protects the contents of the forwarded message from forwarding and saving + :type protect_content: :obj:`bool` - :param remove_caption: Pass True to copy the messages without their captions - :type remove_caption: :obj:`bool` + :param remove_caption: Pass True to copy the messages without their captions + :type remove_caption: :obj:`bool` - :return: On success, an array of MessageId of the sent messages is returned. - :rtype: :obj:`list` of :class:`telebot.types.MessageID` - """ - disable_notification = self.disable_notification if disable_notification is None else disable_notification - protect_content = self.protect_content if protect_content is None else protect_content - result = await asyncio_helper.copy_messages(self.token, chat_id, from_chat_id, message_ids, disable_notification, message_thread_id, - protect_content, remove_caption) - return [types.MessageID.de_json(message_id) for message_id in result] + :return: On success, an array of MessageId of the sent messages is returned. + :rtype: :obj:`list` of :class:`telebot.types.MessageID` + """ + disable_notification = self.disable_notification if disable_notification is None else disable_notification + protect_content = self.protect_content if protect_content is None else protect_content + result = await asyncio_helper.copy_messages(self.token, chat_id, from_chat_id, message_ids, disable_notification, message_thread_id, + protect_content, remove_caption) + return [types.MessageID.de_json(message_id) for message_id in result] async def send_dice( self, chat_id: Union[int, str], @@ -4014,6 +4031,10 @@ async def send_document( if reply_parameters and (reply_parameters.allow_sending_without_reply is None): reply_parameters.allow_sending_without_reply = self.allow_sending_without_reply + if isinstance(document, types.InputFile) and visible_file_name: + # inputfile name ignored, warn + logger.warning('Cannot use both InputFile and visible_file_name. InputFile name will be ignored.') + return types.Message.de_json( await asyncio_helper.send_data( self.token, chat_id, document, 'document', @@ -4526,6 +4547,61 @@ async def send_video_note( self.token, chat_id, data, duration, length, reply_markup, disable_notification, timeout, thumbnail, protect_content, message_thread_id, reply_parameters, business_connection_id, message_effect_id=message_effect_id)) + async def send_paid_media( + self, chat_id: Union[int, str], star_count: int, media: List[types.InputPaidMedia], + caption: Optional[str]=None, parse_mode: Optional[str]=None, caption_entities: Optional[List[types.MessageEntity]]=None, + show_caption_above_media: Optional[bool]=None, disable_notification: Optional[bool]=None, + protect_content: Optional[bool]=None, reply_parameters: Optional[types.ReplyParameters]=None, + reply_markup: Optional[REPLY_MARKUP_TYPES]=None) -> types.Message: + """ + Use this method to send paid media to channel chats. On success, the sent Message is returned. + + Telegram documentation: https://core.telegram.org/bots/api#sendpaidmedia + + :param chat_id: Unique identifier for the target chat or username of the target channel (in the format @channelusername) + :type chat_id: :obj:`int` or :obj:`str` + + :param star_count: The number of Telegram Stars that must be paid to buy access to the media + :type star_count: :obj:`int` + + :param media: A JSON-serialized array describing the media to be sent; up to 10 items + :type media: :obj:`list` of :class:`telebot.types.InputPaidMedia` + + :param caption: Media caption, 0-1024 characters after entities parsing + :type caption: :obj:`str` + + :param parse_mode: Mode for parsing entities in the media caption + :type parse_mode: :obj:`str` + + :param caption_entities: List of special entities that appear in the caption, which can be specified instead of parse_mode + :type caption_entities: :obj:`list` of :class:`telebot.types.MessageEntity` + + :param show_caption_above_media: Pass True, if the caption must be shown above the message media + :type show_caption_above_media: :obj:`bool` + + :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :type disable_notification: :obj:`bool` + + :param protect_content: Protects the contents of the sent message from forwarding and saving + :type protect_content: :obj:`bool` + + :param reply_parameters: Description of the message to reply to + :type reply_parameters: :class:`telebot.types.ReplyParameters` + + :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove a reply keyboard or to force a reply from the user + :type reply_markup: :class:`telebot.types.InlineKeyboardMarkup` or :class:`telebot.types.ReplyKeyboardMarkup` or :class:`telebot.types.ReplyKeyboardRemove` or :class:`telebot.types.ForceReply` + + :return: On success, the sent Message is returned. + :rtype: :class:`telebot.types.Message` + """ + return types.Message.de_json( + await asyncio_helper.send_paid_media( + self.token, chat_id, star_count, media, caption=caption, parse_mode=parse_mode, + caption_entities=caption_entities, show_caption_above_media=show_caption_above_media, + disable_notification=disable_notification, protect_content=protect_content, + reply_parameters=reply_parameters, reply_markup=reply_markup) + ) + async def send_media_group( self, chat_id: Union[int, str], media: List[Union[ @@ -6087,7 +6163,7 @@ async def edit_message_text( result = await asyncio_helper.edit_message_text( self.token, text, chat_id, message_id, inline_message_id, parse_mode, entities, reply_markup, link_preview_options, business_connection_id, timeout) - if type(result) == bool: # if edit inline message return is bool not Message. + if isinstance(result, bool): # if edit inline message return is bool not Message. return result return types.Message.de_json(result) @@ -6131,7 +6207,7 @@ async def edit_message_media( """ result = await asyncio_helper.edit_message_media( self.token, media, chat_id, message_id, inline_message_id, reply_markup, business_connection_id, timeout) - if type(result) == bool: # if edit inline message return is bool not Message. + if isinstance(result, bool): # if edit inline message return is bool not Message. return result return types.Message.de_json(result) @@ -6170,7 +6246,7 @@ async def edit_message_reply_markup( """ result = await asyncio_helper.edit_message_reply_markup( self.token, chat_id, message_id, inline_message_id, reply_markup, business_connection_id, timeout) - if type(result) == bool: + if isinstance(result, bool): return result return types.Message.de_json(result) @@ -6298,7 +6374,7 @@ async def set_game_score( """ result = await asyncio_helper.set_game_score(self.token, user_id, score, force, disable_edit_message, chat_id, message_id, inline_message_id) - if type(result) == bool: + if isinstance(result, bool): return result return types.Message.de_json(result) @@ -6942,7 +7018,7 @@ async def edit_message_caption( self.token, caption, chat_id, message_id, inline_message_id, parse_mode, caption_entities, reply_markup, show_caption_above_media=show_caption_above_media, business_connection_id=business_connection_id, timeout=timeout) - if type(result) == bool: + if isinstance(result, bool): return result return types.Message.de_json(result) diff --git a/telebot/asyncio_helper.py b/telebot/asyncio_helper.py index 0a484f3b4..b70508a2a 100644 --- a/telebot/asyncio_helper.py +++ b/telebot/asyncio_helper.py @@ -130,6 +130,8 @@ def _prepare_data(params=None, files=None): if isinstance(f, tuple): if len(f) == 2: file_name, file = f + if isinstance(file, types.InputFile): + file = file.file else: raise ValueError('Tuple must have exactly 2 elements: filename, fileobj') elif isinstance(f, types.InputFile): @@ -514,6 +516,33 @@ async def send_photo( payload['show_caption_above_media'] = show_caption_above_media return await _process_request(token, method_url, params=payload, files=files, method='post') +async def send_paid_media( + token, chat_id, star_count, media, + caption=None, parse_mode=None, caption_entities=None, show_caption_above_media=None, + disable_notification=None, protect_content=None, reply_parameters=None, reply_markup=None): + method_url = r'sendPaidMedia' + media_json, files = convert_input_media_array(media) + payload = {'chat_id': chat_id, 'star_count': star_count, 'media': media_json} + if caption: + payload['caption'] = caption + if parse_mode: + payload['parse_mode'] = parse_mode + if caption_entities: + payload['caption_entities'] = json.dumps(types.MessageEntity.to_list_of_dicts(caption_entities)) + if show_caption_above_media is not None: + payload['show_caption_above_media'] = show_caption_above_media + if disable_notification is not None: + payload['disable_notification'] = disable_notification + if protect_content is not None: + payload['protect_content'] = protect_content + if reply_parameters is not None: + payload['reply_parameters'] = reply_parameters.to_json() + if reply_markup: + payload['reply_markup'] = _convert_markup(reply_markup) + return await _process_request( + token, method_url, params=payload, + method='post' if files else 'get', + files=files if files else None) async def send_media_group( token, chat_id, media, @@ -2081,7 +2110,7 @@ async def convert_input_media_array(array): media = [] files = {} for input_media in array: - if isinstance(input_media, types.InputMedia): + if isinstance(input_media, types.InputMedia) or isinstance(input_media, types.InputPaidMedia): media_dict = input_media.to_dict() if media_dict['media'].startswith('attach://'): key = media_dict['media'].replace('attach://', '') diff --git a/telebot/custom_filters.py b/telebot/custom_filters.py index 1c5bd0b40..ca91dd9b8 100644 --- a/telebot/custom_filters.py +++ b/telebot/custom_filters.py @@ -216,7 +216,7 @@ def check(self, message, text): """ if isinstance(text, TextFilter): return text.check(message) - elif type(text) is list: + elif isinstance(text, list): return message.text in text else: return text == message.text @@ -354,7 +354,7 @@ def check(self, message, text): """ :meta private: """ - if type(text) is list: + if isinstance(text, list): return message.from_user.language_code in text else: return message.from_user.language_code == text @@ -430,7 +430,6 @@ def check(self, message, text): elif type(text) is list and user_state in text: return True - class IsDigitFilter(SimpleCustomFilter): """ Filter to check whether the string is made up of only digits. diff --git a/telebot/types.py b/telebot/types.py index 5d69af0ff..d0d3819bb 100644 --- a/telebot/types.py +++ b/telebot/types.py @@ -707,6 +707,10 @@ class ChatFullInfo(JsonDeserializable): :param location: Optional. For supergroups, the location to which the supergroup is connected. Returned only in getChat. :type location: :class:`telebot.types.ChatLocation` + :param can_send_paid_media: Optional. True, if paid media messages can be sent or forwarded to the channel chat. + The field is available only for channel chats. + :type can_send_paid_media: :obj:`bool` + :return: Instance of the class :rtype: :class:`telebot.types.ChatFullInfo` """ @@ -748,7 +752,8 @@ def __init__(self, id, type, title=None, username=None, first_name=None, available_reactions=None, accent_color_id=None, background_custom_emoji_id=None, profile_accent_color_id=None, profile_background_custom_emoji_id=None, has_visible_history=None, unrestrict_boost_count=None, custom_emoji_sticker_set_name=None, business_intro=None, business_location=None, - business_opening_hours=None, personal_chat=None, birthdate=None, **kwargs): + business_opening_hours=None, personal_chat=None, birthdate=None, + can_send_paid_media=None, **kwargs): self.id: int = id self.type: str = type self.title: str = title @@ -792,6 +797,7 @@ def __init__(self, id, type, title=None, username=None, first_name=None, self.business_opening_hours: BusinessOpeningHours = business_opening_hours self.personal_chat: Chat = personal_chat self.birthdate: Birthdate = birthdate + self.can_send_paid_media: bool = can_send_paid_media class Chat(ChatFullInfo): @@ -964,6 +970,9 @@ class Message(JsonDeserializable): :param document: Optional. Message is a general file, information about the file :type document: :class:`telebot.types.Document` + :param paid_media: Optional. Message contains paid media; information about the paid media + :type paid_media: :class:`telebot.types.PaidMediaInfo` + :param photo: Optional. Message is a photo, available sizes of the photo :type photo: :obj:`list` of :class:`telebot.types.PhotoSize` @@ -1380,7 +1389,8 @@ def de_json(cls, json_string): opts['effect_id'] = obj['effect_id'] if 'show_caption_above_media' in obj: opts['show_caption_above_media'] = obj['show_caption_above_media'] - + if 'paid_media' in obj: + opts['paid_media'] = PaidMediaInfo.de_json(obj['paid_media']) return cls(message_id, from_user, date, chat, content_type, opts, json_string) @@ -1491,6 +1501,7 @@ def __init__(self, message_id, from_user, date, chat, content_type, options, jso self.is_from_offline: Optional[bool] = None self.effect_id: Optional[str] = None self.show_caption_above_media: Optional[bool] = None + self.paid_media : Optional[PaidMediaInfo] = None for key in options: setattr(self, key, options[key]) @@ -6726,7 +6737,7 @@ def to_dict(self): ret['height'] = self.height if self.duration: ret['duration'] = self.duration - if self.supports_streaming: + if self.supports_streaming is not None: ret['supports_streaming'] = self.supports_streaming if self.has_spoiler is not None: ret['has_spoiler'] = self.has_spoiler @@ -7532,7 +7543,9 @@ class MenuButtonWebApp(MenuButton): :type text: :obj:`str` :param web_app: Description of the Web App that will be launched when the user presses the button. The Web App will be - able to send an arbitrary message on behalf of the user using the method answerWebAppQuery. + able to send an arbitrary message on behalf of the user using the method answerWebAppQuery. Alternatively, a t.me link + to a Web App of the bot can be specified in the object instead of the Web App's URL, in which case the Web App will be + opened as if the user pressed the link. :type web_app: :class:`telebot.types.WebAppInfo` :return: Instance of the class @@ -7735,8 +7748,11 @@ class InputFile: InputFile(pathlib.Path('/path/to/file/file.txt')) ) """ - def __init__(self, file) -> None: - self._file, self.file_name = self._resolve_file(file) + def __init__(self, file: Union[str, IOBase, Path], file_name: Optional[str] = None): + self._file, self._file_name = self._resolve_file(file) + if file_name: + self._file_name = file_name + @staticmethod def _resolve_file(file): @@ -7757,6 +7773,13 @@ def file(self): File object. """ return self._file + + @property + def file_name(self): + """ + File name. + """ + return self._file_name class ForumTopicCreated(JsonDeserializable): @@ -8511,6 +8534,9 @@ class ExternalReplyInfo(JsonDeserializable): :param document: Optional. Message is a general file, information about the file :type document: :class:`Document` + :param paid_media: Optional. Message is a paid media content + :type paid_media: :class:`PaidMedia` + :param photo: Optional. Message is a photo, available sizes of the photo :type photo: :obj:`list` of :class:`PhotoSize` @@ -8619,7 +8645,7 @@ def __init__( dice: Optional[Dice]=None, game: Optional[Game]=None, giveaway: Optional[Giveaway]=None, giveaway_winners: Optional[GiveawayWinners]=None, invoice: Optional[Invoice]=None, location: Optional[Location]=None, poll: Optional[Poll]=None, - venue: Optional[Venue]=None, **kwargs) -> None: + venue: Optional[Venue]=None, paid_media: Optional[PaidMediaInfo]=None, **kwargs) -> None: self.origin: MessageOrigin = origin self.chat: Optional[Chat] = chat self.message_id: Optional[int] = message_id @@ -8643,6 +8669,7 @@ def __init__( self.location: Optional[Location] = location self.poll: Optional[Poll] = poll self.venue: Optional[Venue] = venue + self.paid_media: Optional[PaidMediaInfo] = paid_media # noinspection PyUnresolvedReferences,PyShadowingBuiltins @@ -10209,6 +10236,8 @@ def de_json(cls, json_string): return TransactionPartnerFragment.de_json(obj) elif obj["type"] == "user": return TransactionPartnerUser.de_json(obj) + elif obj["type"] == "telegram_ads": + return TransactionPartnerTelegramAds.de_json(obj) elif obj["type"] == "other": return TransactionPartnerOther.de_json(obj) @@ -10255,13 +10284,17 @@ class TransactionPartnerUser(TransactionPartner): :param user: Information about the user :type user: :class:`User` + :param invoice_payload: Optional, Bot-specified invoice payload + :type invoice_payload: :obj:`str` + :return: Instance of the class :rtype: :class:`TransactionPartnerUser` """ - def __init__(self, type, user, **kwargs): + def __init__(self, type, user, invoice_payload=None, **kwargs): self.type: str = type self.user: User = user + self.invoice_payload: Optional[str] = invoice_payload @classmethod def de_json(cls, json_string): @@ -10270,6 +10303,27 @@ def de_json(cls, json_string): obj['user'] = User.de_json(obj['user']) return cls(**obj) +class TransactionPartnerTelegramAds(TransactionPartner): + """ + Describes a transaction with Telegram Ads. + + Telegram documentation: https://core.telegram.org/bots/api#transactionpartnertelegramads + + :param type: Type of the transaction partner, always “telegram_ads” + :type type: :obj:`str` + + :return: Instance of the class + :rtype: :class:`TransactionPartnerTelegramAds` + """ + + def __init__(self, type, **kwargs): + self.type: str = type + + @classmethod + def de_json(cls, json_string): + if json_string is None: return None + obj = cls.check_json(json_string) + class TransactionPartnerOther(TransactionPartner): """ @@ -10361,3 +10415,266 @@ def de_json(cls, json_string): def __init__(self, transactions, **kwargs): self.transactions: List[StarTransaction] = transactions + + +class PaidMedia(JsonDeserializable): + """ + This object describes paid media. Currently, it can be one of + + PaidMediaPreview + PaidMediaPhoto + PaidMediaVideo + + Telegram documentation: https://core.telegram.org/bots/api#paidmedia + + :return: Instance of the class + :rtype: :class:`PaidMediaPreview` or :class:`PaidMediaPhoto` or :class:`PaidMediaVideo` + """ + + @classmethod + def de_json(cls, json_string): + if json_string is None: return None + obj = cls.check_json(json_string) + if obj["type"] == "preview": + return PaidMediaPreview.de_json(obj) + elif obj["type"] == "photo": + return PaidMediaPhoto.de_json(obj) + elif obj["type"] == "video": + return PaidMediaVideo.de_json(obj) + +class PaidMediaPreview(PaidMedia): + """ + The paid media isn't available before the payment. + + Telegram documentation: https://core.telegram.org/bots/api#paidmediapreview + + :param type: Type of the paid media, always “preview” + :type type: :obj:`str` + + :param width: Optional. Media width as defined by the sender + :type width: :obj:`int` + + :param height: Optional. Media height as defined by the sender + :type height: :obj:`int` + + :param duration: Optional. Duration of the media in seconds as defined by the sender + :type duration: :obj:`int` + + :return: Instance of the class + :rtype: :class:`PaidMediaPreview` + """ + + def __init__(self, type, width=None, height=None, duration=None, **kwargs): + self.type: str = type + self.width: Optional[int] = width + self.height: Optional[int] = height + self.duration: Optional[int] = duration + + @classmethod + def de_json(cls, json_string): + if json_string is None: return None + obj = cls.check_json(json_string) + return cls(**obj) + + +class PaidMediaPhoto(PaidMedia): + """ + The paid media is a photo. + + Telegram documentation: https://core.telegram.org/bots/api#paidmediaphoto + + :param type: Type of the paid media, always “photo” + :type type: :obj:`str` + + :param photo: The photo + :type photo: :obj:`list` of :class:`PhotoSize` + + :return: Instance of the class + :rtype: :class:`PaidMediaPhoto` + + """ + + def __init__(self, type, photo, **kwargs): + self.type: str = type + self.photo: List[PhotoSize] = photo + + @classmethod + def de_json(cls, json_string): + if json_string is None: return None + obj = cls.check_json(json_string) + + obj['photo'] = [PhotoSize.de_json(photo) for photo in obj['photo']] + return cls(**obj) + + +class PaidMediaVideo(PaidMedia): + """ + The paid media is a video. + + Telegram documentation: https://core.telegram.org/bots/api#paidmediavideo + + :param type: Type of the paid media, always “video” + :type type: :obj:`str` + + :param video: The video + :type video: :class:`Video` + + :return: Instance of the class + :rtype: :class:`PaidMediaVideo` + """ + + def __init__(self, type, video, **kwargs): + self.type: str = type + self.video: Video = video + + @classmethod + def de_json(cls, json_string): + if json_string is None: return None + obj = cls.check_json(json_string) + obj['video'] = Video.de_json(obj['video']) + return cls(**obj) + + +class PaidMediaInfo(JsonDeserializable): + """ + Describes the paid media added to a message. + + Telegram documentation: https://core.telegram.org/bots/api#paidmediainfo + + :param star_count: The number of Telegram Stars that must be paid to buy access to the media + :type star_count: :obj:`int` + + :param paid_media: Information about the paid media + :type paid_media: :obj:`list` of :class:`PaidMedia` + + :return: Instance of the class + :rtype: :class:`PaidMediaInfo` + """ + + @classmethod + def de_json(cls, json_string): + if json_string is None: return None + obj = cls.check_json(json_string) + obj['paid_media'] = [PaidMedia.de_json(media) for media in obj['paid_media']] + return cls(**obj) + + def __init__(self, star_count, paid_media, **kwargs): + self.star_count: int = star_count + self.paid_media: List[PaidMedia] = paid_media + + +class InputPaidMedia(JsonSerializable): + """ + This object describes the paid media to be sent. Currently, it can be one of + InputPaidMediaPhoto + InputPaidMediaVideo + + Telegram documentation: https://core.telegram.org/bots/api#inputpaidmedia + + :return: Instance of the class + :rtype: :class:`InputPaidMediaPhoto` or :class:`InputPaidMediaVideo` + """ + + def __init__(self, type, media, **kwargs): + self.type = type + self.media = media + + if service_utils.is_string(self.media): + self._media_name = '' + self._media_dic = self.media + else: + self._media_name = service_utils.generate_random_token() + self._media_dic = 'attach://{0}'.format(self._media_name) + + def to_json(self): + return json.dumps(self.to_dict()) + + def to_dict(self): + data = { + 'type': self.type, + 'media': self._media_dic + } + return data + +class InputPaidMediaPhoto(InputPaidMedia): + """ + The paid media to send is a photo. + + Telegram documentation: https://core.telegram.org/bots/api#inputpaidmediaphoto + + :param type: Type of the media, must be photo + :type type: :obj:`str` + + :param media: File to send. Pass a file_id to send a file that exists on the Telegram servers (recommended), pass an HTTP URL for + Telegram to get a file from the Internet, or pass “attach://” to upload a new one using multipart/form-data + under name. More information on Sending Files » + :type media: :obj:`str` + + :return: Instance of the class + :rtype: :class:`InputPaidMediaPhoto` + """ + + def __init__(self, media, **kwargs): + super().__init__(type='photo', media=media) + +class InputPaidMediaVideo(InputPaidMedia): + """ + The paid media to send is a video. + + Telegram documentation: https://core.telegram.org/bots/api#inputpaidmediavideo + + :param type: Type of the media, must be video + :type type: :obj:`str` + + :param media: File to send. Pass a file_id to send a file that exists on the Telegram servers (recommended), pass an HTTP URL for + Telegram to get a file from the Internet, or pass “attach://” to upload a new one using multipart/form-data + under name. More information on Sending Files » + :type media: :obj:`str` + + :param thumbnail: Optional. Thumbnail of the file sent; can be ignored if thumbnail generation for the file is supported server-side. + The thumbnail should be in JPEG format and less than 200 kB in size. A thumbnail's width and height should not exceed 320. + Ignored if the file is not uploaded using multipart/form-data. Thumbnails can't be reused and can be only uploaded as a new file, + so you can pass “attach://” if the thumbnail was uploaded using multipart/form-data under . + More information on Sending Files » + :type thumbnail: :class:`InputFile` + + :param width: Optional. Video width + :type width: :obj:`int` + + :param height: Optional. Video height + :type height: :obj:`int` + + :param duration: Optional. Video duration in seconds + :type duration: :obj:`int` + + :param supports_streaming: Optional. Pass True if the uploaded video is suitable for streaming + :type supports_streaming: :obj:`bool` + + :return: Instance of the class + :rtype: :class:`InputPaidMediaVideo` + + """ + + def __init__(self, media, thumbnail=None, width=None, height=None, duration=None, supports_streaming=None, **kwargs): + super().__init__(type='video', media=media) + self.thumbnail = thumbnail + self.width = width + self.height = height + self.duration = duration + self.supports_streaming = supports_streaming + + def to_dict(self): + data = super().to_dict() + if self.thumbnail: + data['thumbnail'] = self.thumbnail + if self.width: + data['width'] = self.width + if self.height: + data['height'] = self.height + if self.duration: + data['duration'] = self.duration + if self.supports_streaming is not None: + data['supports_streaming'] = self.supports_streaming + return data + + \ No newline at end of file diff --git a/telebot/version.py b/telebot/version.py index 96c6fbd20..5016764c6 100644 --- a/telebot/version.py +++ b/telebot/version.py @@ -1,3 +1,3 @@ # Versions should comply with PEP440. # This line is parsed in setup.py: -__version__ = '4.20.0' +__version__ = '4.21.0'