diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e725e2821..0043a1f2b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,20 @@ repos: - repo: git://github.com/python-telegram-bot/mirrors-yapf - rev: master + sha: 5769e088ef6e0a0d1eb63bd6d0c1fe9f3606d6c8 hooks: - id: yapf files: ^(telegram|tests)/.*\.py$ args: - --diff - repo: git://github.com/pre-commit/pre-commit-hooks - rev: v2.0.0 + sha: 0b70e285e369bcb24b57b74929490ea7be9c4b19 hooks: - id: flake8 exclude: ^(setup.py|docs/source/conf.py)$ args: - --ignore=W605,W503 - repo: git://github.com/pre-commit/mirrors-pylint - rev: v2.3.0 + sha: 9d8dcbc2b86c796275680f239c1e90dcd50bd398 hooks: - id: pylint files: ^telegram/.*\.py$ diff --git a/appveyor.yml b/appveyor.yml index b2aa6e1ce..237f9516e 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,7 +7,6 @@ environment: # isn't covered by this document) at the time of writing. - PYTHON: "C:\\Python27" - - PYTHON: "C:\\Python34" - PYTHON: "C:\\Python35" - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python37" diff --git a/docs/source/telegram.poll.rst b/docs/source/telegram.poll.rst new file mode 100644 index 000000000..cd369a027 --- /dev/null +++ b/docs/source/telegram.poll.rst @@ -0,0 +1,6 @@ +telegram.Poll +============= + +.. autoclass:: telegram.Poll + :members: + :show-inheritance: diff --git a/docs/source/telegram.polloption.rst b/docs/source/telegram.polloption.rst new file mode 100644 index 000000000..53a6cd69e --- /dev/null +++ b/docs/source/telegram.polloption.rst @@ -0,0 +1,6 @@ +telegram.PollOption +=================== + +.. autoclass:: telegram.PollOption + :members: + :show-inheritance: diff --git a/docs/source/telegram.rst b/docs/source/telegram.rst index 659e850a9..9241c7e7c 100644 --- a/docs/source/telegram.rst +++ b/docs/source/telegram.rst @@ -35,6 +35,8 @@ telegram package telegram.messageentity telegram.parsemode telegram.photosize + telegram.poll + telegram.polloption telegram.replykeyboardremove telegram.replykeyboardmarkup telegram.replymarkup diff --git a/telegram/__init__.py b/telegram/__init__.py index db5c682d9..e9a3c5091 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -47,6 +47,8 @@ from .files.file import File from .parsemode import ParseMode from .messageentity import MessageEntity from .games.game import Game +from .poll import Poll, PollOption +from .loginurl import LoginUrl from .games.callbackgame import CallbackGame from .payment.shippingaddress import ShippingAddress from .payment.orderinfo import OrderInfo @@ -57,11 +59,11 @@ from .passport.passportfile import PassportFile from .passport.data import IdDocumentData, PersonalDetails, ResidentialAddress from .passport.encryptedpassportelement import EncryptedPassportElement from .passport.passportdata import PassportData +from .inline.inlinekeyboardbutton import InlineKeyboardButton +from .inline.inlinekeyboardmarkup import InlineKeyboardMarkup from .message import Message from .callbackquery import CallbackQuery from .choseninlineresult import ChosenInlineResult -from .inline.inlinekeyboardbutton import InlineKeyboardButton -from .inline.inlinekeyboardmarkup import InlineKeyboardMarkup from .inline.inputmessagecontent import InputMessageContent from .inline.inlinequery import InlineQuery from .inline.inlinequeryresult import InlineQueryResult @@ -152,5 +154,6 @@ __all__ = [ 'PersonalDetails', 'ResidentialAddress', 'InputMediaVideo', 'InputMediaAnimation', 'InputMediaAudio', 'InputMediaDocument', 'TelegramDecryptionError', 'PassportElementErrorSelfie', 'PassportElementErrorTranslationFile', - 'PassportElementErrorTranslationFiles', 'PassportElementErrorUnspecified' + 'PassportElementErrorTranslationFiles', 'PassportElementErrorUnspecified', 'Poll', + 'PollOption', 'LoginUrl' ] diff --git a/telegram/bot.py b/telegram/bot.py index d3e151e58..f524a00c7 100644 --- a/telegram/bot.py +++ b/telegram/bot.py @@ -37,7 +37,7 @@ from future.utils import string_types from telegram import (User, Message, Update, Chat, ChatMember, UserProfilePhotos, File, ReplyMarkup, TelegramObject, WebhookInfo, GameHighScore, StickerSet, PhotoSize, Audio, Document, Sticker, Video, Animation, Voice, VideoNote, - Location, Venue, Contact, InputFile) + Location, Venue, Contact, InputFile, Poll) from telegram.error import InvalidToken, TelegramError from telegram.utils.helpers import to_timestamp from telegram.utils.request import Request @@ -260,13 +260,16 @@ class Bot(TelegramObject): @log def delete_message(self, chat_id, message_id, timeout=None, **kwargs): """ - Use this method to delete a message. A message can only be deleted if it was sent less - than 48 hours ago. Any such recently sent outgoing message may be deleted. Additionally, - if the bot is an administrator in a group chat, it can delete any message. If the bot is - an administrator in a supergroup, it can delete messages from any other user and service - messages about people joining or leaving the group (other types of service messages may - only be removed by the group creator). In channels, bots can only remove their own - messages. + Use this method to delete a message, including service messages, with the following + limitations: + + - A message can only be deleted if it was sent less than 48 hours ago. + - Bots can delete outgoing messages in private chats, groups, and supergroups. + - Bots can delete incoming messages in private chats. + - Bots granted can_post_messages permissions can delete outgoing messages in channels. + - If the bot is an administrator of a group, it can delete any message there. + - If the bot has can_delete_messages permission in a supergroup or a channel, it can + delete any message there. Args: chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target chat or username @@ -3319,6 +3322,101 @@ class Bot(TelegramObject): return result + @log + def send_poll(self, + chat_id, + question, + options, + disable_notification=None, + reply_to_message_id=None, + reply_markup=None, + timeout=None, + **kwargs): + """ + Use this method to send a native poll. A native poll can't be sent to a private chat. + + Args: + chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target private chat. + question (:obj:`str`): Poll question, 1-255 characters. + options (List[:obj:`str`]): List of answer options, 2-10 strings 1-100 characters each. + disable_notification (:obj:`bool`, optional): Sends the message silently. Users will + receive a notification with no sound. + reply_to_message_id (:obj:`int`, optional): If the message is a reply, ID of the + original message. + reply_markup (:class:`telegram.ReplyMarkup`, optional): Additional interface options. A + JSON-serialized object for an inline keyboard, custom reply keyboard, instructions + to remove reply keyboard or to force a reply from the user. + timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as + the read timeout from the server (instead of the one specified during creation of + the connection pool). + **kwargs (:obj:`dict`): Arbitrary keyword arguments. + + Returns: + :class:`telegram.Message`: On success, the sent Message is returned. + + Raises: + :class:`telegram.TelegramError` + + """ + url = '{0}/sendPoll'.format(self.base_url) + + data = { + 'chat_id': chat_id, + 'question': question, + 'options': options + } + + return self._message(url, data, timeout=timeout, disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, + **kwargs) + + @log + def stop_poll(self, + chat_id, + message_id, + reply_markup=None, + timeout=None, + **kwargs): + """ + Use this method to stop a poll which was sent by the bot. + + Args: + chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target chat or username + of the target channel (in the format @channelusername). + message_id (:obj:`int`): Identifier of the original message with the poll. + reply_markup (:class:`telegram.ReplyMarkup`, optional): Additional interface options. A + JSON-serialized object for an inline keyboard, custom reply keyboard, instructions + to remove reply keyboard or to force a reply from the user. + timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as + the read timeout from the server (instead of the one specified during creation of + the connection pool). + **kwargs (:obj:`dict`): Arbitrary keyword arguments. + + Returns: + :class:`telegram.Poll`: On success, the stopped Poll with the + final results is returned. + + Raises: + :class:`telegram.TelegramError` + + """ + url = '{0}/stopPoll'.format(self.base_url) + + data = { + 'chat_id': chat_id, + 'message_id': message_id + } + + if reply_markup: + if isinstance(reply_markup, ReplyMarkup): + data['reply_markup'] = reply_markup.to_json() + else: + data['reply_markup'] = reply_markup + + result = self._request.post(url, data, timeout=timeout) + + return Poll.de_json(result, self) + def to_dict(self): data = {'id': self.id, 'username': self.username, 'first_name': self.username} @@ -3456,3 +3554,7 @@ class Bot(TelegramObject): """Alias for :attr:`delete_sticker_from_set`""" setPassportDataErrors = set_passport_data_errors """Alias for :attr:`set_passport_data_errors`""" + sendPoll = send_poll + """Alias for :attr:`send_poll`""" + stopPoll = stop_poll + """Alias for :attr:`stop_poll`""" diff --git a/telegram/chat.py b/telegram/chat.py index 5938c7c14..af6708bb1 100644 --- a/telegram/chat.py +++ b/telegram/chat.py @@ -337,3 +337,16 @@ class Chat(TelegramObject): """ return self.bot.send_voice(self.id, *args, **kwargs) + + def send_poll(self, *args, **kwargs): + """Shortcut for:: + + bot.send_poll(Chat.id, *args, **kwargs) + + Where Chat is the current instance. + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return self.bot.send_poll(self.id, *args, **kwargs) diff --git a/telegram/chatmember.py b/telegram/chatmember.py index c39df0864..571ed1dd2 100644 --- a/telegram/chatmember.py +++ b/telegram/chatmember.py @@ -46,6 +46,8 @@ class ChatMember(TelegramObject): can_pin_messages (:obj:`bool`): Optional. If the administrator can pin messages. can_promote_members (:obj:`bool`): Optional. If the administrator can add new administrators. + is_member (:obj:`bool`): Optional. Restricted only. True, if the user is a member of the + chat at the moment of the request. can_send_messages (:obj:`bool`): Optional. If the user can send text messages, contacts, locations and venues. can_send_media_messages (:obj:`bool`): Optional. If the user can send media messages, @@ -81,6 +83,8 @@ class ChatMember(TelegramObject): administrator can add new administrators with a subset of his own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by the user). + is_member (:obj:`bool`, optional): Restricted only. True, if the user is a member of the + chat at the moment of the request. can_send_messages (:obj:`bool`, optional): Restricted only. True, if the user can send text messages, contacts, locations and venues. can_send_media_messages (:obj:`bool`, optional): Restricted only. True, if the user can @@ -111,7 +115,7 @@ class ChatMember(TelegramObject): can_restrict_members=None, can_pin_messages=None, can_promote_members=None, can_send_messages=None, can_send_media_messages=None, can_send_other_messages=None, - can_add_web_page_previews=None, **kwargs): + can_add_web_page_previews=None, is_member=None, **kwargs): # Required self.user = user self.status = status @@ -129,6 +133,7 @@ class ChatMember(TelegramObject): self.can_send_media_messages = can_send_media_messages self.can_send_other_messages = can_send_other_messages self.can_add_web_page_previews = can_add_web_page_previews + self.is_member = is_member self._id_attrs = (self.user, self.status) diff --git a/telegram/inline/inlinekeyboardbutton.py b/telegram/inline/inlinekeyboardbutton.py index bbf2a7f93..eedce7913 100644 --- a/telegram/inline/inlinekeyboardbutton.py +++ b/telegram/inline/inlinekeyboardbutton.py @@ -31,6 +31,8 @@ class InlineKeyboardButton(TelegramObject): Attributes: text (:obj:`str`): Label text on the button. url (:obj:`str`): Optional. HTTP url to be opened when button is pressed. + login_url (:class:`telegram.LoginUrl`) Optional. An HTTP URL used to automatically + authorize the user. callback_data (:obj:`str`): Optional. Data to be sent in a callback query to the bot when button is pressed, UTF-8 1-64 bytes. switch_inline_query (:obj:`str`): Optional. Will prompt the user to select one of their @@ -45,6 +47,8 @@ class InlineKeyboardButton(TelegramObject): Args: text (:obj:`str`): Label text on the button. url (:obj:`str`): HTTP url to be opened when button is pressed. + login_url (:class:`telegram.LoginUrl`, optional) An HTTP URL used to automatically + authorize the user. callback_data (:obj:`str`, optional): Data to be sent in a callback query to the bot when button is pressed, 1-64 UTF-8 bytes. switch_inline_query (:obj:`str`, optional): If set, pressing the button will prompt the @@ -76,14 +80,30 @@ class InlineKeyboardButton(TelegramObject): switch_inline_query_current_chat=None, callback_game=None, pay=None, + login_url=None, **kwargs): # Required self.text = text # Optionals - self.url = url - self.callback_data = callback_data - self.switch_inline_query = switch_inline_query - self.switch_inline_query_current_chat = switch_inline_query_current_chat - self.callback_game = callback_game - self.pay = pay + if url: + self.url = url + if login_url: + self.login_url = login_url + if callback_data: + self.callback_data = callback_data + if switch_inline_query: + self.switch_inline_query = switch_inline_query + if switch_inline_query_current_chat: + self.switch_inline_query_current_chat = switch_inline_query_current_chat + if callback_game: + self.callback_game = callback_game + if pay: + self.pay = pay + + @classmethod + def de_json(cls, data, bot): + if not data: + return None + + return cls(**data) diff --git a/telegram/inline/inlinekeyboardmarkup.py b/telegram/inline/inlinekeyboardmarkup.py index d212d896c..064231b78 100644 --- a/telegram/inline/inlinekeyboardmarkup.py +++ b/telegram/inline/inlinekeyboardmarkup.py @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram InlineKeyboardMarkup.""" -from telegram import ReplyMarkup +from telegram import ReplyMarkup, InlineKeyboardButton class InlineKeyboardMarkup(ReplyMarkup): @@ -49,6 +49,19 @@ class InlineKeyboardMarkup(ReplyMarkup): return data + @classmethod + def de_json(cls, data, bot): + if not data: + return None + keyboard = [] + for row in data['inline_keyboard']: + tmp = [] + for col in row: + tmp.append(InlineKeyboardButton.de_json(col, bot)) + keyboard.append(tmp) + + return cls(keyboard) + @classmethod def from_button(cls, button, **kwargs): """Shortcut for:: diff --git a/telegram/loginurl.py b/telegram/loginurl.py new file mode 100644 index 000000000..f5283c641 --- /dev/null +++ b/telegram/loginurl.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# pylint: disable=R0903 +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2019 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram LoginUrl.""" +from telegram import TelegramObject + + +class LoginUrl(TelegramObject): + """This object represents a parameter of the inline keyboard button used to automatically + authorize a user. Serves as a great replacement for the Telegram Login Widget when the user is + coming from Telegram. All the user needs to do is tap/click a button and confirm that they want + to log in. Telegram apps support these buttons as of version 5.7. + Sample bot: @discussbot + + Attributes: + url (:obj:`str`): An HTTP URL to be opened with user authorization data. + forward_text (:obj:`str`): Optional. New text of the button in forwarded messages. + bot_username (:obj:`str`): Optional. Username of a bot, which will be used for user + authorization. + request_write_access (:obj:`bool`): Optional. Pass True to request the permission for your + bot to send messages to the user. + + Args: + url (:obj:`str`): An HTTP URL to be opened with user authorization data added to the query + string when the button is pressed. If the user refuses to provide authorization data, + the original URL without information about the user will be opened. The data added is + the same as described in Receiving authorization data. + NOTE: You must always check the hash of the received data to verify the authentication + and the integrity of the data as described in Checking authorization. + forward_text (:obj:`str`, optional): New text of the button in forwarded messages. + bot_username (:obj:`str`, optional): Username of a bot, which will be used for user + authorization. See Setting up a bot for more details. If not specified, the current + bot's username will be assumed. The url's domain must be the same as the domain linked + with the bot. See Linking your domain to the bot for more details. + request_write_access (:obj:`bool`, optional): Pass True to request the permission for your + bot to send messages to the user. + """ + + def __init__(self, url, forward_text=None, bot_username=None, request_write_access=None): + self.url = url + + if forward_text: + self.forward_text = forward_text + if bot_username: + self.bot_username = bot_username + if request_write_access: + self.request_write_access = request_write_access + + self._id_attrs = (self.url,) diff --git a/telegram/message.py b/telegram/message.py index 2e7731e25..320656db9 100644 --- a/telegram/message.py +++ b/telegram/message.py @@ -23,7 +23,7 @@ from html import escape from telegram import (Animation, Audio, Contact, Document, Chat, Location, PhotoSize, Sticker, TelegramObject, User, Video, Voice, Venue, MessageEntity, Game, Invoice, - SuccessfulPayment, VideoNote, PassportData) + SuccessfulPayment, VideoNote, PassportData, Poll, InlineKeyboardMarkup) from telegram import ParseMode from telegram.utils.helpers import escape_markdown, to_timestamp, from_timestamp @@ -99,9 +99,15 @@ class Message(TelegramObject): has logged in. forward_signature (:obj:`str`): Optional. Signature of the post author for messages forwarded from channels. + forward_sender_name (:obj:`str`): Optional. Sender's name for messages forwarded from users + who disallow adding a link to their account in forwarded messages. author_signature (:obj:`str`): Optional. Signature of the post author for messages in channels. - passport_data (:class:`telegram.PassportData`): Optional. Telegram Passport data + passport_data (:class:`telegram.PassportData`): Optional. Telegram Passport data. + poll (:class:`telegram.Poll`): Optional. Message is a native poll, + information about the poll. + reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached + to the message. bot (:class:`telegram.Bot`): Optional. The Bot to use for instance methods. Args: @@ -117,6 +123,8 @@ class Message(TelegramObject): channel, information about the original channel. forward_from_message_id (:obj:`int`, optional): For forwarded channel posts, identifier of the original message in the channel. + forward_sender_name (:obj:`str`, optional): Sender's name for messages forwarded from users + who disallow adding a link to their account in forwarded messages. forward_date (:class:`datetime.datetime`, optional): For forwarded messages, date the original message was sent in Unix time. Converted to :class:`datetime.datetime`. reply_to_message (:class:`telegram.Message`, optional): For replies, the original message. @@ -201,7 +209,12 @@ class Message(TelegramObject): forwarded from channels. author_signature (:obj:`str`, optional): Signature of the post author for messages in channels. - passport_data (:class:`telegram.PassportData`, optional): Telegram Passport data + passport_data (:class:`telegram.PassportData`, optional): Telegram Passport data. + poll (:class:`telegram.Poll`, optional): Message is a native poll, + information about the poll. + reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached + to the message. login_url buttons are represented as ordinary url buttons. + """ _effective_attachment = _UNDEFINED @@ -260,6 +273,9 @@ class Message(TelegramObject): connected_website=None, animation=None, passport_data=None, + poll=None, + forward_sender_name=None, + reply_markup=None, bot=None, **kwargs): # Required @@ -304,11 +320,13 @@ class Message(TelegramObject): self.successful_payment = successful_payment self.connected_website = connected_website self.forward_signature = forward_signature + self.forward_sender_name = forward_sender_name self.author_signature = author_signature self.media_group_id = media_group_id self.animation = animation self.passport_data = passport_data - + self.poll = poll + self.reply_markup = reply_markup self.bot = bot self._id_attrs = (self.message_id,) @@ -362,6 +380,8 @@ class Message(TelegramObject): data['invoice'] = Invoice.de_json(data.get('invoice'), bot) data['successful_payment'] = SuccessfulPayment.de_json(data.get('successful_payment'), bot) data['passport_data'] = PassportData.de_json(data.get('passport_data'), bot) + data['poll'] = Poll.de_json(data.get('poll'), bot) + data['reply_markup'] = InlineKeyboardMarkup.de_json(data.get('reply_markup'), bot) return cls(bot=bot, **data) @@ -705,6 +725,23 @@ class Message(TelegramObject): self._quote(kwargs) return self.bot.send_contact(self.chat_id, *args, **kwargs) + def reply_poll(self, *args, **kwargs): + """Shortcut for:: + + bot.send_poll(update.message.chat_id, *args, **kwargs) + + Keyword Args: + quote (:obj:`bool`, optional): If set to ``True``, the photo is sent as an actual reply + to this message. If ``reply_to_message_id`` is passed in ``kwargs``, this parameter + will be ignored. Default: ``True`` in group chats and ``False`` in private chats. + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + self._quote(kwargs) + return self.bot.send_poll(self.chat_id, *args, **kwargs) + def forward(self, chat_id, disable_notification=False): """Shortcut for:: diff --git a/telegram/poll.py b/telegram/poll.py new file mode 100644 index 000000000..0c22d1bb2 --- /dev/null +++ b/telegram/poll.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python +# pylint: disable=R0903 +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2018 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram Poll.""" + +from telegram import (TelegramObject) + + +class PollOption(TelegramObject): + """ + This object contains information about one answer option in a poll. + + Attributes: + text (:obj:`str`): Option text, 1-100 characters. + voter_count (:obj:`int`): Number of users that voted for this option. + + Args: + text (:obj:`str`): Option text, 1-100 characters. + voter_count (:obj:`int`): Number of users that voted for this option. + + """ + + def __init__(self, text, voter_count, **kwargs): + self.text = text + self.voter_count = voter_count + + @classmethod + def de_json(cls, data, bot): + if not data: + return None + + return cls(**data) + + +class Poll(TelegramObject): + """ + This object contains information about a poll. + + Attributes: + id (:obj:`str`): Unique poll identifier. + question (:obj:`str`): Poll question, 1-255 characters. + options (List[:class:`PollOption`]): List of poll options. + is_closed (:obj:`bool`): True, if the poll is closed. + + Args: + id (:obj:`str`): Unique poll identifier. + question (:obj:`str`): Poll question, 1-255 characters. + options (List[:class:`PollOption`]): List of poll options. + is_closed (:obj:`bool`): True, if the poll is closed. + + """ + + def __init__(self, id, question, options, is_closed, **kwargs): + self.id = id + self.question = question + self.options = options + self.is_closed = is_closed + + self._id_attrs = (self.id,) + + @classmethod + def de_json(cls, data, bot): + if not data: + return None + + data = super(Poll, cls).de_json(data, bot) + + data['options'] = [PollOption.de_json(option, bot) for option in data['options']] + + return cls(**data) + + def to_dict(self): + data = super(Poll, self).to_dict() + + data['options'] = [x.to_dict() for x in self.options] + + return data diff --git a/telegram/update.py b/telegram/update.py index e69fd3a94..b05d66124 100644 --- a/telegram/update.py +++ b/telegram/update.py @@ -19,7 +19,7 @@ """This module contains an object that represents a Telegram Update.""" from telegram import (Message, TelegramObject, InlineQuery, ChosenInlineResult, - CallbackQuery, ShippingQuery, PreCheckoutQuery) + CallbackQuery, ShippingQuery, PreCheckoutQuery, Poll) class Update(TelegramObject): @@ -41,6 +41,8 @@ class Update(TelegramObject): shipping_query (:class:`telegram.ShippingQuery`): Optional. New incoming shipping query. pre_checkout_query (:class:`telegram.PreCheckoutQuery`): Optional. New incoming pre-checkout query. + poll (:class:`telegram.Poll`): Optional. New poll state. Bots receive only updates + about polls, which are sent or stopped by the bot Args: update_id (:obj:`int`): The update's unique identifier. Update identifiers start from a @@ -63,6 +65,8 @@ class Update(TelegramObject): Only for invoices with flexible price. pre_checkout_query (:class:`telegram.PreCheckoutQuery`, optional): New incoming pre-checkout query. Contains full information about checkout + poll (:class:`telegram.Poll`, optional): New poll state. Bots receive only updates + about polls, which are sent or stopped by the bot **kwargs (:obj:`dict`): Arbitrary keyword arguments. """ @@ -78,6 +82,7 @@ class Update(TelegramObject): callback_query=None, shipping_query=None, pre_checkout_query=None, + poll=None, **kwargs): # Required self.update_id = int(update_id) @@ -91,6 +96,7 @@ class Update(TelegramObject): self.pre_checkout_query = pre_checkout_query self.channel_post = channel_post self.edited_channel_post = edited_channel_post + self.poll = poll self._effective_user = None self._effective_chat = None @@ -102,7 +108,7 @@ class Update(TelegramObject): def effective_user(self): """ :class:`telegram.User`: The user that sent this update, no matter what kind of update this - is. Will be ``None`` for :attr:`channel_post`. + is. Will be ``None`` for :attr:`channel_post` and :attr:`poll`. """ if self._effective_user: @@ -140,7 +146,7 @@ class Update(TelegramObject): :class:`telegram.Chat`: The chat that this update was sent in, no matter what kind of update this is. Will be ``None`` for :attr:`inline_query`, :attr:`chosen_inline_result`, :attr:`callback_query` from inline messages, - :attr:`shipping_query` and :attr:`pre_checkout_query`. + :attr:`shipping_query`, :attr:`pre_checkout_query` and :attr:`poll`. """ if self._effective_chat: @@ -172,7 +178,7 @@ class Update(TelegramObject): :class:`telegram.Message`: The message included in this update, no matter what kind of update this is. Will be ``None`` for :attr:`inline_query`, :attr:`chosen_inline_result`, :attr:`callback_query` from inline messages, - :attr:`shipping_query` and :attr:`pre_checkout_query`. + :attr:`shipping_query`, :attr:`pre_checkout_query` and :attr:`poll`. """ if self._effective_message: @@ -215,5 +221,6 @@ class Update(TelegramObject): data['pre_checkout_query'] = PreCheckoutQuery.de_json(data.get('pre_checkout_query'), bot) data['channel_post'] = Message.de_json(data.get('channel_post'), bot) data['edited_channel_post'] = Message.de_json(data.get('edited_channel_post'), bot) + data['poll'] = Poll.de_json(data.get('poll'), bot) return cls(**data) diff --git a/tests/bots.py b/tests/bots.py index 8187872f4..3177cde26 100644 --- a/tests/bots.py +++ b/tests/bots.py @@ -31,13 +31,13 @@ FALLBACKS = [ 'token': '579694714:AAHRLL5zBVy4Blx2jRFKe1HlfnXCg08WuLY', 'payment_provider_token': '284685063:TEST:NjQ0NjZlNzI5YjJi', 'chat_id': '675666224', - 'group_id': '-269513406', + 'super_group_id': '-1001493296829', 'channel_id': '@pythontelegrambottests' }, { 'token': '558194066:AAEEylntuKSLXj9odiv3TnX7Z5KY2J3zY3M', 'payment_provider_token': '284685063:TEST:YjEwODQwMTFmNDcy', 'chat_id': '675666224', - 'group_id': '-269513406', + 'super_group_id': '-1001493296829', 'channel_id': '@pythontelegrambottests' } ] diff --git a/tests/conftest.py b/tests/conftest.py index 7ac788919..b643a5165 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,8 +55,8 @@ def chat_id(bot_info): @pytest.fixture(scope='session') -def group_id(bot_info): - return bot_info['group_id'] +def super_group_id(bot_info): + return bot_info['super_group_id'] @pytest.fixture(scope='session') diff --git a/tests/test_bot.py b/tests/test_bot.py index 6c54453c5..341e8e334 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -28,7 +28,7 @@ from future.utils import string_types from telegram import (Bot, Update, ChatAction, TelegramError, User, InlineKeyboardMarkup, InlineKeyboardButton, InlineQueryResultArticle, InputTextMessageContent, - ShippingOption, LabeledPrice) + ShippingOption, LabeledPrice, Poll) from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter from telegram.utils.helpers import from_timestamp @@ -149,6 +149,34 @@ class TestBot(object): assert message.contact.first_name == first_name assert message.contact.last_name == last_name + # TODO: Add bot to group to test polls too + + @flaky(3, 1) + @pytest.mark.timeout(10) + def test_send_and_stop_poll(self, bot, super_group_id): + question = 'Is this a test?' + answers = ['Yes', 'No', 'Maybe'] + message = bot.send_poll(chat_id=super_group_id, question=question, options=answers, + timeout=60) + + assert message.poll + assert message.poll.question == question + assert message.poll.options[0].text == answers[0] + assert message.poll.options[1].text == answers[1] + assert message.poll.options[2].text == answers[2] + assert not message.poll.is_closed + + poll = bot.stop_poll(chat_id=super_group_id, message_id=message.message_id, timeout=60) + assert isinstance(poll, Poll) + assert poll.is_closed + assert poll.options[0].text == answers[0] + assert poll.options[0].voter_count == 0 + assert poll.options[1].text == answers[1] + assert poll.options[1].voter_count == 0 + assert poll.options[2].text == answers[2] + assert poll.options[2].voter_count == 0 + assert poll.question == question + @flaky(3, 1) @pytest.mark.timeout(10) def test_send_game(self, bot, chat_id): @@ -346,12 +374,12 @@ class TestBot(object): @flaky(3, 1) @pytest.mark.timeout(10) - def test_get_chat(self, bot, group_id): - chat = bot.get_chat(group_id) + def test_get_chat(self, bot, super_group_id): + chat = bot.get_chat(super_group_id) - assert chat.type == 'group' + assert chat.type == 'supergroup' assert chat.title == '>>> telegram.Bot(test)' - assert chat.id == int(group_id) + assert chat.id == int(super_group_id) @flaky(3, 1) @pytest.mark.timeout(10) @@ -609,15 +637,16 @@ class TestBot(object): def test_set_chat_description(self, bot, channel_id): assert bot.set_chat_description(channel_id, 'Time: ' + str(time.time())) - @flaky(3, 1) - @pytest.mark.timeout(10) - def test_error_pin_unpin_message(self, bot, message): - # TODO: Add bot to supergroup so this can be tested properly - with pytest.raises(BadRequest, match='Method is available only for supergroups'): - bot.pin_chat_message(message.chat_id, message.message_id, disable_notification=True) + # TODO: Add bot to group to test there too + def test_pin_and_unpin_message(self, bot, super_group_id): + message = bot.send_message(super_group_id, text="test_pin_message") + assert bot.pin_chat_message(chat_id=super_group_id, message_id=message.message_id, + disable_notification=True) - with pytest.raises(BadRequest, match='Method is available only for supergroups'): - bot.unpin_chat_message(message.chat_id) + chat = bot.get_chat(super_group_id) + assert chat.pinned_message == message + + assert bot.unpinChatMessage(super_group_id) # get_sticker_set, upload_sticker_file, create_new_sticker_set, add_sticker_to_set, # set_sticker_position_in_set and delete_sticker_from_set are tested in the diff --git a/tests/test_chat.py b/tests/test_chat.py index a108ba7e8..681d42a3d 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -196,6 +196,13 @@ class TestChat(object): monkeypatch.setattr('telegram.Bot.send_animation', test) assert chat.send_animation('test_animation') + def test_instance_method_send_poll(self, monkeypatch, chat): + def test(*args, **kwargs): + return args[1] == chat.id and args[2] == 'test_poll' + + monkeypatch.setattr('telegram.Bot.send_poll', test) + assert chat.send_poll('test_poll') + def test_equality(self): a = Chat(self.id, self.title, self.type) b = Chat(self.id, self.title, self.type) diff --git a/tests/test_inlinekeyboardbutton.py b/tests/test_inlinekeyboardbutton.py index 8a0e9b06a..98faf1734 100644 --- a/tests/test_inlinekeyboardbutton.py +++ b/tests/test_inlinekeyboardbutton.py @@ -19,7 +19,7 @@ import pytest -from telegram import InlineKeyboardButton +from telegram import InlineKeyboardButton, LoginUrl @pytest.fixture(scope='class') @@ -31,7 +31,8 @@ def inline_keyboard_button(): switch_inline_query_current_chat=TestInlineKeyboardButton .switch_inline_query_current_chat, callback_game=TestInlineKeyboardButton.callback_game, - pay=TestInlineKeyboardButton.pay) + pay=TestInlineKeyboardButton.pay, + login_url=TestInlineKeyboardButton.login_url) class TestInlineKeyboardButton(object): @@ -42,6 +43,7 @@ class TestInlineKeyboardButton(object): switch_inline_query_current_chat = 'switch_inline_query_current_chat' callback_game = 'callback_game' pay = 'pay' + login_url = LoginUrl("http://google.com") def test_expected_values(self, inline_keyboard_button): assert inline_keyboard_button.text == self.text @@ -52,6 +54,7 @@ class TestInlineKeyboardButton(object): == self.switch_inline_query_current_chat) assert inline_keyboard_button.callback_game == self.callback_game assert inline_keyboard_button.pay == self.pay + assert inline_keyboard_button.login_url == self.login_url def test_to_dict(self, inline_keyboard_button): inline_keyboard_button_dict = inline_keyboard_button.to_dict() @@ -66,3 +69,26 @@ class TestInlineKeyboardButton(object): == inline_keyboard_button.switch_inline_query_current_chat) assert inline_keyboard_button_dict['callback_game'] == inline_keyboard_button.callback_game assert inline_keyboard_button_dict['pay'] == inline_keyboard_button.pay + assert inline_keyboard_button_dict['login_url'] == \ + inline_keyboard_button.login_url.to_dict() # NOQA: E127 + + def test_de_json(self, bot): + json_dict = { + 'text': self.text, + 'url': self.url, + 'callback_data': self.callback_data, + 'switch_inline_query': self.switch_inline_query, + 'switch_inline_query_current_chat': self.switch_inline_query_current_chat, + 'callback_game': self.callback_game, + 'pay': self.pay + } + + inline_keyboard_button = InlineKeyboardButton.de_json(json_dict, None) + assert inline_keyboard_button.text == self.text + assert inline_keyboard_button.url == self.url + assert inline_keyboard_button.callback_data == self.callback_data + assert inline_keyboard_button.switch_inline_query == self.switch_inline_query + assert (inline_keyboard_button.switch_inline_query_current_chat + == self.switch_inline_query_current_chat) + assert inline_keyboard_button.callback_game == self.callback_game + assert inline_keyboard_button.pay == self.pay diff --git a/tests/test_inlinekeyboardmarkup.py b/tests/test_inlinekeyboardmarkup.py index 68da77065..8666ff185 100644 --- a/tests/test_inlinekeyboardmarkup.py +++ b/tests/test_inlinekeyboardmarkup.py @@ -78,3 +78,34 @@ class TestInlineKeyboardMarkup(object): self.inline_keyboard[0][1].to_dict() ] ] + + def test_de_json(self): + json_dict = { + 'inline_keyboard': [[ + { + 'text': 'start', + 'url': 'http://google.com' + }, + { + 'text': 'next', + 'callback_data': 'abcd' + }], + [{ + 'text': 'Cancel', + 'callback_data': 'Cancel' + }] + ]} + inline_keyboard_markup = InlineKeyboardMarkup.de_json(json_dict, None) + + assert isinstance(inline_keyboard_markup, InlineKeyboardMarkup) + keyboard = inline_keyboard_markup.inline_keyboard + assert len(keyboard) == 2 + assert len(keyboard[0]) == 2 + assert len(keyboard[1]) == 1 + + assert isinstance(keyboard[0][0], InlineKeyboardButton) + assert isinstance(keyboard[0][1], InlineKeyboardButton) + assert isinstance(keyboard[1][0], InlineKeyboardButton) + + assert keyboard[0][0].text == 'start' + assert keyboard[0][0].url == 'http://google.com' diff --git a/tests/test_loginurl.py b/tests/test_loginurl.py new file mode 100644 index 000000000..4a41f0b88 --- /dev/null +++ b/tests/test_loginurl.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2019 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import LoginUrl + + +@pytest.fixture(scope='class') +def login_url(): + return LoginUrl(url=TestLoginUrl.url, forward_text=TestLoginUrl.forward_text, + bot_username=TestLoginUrl.bot_username, + request_write_access=TestLoginUrl.request_write_access) + + +class TestLoginUrl(object): + url = "http://www.google.com" + forward_text = "Send me forward!" + bot_username = "botname" + request_write_access = True + + def test_to_dict(self, login_url): + login_url_dict = login_url.to_dict() + + assert isinstance(login_url_dict, dict) + assert login_url_dict['url'] == self.url + assert login_url_dict['forward_text'] == self.forward_text + assert login_url_dict['bot_username'] == self.bot_username + assert login_url_dict['request_write_access'] == self.request_write_access + + def test_equality(self): + a = LoginUrl(self.url, self.forward_text, self.bot_username, self.request_write_access) + b = LoginUrl(self.url, self.forward_text, self.bot_username, self.request_write_access) + c = LoginUrl(self.url) + d = LoginUrl("text.com", self.forward_text, self.bot_username, self.request_write_access) + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_message.py b/tests/test_message.py index a0544dca5..f2499ea5d 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -20,10 +20,9 @@ from datetime import datetime import pytest -from telegram import ParseMode from telegram import (Update, Message, User, MessageEntity, Chat, Audio, Document, Animation, Game, PhotoSize, Sticker, Video, Voice, VideoNote, Contact, Location, Venue, - Invoice, SuccessfulPayment, PassportData) + Invoice, SuccessfulPayment, PassportData, ParseMode, Poll, PollOption) from tests.test_passport import RAW_PASSPORT_DATA @@ -88,7 +87,14 @@ def message(bot): {'photo': [PhotoSize('photo_id', 50, 50)], 'caption': 'photo_file', 'media_group_id': 1234443322222}, - {'passport_data': PassportData.de_json(RAW_PASSPORT_DATA, None)} + {'passport_data': PassportData.de_json(RAW_PASSPORT_DATA, None)}, + {'poll': Poll(id='abc', question='What is this?', + options=[PollOption(text='a', voter_count=1), + PollOption(text='b', voter_count=2)], is_closed=False)}, + {'text': 'a text message', 'reply_markup': {'inline_keyboard': [[{ + 'text': 'start', 'url': 'http://google.com'}, { + 'text': 'next', 'callback_data': 'abcd'}], + [{'text': 'Cancel', 'callback_data': 'Cancel'}]]}} ], ids=['forwarded_user', 'forwarded_channel', 'reply', 'edited', 'text', 'caption_entities', 'audio', 'document', 'animation', 'game', 'photo', @@ -97,7 +103,7 @@ def message(bot): 'group_created', 'supergroup_created', 'channel_created', 'migrated_to', 'migrated_from', 'pinned', 'invoice', 'successful_payment', 'connected_website', 'forward_signature', 'author_signature', - 'photo_from_media_group', 'passport_data']) + 'photo_from_media_group', 'passport_data', 'poll', 'reply_markup']) def message_params(bot, request): return Message(message_id=TestMessage.id, from_user=TestMessage.from_user, @@ -554,6 +560,20 @@ class TestMessage(object): assert message.reply_contact(contact='test_contact') assert message.reply_contact(contact='test_contact', quote=True) + def test_reply_poll(self, monkeypatch, message): + def test(*args, **kwargs): + id = args[1] == message.chat_id + contact = kwargs['contact'] == 'test_poll' + if kwargs.get('reply_to_message_id'): + reply = kwargs['reply_to_message_id'] == message.message_id + else: + reply = True + return id and contact and reply + + monkeypatch.setattr('telegram.Bot.send_poll', test) + assert message.reply_poll(contact='test_poll') + assert message.reply_poll(contact='test_poll', quote=True) + def test_forward(self, monkeypatch, message): def test(*args, **kwargs): chat_id = kwargs['chat_id'] == 123456 diff --git a/tests/test_poll.py b/tests/test_poll.py new file mode 100644 index 000000000..bbadf2252 --- /dev/null +++ b/tests/test_poll.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2018 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import Poll, PollOption + + +@pytest.fixture(scope="class") +def poll_option(): + return PollOption(text=TestPollOption.text, + voter_count=TestPollOption.voter_count) + + +class TestPollOption(object): + text = "test option" + voter_count = 3 + + def test_de_json(self): + json_dict = { + 'text': self.text, + 'voter_count': self.voter_count + } + poll_option = PollOption.de_json(json_dict, None) + + assert poll_option.text == self.text + assert poll_option.voter_count == self.voter_count + + def test_to_dict(self, poll_option): + poll_option_dict = poll_option.to_dict() + + assert isinstance(poll_option_dict, dict) + assert poll_option_dict['text'] == poll_option.text + assert poll_option_dict['voter_count'] == poll_option.voter_count + + +@pytest.fixture(scope='class') +def poll(): + return Poll(TestPoll.id, + TestPoll.question, + TestPoll.options, + TestPoll.is_closed) + + +class TestPoll(object): + id = 'id' + question = 'Test?' + options = [PollOption('test', 10), PollOption('test2', 11)] + is_closed = True + + def test_de_json(self): + json_dict = { + 'id': self.id, + 'question': self.question, + 'options': [o.to_dict() for o in self.options], + 'is_closed': self.is_closed + } + poll = Poll.de_json(json_dict, None) + + assert poll.id == self.id + assert poll.question == self.question + assert poll.options == self.options + assert poll.options[0].text == self.options[0].text + assert poll.options[0].voter_count == self.options[0].voter_count + assert poll.options[1].text == self.options[1].text + assert poll.options[1].voter_count == self.options[1].voter_count + assert poll.is_closed == self.is_closed + + def test_to_dict(self, poll): + poll_dict = poll.to_dict() + + assert isinstance(poll_dict, dict) + assert poll_dict['id'] == poll.id + assert poll_dict['question'] == poll.question + assert poll_dict['options'] == [o.to_dict() for o in poll.options] + assert poll_dict['is_closed'] == poll.is_closed diff --git a/tests/test_update.py b/tests/test_update.py index 92a846b44..59702207a 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -20,7 +20,7 @@ import pytest from telegram import (Message, User, Update, Chat, CallbackQuery, InlineQuery, - ChosenInlineResult, ShippingQuery, PreCheckoutQuery) + ChosenInlineResult, ShippingQuery, PreCheckoutQuery, Poll, PollOption) message = Message(1, User(1, '', False), None, Chat(1, ''), text='Text') @@ -34,12 +34,13 @@ params = [ {'chosen_inline_result': ChosenInlineResult('id', User(1, '', False), '')}, {'shipping_query': ShippingQuery('id', User(1, '', False), '', None)}, {'pre_checkout_query': PreCheckoutQuery('id', User(1, '', False), '', 0, '')}, - {'callback_query': CallbackQuery(1, User(1, '', False), 'chat')} + {'callback_query': CallbackQuery(1, User(1, '', False), 'chat')}, + {'poll': Poll('id', '?', [PollOption('.', 1)], False)} ] all_types = ('message', 'edited_message', 'callback_query', 'channel_post', 'edited_channel_post', 'inline_query', 'chosen_inline_result', - 'shipping_query', 'pre_checkout_query') + 'shipping_query', 'pre_checkout_query', 'poll') ids = all_types + ('callback_query_without_message',) @@ -91,7 +92,8 @@ class TestUpdate(object): or (update.callback_query is not None and update.callback_query.message is None) or update.shipping_query is not None - or update.pre_checkout_query is not None): + or update.pre_checkout_query is not None + or update.poll is not None): assert chat.id == 1 else: assert chat is None @@ -99,7 +101,9 @@ class TestUpdate(object): def test_effective_user(self, update): # Test that it's sometimes None per docstring user = update.effective_user - if not (update.channel_post is not None or update.edited_channel_post is not None): + if not (update.channel_post is not None + or update.edited_channel_post is not None + or update.poll is not None): assert user.id == 1 else: assert user is None @@ -112,7 +116,8 @@ class TestUpdate(object): or (update.callback_query is not None and update.callback_query.message is None) or update.shipping_query is not None - or update.pre_checkout_query is not None): + or update.pre_checkout_query is not None + or update.poll is not None): assert eff_message.message_id == message.message_id else: assert eff_message is None