diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index bf025452..518abc8d 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -1,3 +1,4 @@ +import asyncio import getpass import inspect import os @@ -235,7 +236,7 @@ async def sign_in( if isinstance(result, _tl.auth.AuthorizationSignUpRequired): # The method must return the User but we don't have it, so raise instead (matches pre-layer 104 behaviour) - self._tos = result.terms_of_service + self._tos = (result.terms_of_service, None) raise errors.SignUpRequired() return await _update_session_state(self, result.user) @@ -258,15 +259,10 @@ async def sign_up( # because the user already tried to sign in. # # We're emulating pre-layer 104 behaviour so except the right error: - if not self._tos: - try: - return await self.sign_in(code=code) - except errors.SignUpRequired: - pass # code is correct and was used, now need to sign in - - if self._tos and self._tos.text: - sys.stderr.write("{}\n".format(self._tos.text)) - sys.stderr.flush() + try: + return await self.sign_in(code=code) + except errors.SignUpRequired: + pass # code is correct and was used, now need to sign in result = await self(_tl.fn.auth.SignUp( phone_number=phone, @@ -275,12 +271,23 @@ async def sign_up( last_name=last_name )) - if self._tos: - await self(_tl.fn.help.AcceptTermsOfService(self._tos.id)) - return await _update_session_state(self, result.user) +async def get_tos(self): + first_time = self._tos is None + no_tos = self._tos and self._tos[0] is None + tos_expired = self._tos and self._tos[1] is not None and asyncio.get_running_loop().time() >= self._tos[1] + + if first_time or no_tos or tos_expired: + result = await self(_tl.fn.help.GetTermsOfServiceUpdate()) + tos = getattr(result, 'terms_of_service', None) + self._tos = (tos, asyncio.get_running_loop().time() + result.expires) + + # not stored in the client to prevent a cycle + return _custom.TermsOfService._new(self, *self._tos) + + async def _update_session_state(self, user, save=True): """ Callback called whenever the login or sign up process completes. diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index ca11d177..ec2a0ee5 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -142,6 +142,7 @@ def init( self.flood_sleep_threshold = flood_sleep_threshold self._flood_waited_requests = {} # prevent calls that would floodwait entirely self._phone_code_hash = None # used during login to prevent exposing the hash to end users + self._tos = None # used during signup and when fetching tos (tos/expiry) # Update handling. self._catch_up = catch_up diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 68393583..c8a41c92 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -455,10 +455,15 @@ class TelegramClient: You must call `send_code_request` first. - **By using this method you're agreeing to Telegram's - Terms of Service. This is required and your account - will be banned otherwise.** See https://telegram.org/tos - and https://core.telegram.org/api/terms. + .. important:: + + When creating a new account, you must be sure to show the Terms of Service + to the user, and only after they approve, the code can accept the Terms of + Service. If not, they must be declined, in which case the account **will be + deleted**. + + Make sure to use `client.get_tos` to fetch the Terms of Service, and to + use `tos.accept()` or `tos.decline()` after the user selects an option. Arguments first_name (`str`): @@ -481,6 +486,16 @@ class TelegramClient: code = input('enter code: ') await client.sign_up('Anna', 'Banana', code=code) + + # IMPORTANT: you MUST retrieve the Terms of Service and accept + # them, or Telegram has every right to delete the account. + tos = await client.get_tos() + print(tos.html) + + if code('accept (y/n)?: ') == 'y': + await tos.accept() + else: + await tos.decline() # deletes the account! """ @forward_call(auth.send_code_request) @@ -628,6 +643,42 @@ class TelegramClient: await client.edit_2fa(current_password='I_<3_Telethon') """ + @forward_call(auth.get_tos) + async def get_tos(self: 'TelegramClient') -> '_custom.TermsOfService': + """ + Fetch `Telegram's Terms of Service`_, which every user must accept in order to use + Telegram, or they must otherwise `delete their account`_. + + This method **must** be called after sign up, and **should** be called again + after it expires (at the risk of having the account terminated otherwise). + + See the documentation of `TermsOfService` for more information. + + The library cannot automate this process because the user must read the Terms of Service. + Automating its usage without reading the terms would be done at the developer's own risk. + + Example + .. code-block:: python + + # Fetch the ToS, forever (this could be a separate task, for example) + while True: + tos = await client.get_tos() + + if tos: + # There's an update or they must be accepted (you could show a popup) + print(tos.html) + if code('accept (y/n)?: ') == 'y': + await tos.accept() + else: + await tos.decline() # deletes the account! + + # after tos.timeout expires, the method should be called again! + await asyncio.sleep(tos.timeout) + + _Telegram's Terms of Service: https://telegram.org/tos + _delete their account: https://core.telegram.org/api/config#terms-of-service + """ + async def __aenter__(self): await self.connect() return self diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 4717d6b1..154a778c 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -10,6 +10,7 @@ from ..errors._rpcbase import RpcError, ServerError, FloodError, InvalidDcError, from .._misc import helpers, utils, hints from .._sessions.types import Entity from .. import errors, _tl +from ..types import _custom from .account import ignore_takeout _NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!') @@ -134,7 +135,7 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl async def get_me(self: 'TelegramClient') \ -> 'typing.Union[_tl.User, _tl.InputPeerUser]': try: - return (await self(_tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0] + return _custom.User._new(self, (await self(_tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0]) except UnauthorizedError: return None diff --git a/telethon/types/__init__.py b/telethon/types/__init__.py index 665f9481..39f8520e 100644 --- a/telethon/types/__init__.py +++ b/telethon/types/__init__.py @@ -17,4 +17,5 @@ from ._custom import ( ParticipantPermissions, Chat, User, + TermsOfService, ) diff --git a/telethon/types/_custom/__init__.py b/telethon/types/_custom/__init__.py index 5ee32a68..d8945804 100644 --- a/telethon/types/_custom/__init__.py +++ b/telethon/types/_custom/__init__.py @@ -14,3 +14,4 @@ from .qrlogin import QRLogin from .participantpermissions import ParticipantPermissions from .chat import Chat from .user import User +from .tos import TermsOfService diff --git a/telethon/types/_custom/tos.py b/telethon/types/_custom/tos.py new file mode 100644 index 00000000..7b4f4c34 --- /dev/null +++ b/telethon/types/_custom/tos.py @@ -0,0 +1,160 @@ +import sys + +from typing import Optional, List, TYPE_CHECKING +from datetime import datetime +from dataclasses import dataclass +import mimetypes +from .chatgetter import ChatGetter +from .sendergetter import SenderGetter +from .messagebutton import MessageButton +from .forward import Forward +from .file import File +from .inputfile import InputFile +from .inputmessage import InputMessage +from .button import build_reply_markup +from ..._misc import utils, helpers, tlobject, markdown, html +from ... import _tl, _misc + + +_DEFAULT_TIMEOUT = 24 * 60 * 60 + + +class TermsOfService: + """ + Represents `Telegram's Terms of Service`_, which every user must accept in order to use + Telegram, or they must otherwise `delete their account`_. + + This is not the same as the `API's Terms of Service`_, which every developer must accept + before creating applications for Telegram. + + You must make sure to check for the terms text (or markdown, or HTML), as well as confirm + the user's age if required. + + This class implements `__bool__`, meaning it will be truthy if there are terms to display, + and falsey otherwise. + + .. code-block:: python + + tos = await client.get_tos() + if tos: + print(tos.html) # there's something to read and accept or decline + ... + else: + await asyncio.sleep(tos.timeout) # nothing to read, but still has tos.timeout + + _Telegram's Terms of Service: https://telegram.org/tos + _delete their account: https://core.telegram.org/api/config#terms-of-service + _API's Terms of Service: https://core.telegram.org/api/terms + """ + + @property + def text(self): + """Plain-text version of the Terms of Service, or `None` if there is no ToS update.""" + return self._tos and self._tos.text + + @property + def markdown(self): + """Markdown-formatted version of the Terms of Service, or `None` if there is no ToS update.""" + return self._tos and markdown.unparse(self._tos.text, self._tos.entities) + + @property + def html(self): + """HTML-formatted version of the Terms of Service, or `None` if there is no ToS update.""" + return self._tos and html.unparse(self._tos.text, self._tos.entities) + + @property + def popup(self): + """`True` a popup should be shown to the user.""" + return self._tos and self._tos.popup + + @property + def minimum_age(self): + """The minimum age the user must be to accept the terms, or `None` if there's no requirement.""" + return self._tos and self._tos.min_age_confirm + + @property + def timeout(self): + """ + How many seconds are left before `client.get_tos` should be used again. + + This value is a positive floating point number, and is monotically decreasing. + The value will reach zero after enough seconds have elapsed. This lets you do some work + and call sleep on the value and still wait just long enough. + """ + return max(0.0, self._expiry - asyncio.get_running_loop().time()) + + @property + def expired(self): + """ + Returns `True` if this instance of the Terms of Service has expired and should be re-fetched. + + .. code-block:: python + + if tos.expired: + tos = await client.get_tos() + """ + return asyncio.get_running_loop() >= self._expiry + + def __init__(self): + raise TypeError('You cannot create TermsOfService instances by hand!') + + @classmethod + def _new(cls, client, tos, expiry): + self = cls.__new__(cls) + self._client = client + self._tos = tos + self._expiry = expiry or asyncio.get_running_loop().time() + _DEFAULT_TIMEOUT + return self + + async def accept(self, *, age=None): + """ + Accept the Terms of Service. + + Does nothing if there is nothing to accept. + + If `minimum_age` is not `None`, the `age` parameter must be provided, + and be greater than or equal to `minimum_age`. Otherwise, the function will fail. + + .. code-example: + + if tos.minimum_age: + age = int(input('age: ')) + else: + age = None + + print(tos.html) + if input('accept (y/n)?: ') == 'y': + await tos.accept(age=age) + """ + if not self._tos: + return + + if age < (self.minimum_age or 0): + raise ValueError('User is not old enough to accept the Terms of Service') + + if age > 122: + # This easter egg may be out of date by 2025 + print('Lying is done at your own risk!', file=sys.stderr) + + await self._client(_tl.fn.help.AcceptTermsOfService(self._tos.id)) + + async def decline(self): + """ + Decline the Terms of Service. + + Does nothing if there is nothing to decline. + + .. danger:: + + Declining the Terms of Service will result in the `termination of your account`_. + **Your account will be deleted**. + + _termination of your account: https://core.telegram.org/api/config#terms-of-service + """ + if not self._tos: + return + + await self._client(_tl.fn.account.DeleteAccount('Decline ToS update')) + + def __bool__(self): + return self._tos is not None