Merge branch 'master' into sync

This commit is contained in:
Lonami Exo 2018-08-01 15:18:44 +02:00
commit 3177e3a956
55 changed files with 2776 additions and 1146 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ docs/
# Generated code
telethon/tl/functions/
telethon/tl/types/
telethon/tl/patched/
telethon/tl/alltlobjects.py
telethon/errors/rpcerrorlist.py

View File

@ -13,10 +13,12 @@ from .telegrambaseclient import TelegramBaseClient
from .users import UserMethods # Required for everything
from .messageparse import MessageParseMethods # Required for messages
from .uploads import UploadMethods # Required for messages to send files
from .updates import UpdateMethods # Required for buttons (register callbacks)
from .buttons import ButtonMethods # Required for messages to use buttons
from .messages import MessageMethods
from .chats import ChatMethods
from .dialogs import DialogMethods
from .downloads import DownloadMethods
from .auth import AuthMethods
from .updates import UpdateMethods
from .bots import BotMethods
from .telegramclient import TelegramClient

View File

@ -16,7 +16,7 @@ class AuthMethods(MessageParseMethods, UserMethods):
def start(
self,
phone=lambda: input('Please enter your phone: '),
phone=lambda: input('Please enter your phone (or bot token): '),
password=lambda: getpass.getpass('Please enter your password: '),
*,
bot_token=None, force_sms=False, code_callback=None,
@ -45,7 +45,8 @@ class AuthMethods(MessageParseMethods, UserMethods):
Args:
phone (`str` | `int` | `callable`):
The phone (or callable without arguments to get it)
to which the code will be sent.
to which the code will be sent. If a bot-token-like
string is given, it will be used as such instead.
password (`callable`, optional):
The password for 2 Factor Authentication (2FA).
@ -118,14 +119,21 @@ class AuthMethods(MessageParseMethods, UserMethods):
if self.is_user_authorized():
return self
if not bot_token:
# Turn the callable into a valid phone number (or bot token)
while callable(phone):
value = phone()
if ':' in value:
# Bot tokens have 'user_id:access_hash' format
bot_token = value
break
phone = utils.parse_phone(value) or phone
if bot_token:
self.sign_in(bot_token=bot_token)
return self
# Turn the callable into a valid phone number
while callable(phone):
phone = utils.parse_phone(phone()) or phone
me = None
attempts = 0
two_step_detected = False

45
telethon/client/bots.py Normal file
View File

@ -0,0 +1,45 @@
from .users import UserMethods
from ..tl import types, functions, custom
class BotMethods(UserMethods):
def inline_query(self, bot, query, *, offset=None, geo_point=None):
"""
Makes the given inline query to the specified bot
i.e. ``@vote My New Poll`` would be as follows:
>>> client = ...
>>> client.inline_query('vote', 'My New Poll')
Args:
bot (`entity`):
The bot entity to which the inline query should be made.
query (`str`):
The query that should be made to the bot.
offset (`str`, optional):
The string offset to use for the bot.
geo_point (:tl:`GeoPoint`, optional)
The geo point location information to send to the bot
for localised results. Available under some bots.
Returns:
A list of `custom.InlineResult
<telethon.tl.custom.inlineresult.InlineResult>`.
"""
bot = self.get_input_entity(bot)
result = self(functions.messages.GetInlineBotResultsRequest(
bot=bot,
peer=types.InputPeerEmpty(),
query=query,
offset=offset or '',
geo_point=geo_point
))
# TODO Custom InlineResults(UserList) class with more information
return [
custom.InlineResult(self, x, query_id=result.query_id)
for x in result.results
]

View File

@ -0,0 +1,69 @@
from .updates import UpdateMethods
from ..tl import types, custom
from .. import utils, events
class ButtonMethods(UpdateMethods):
def build_reply_markup(self, buttons, inline_only=False):
"""
Builds a :tl`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for
the given buttons, or does nothing if either no buttons are
provided or the provided argument is already a reply markup.
This will add any event handlers defined in the
buttons and delete old ones not to call them twice,
so you should probably call this method manually for
serious bots instead re-adding handlers every time you
send a message. Magic can only go so far.
"""
if buttons is None:
return None
try:
if buttons.SUBCLASS_OF_ID == 0xe2e10ef2:
return buttons # crc32(b'ReplyMarkup'):
except AttributeError:
pass
if not utils.is_list_like(buttons):
buttons = [[buttons]]
elif not utils.is_list_like(buttons[0]):
buttons = [buttons]
is_inline = False
is_normal = False
rows = []
for row in buttons:
current = []
for button in row:
inline = custom.Button._is_inline(button)
is_inline |= inline
is_normal |= not inline
if isinstance(button, custom.Button):
if button.callback:
self.remove_event_handler(
button.callback, events.CallbackQuery)
self.add_event_handler(
button.callback,
events.CallbackQuery(data=button.data)
)
button = button.button
if button.SUBCLASS_OF_ID == 0xbad74a3:
# 0xbad74a3 == crc32(b'KeyboardButton')
current.append(button)
if current:
rows.append(types.KeyboardButtonRow(current))
if inline_only and is_normal:
raise ValueError('You cannot use non-inline buttons here')
elif is_inline == is_normal and is_normal:
raise ValueError('You cannot mix inline with normal buttons')
elif is_inline:
return types.ReplyInlineMarkup(rows)
elif is_normal:
return types.ReplyKeyboardMarkup(rows)

View File

@ -31,14 +31,14 @@ class ChatMethods(UserMethods):
This has no effect for normal chats or users.
aggressive (`bool`, optional):
Aggressively looks for all participants in the chat in
order to get more than 10,000 members (a hard limit
imposed by Telegram). Note that this might take a long
time (over 5 minutes), but is able to return over 90,000
participants on groups with 100,000 members.
Aggressively looks for all participants in the chat.
This has no effect for groups or channels with less than
10,000 members, or if a ``filter`` is given.
This is useful for channels since 20 July 2018,
Telegram added a server-side limit where only the
first 200 members can be retrieved. With this flag
set, more than 200 will be often be retrieved.
This has no effect if a ``filter`` is given.
_total (`list`, optional):
A single-item list to pass the total parameter by reference.
@ -73,20 +73,16 @@ class ChatMethods(UserMethods):
limit = float('inf') if limit is None else int(limit)
if isinstance(entity, types.InputPeerChannel):
if _total or (aggressive and not filter):
total = (self(functions.channels.GetFullChannelRequest(
entity
))).full_chat.participants_count
if _total:
_total[0] = total
else:
total = 0
_total[0] = (self(
functions.channels.GetFullChannelRequest(entity)
)).full_chat.participants_count
if limit == 0:
return
seen = set()
if total > 10000 and aggressive and not filter:
if aggressive and not filter:
requests = [functions.channels.GetParticipantsRequest(
channel=entity,
filter=types.ChannelParticipantsSearch(search + chr(x)),

View File

@ -77,10 +77,14 @@ class DialogMethods(UserMethods):
if _total:
_total[0] = getattr(r, 'count', len(r.dialogs))
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)}
messages = {m.id: custom.Message(self, m, entities, None)
for m in r.messages}
messages = {}
for m in r.messages:
m._finish_init(self, entities, None)
messages[m.id] = m
# Happens when there are pinned dialogs
if len(r.dialogs) > limit:

View File

@ -3,6 +3,7 @@ import io
import logging
import os
import pathlib
import urllib.request
from .users import UserMethods
from .. import utils, helpers, errors
@ -123,6 +124,9 @@ class DownloadMethods(UserMethods):
date = datetime.datetime.now()
media = message
if isinstance(media, str):
media = utils.resolve_bot_file_id(media)
if isinstance(media, types.MessageMediaWebPage):
if isinstance(media.webpage, types.WebPage):
media = media.webpage.document or media.webpage.photo
@ -140,6 +144,10 @@ class DownloadMethods(UserMethods):
return self._download_contact(
media, file
)
elif isinstance(media, (types.WebDocument, types.WebDocumentNoProxy)):
return self._download_web_document(
media, file, progress_callback
)
def download_file(
self, input_location, file=None, *, part_size_kb=None,
@ -200,10 +208,27 @@ class DownloadMethods(UserMethods):
else:
f = file
# The used sender will change if ``FileMigrateError`` occurs
dc_id, input_location = utils.get_input_location(input_location)
exported = dc_id and self.session.dc_id != dc_id
if exported:
try:
sender = self._borrow_exported_sender(dc_id)
except errors.DcIdInvalidError:
# Can't export a sender for the ID we are currently in
config = self(functions.help.GetConfigRequest())
for option in config.dc_options:
if option.ip_address == self.session.server_address:
self.session.set_dc(
option.id, option.ip_address, option.port)
self.session.save()
break
# TODO Figure out why the session may have the wrong DC ID
sender = self._sender
exported = False
input_location = utils.get_input_location(input_location)
else:
# The used sender will also change if ``FileMigrateError`` occurs
sender = self._sender
__log__.info('Downloading file in chunks of %d bytes', part_size)
try:
@ -281,19 +306,12 @@ class DownloadMethods(UserMethods):
progress_callback=progress_callback)
return file
def _download_document(
self, document, file, date, progress_callback):
"""Specialized version of .download_media() for documents."""
if isinstance(document, types.MessageMediaDocument):
document = document.document
if not isinstance(document, types.Document):
return
file_size = document.size
@staticmethod
def _get_kind_and_names(attributes):
"""Gets kind and possible names for :tl:`DocumentAttribute`."""
kind = 'document'
possible_names = []
for attr in document.attributes:
for attr in attributes:
if isinstance(attr, types.DocumentAttributeFilename):
possible_names.insert(0, attr.file_name)
@ -310,13 +328,24 @@ class DownloadMethods(UserMethods):
elif attr.voice:
kind = 'voice'
return kind, possible_names
def _download_document(
self, document, file, date, progress_callback):
"""Specialized version of .download_media() for documents."""
if isinstance(document, types.MessageMediaDocument):
document = document.document
if not isinstance(document, types.Document):
return
kind, possible_names = self._get_kind_and_names(document.attributes)
file = self._get_proper_filename(
file, kind, utils.get_extension(document),
date=date, possible_names=possible_names
)
self.download_file(
document, file, file_size=file_size,
document, file, file_size=document.size,
progress_callback=progress_callback)
return file
@ -356,6 +385,37 @@ class DownloadMethods(UserMethods):
return file
@classmethod
async def _download_web_document(cls, web, file, progress_callback):
"""
Specialized version of .download_media() for web documents.
"""
# TODO Better way to get opened handles of files and auto-close
if isinstance(file, str):
kind, possible_names = cls._get_kind_and_names(web.attributes)
file = cls._get_proper_filename(
file, kind, utils.get_extension(web),
possible_names=possible_names
)
f = open(file, 'wb')
else:
f = file
session = urllib.request.urlopen(web.url)
try:
# TODO Use progress_callback; get content length from response
# int(session.info().getheaders('Content-Length')[0])
# https://github.com/telegramdesktop/tdesktop/blob/c7e773dd9aeba94e2be48c032edc9a78bb50234e/Telegram/SourceFiles/ui/images.cpp#L1318-L1319
while True:
chunk = await session.read(128 * 1024)
if not chunk:
break
f.write(chunk)
finally:
session.close()
if isinstance(file, str):
f.close()
@staticmethod
def _get_proper_filename(file, kind, extension,
date=None, possible_names=None):

View File

@ -133,6 +133,7 @@ class MessageParseMethods(UserMethods):
break
if found:
return custom.Message(self, found, entities, input_chat)
found._finish_init(self, entities, input_chat)
return found
# endregion

View File

@ -6,13 +6,14 @@ from collections import UserList
from .messageparse import MessageParseMethods
from .uploads import UploadMethods
from .buttons import ButtonMethods
from .. import utils
from ..tl import types, functions, custom
__log__ = logging.getLogger(__name__)
class MessageMethods(UploadMethods, MessageParseMethods):
class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods):
# region Public methods
@ -33,6 +34,14 @@ class MessageMethods(UploadMethods, MessageParseMethods):
entity (`entity`):
The entity from whom to retrieve the message history.
It may be ``None`` to perform a global search, or
to get messages by their ID from no particular chat.
Note that some of the offsets will not work if this
is the case.
Note that if you want to perform a global search,
you **must** set a non-empty `search` string.
limit (`int` | `None`, optional):
Number of messages to be retrieved. Due to limitations with
the API retrieving more than 3000 messages will take longer
@ -100,6 +109,8 @@ class MessageMethods(UploadMethods, MessageParseMethods):
instead of being `max_id` as well since messages are returned
in ascending order.
You cannot use this if both `entity` and `ids` are ``None``.
_total (`list`, optional):
A single-item list to pass the total parameter by reference.
@ -113,9 +124,9 @@ class MessageMethods(UploadMethods, MessageParseMethods):
an higher limit, so you're free to set the ``batch_size`` that
you think may be good.
"""
# It's possible to get messages by ID without their entity, so only
# fetch the input version if we're not using IDs or if it was given.
if not ids or entity:
# Note that entity being ``None`` is intended to get messages by
# ID under no specific chat, and also to request a global search.
if entity:
entity = self.get_input_entity(entity)
if ids:
@ -154,7 +165,19 @@ class MessageMethods(UploadMethods, MessageParseMethods):
from_id = None
limit = float('inf') if limit is None else int(limit)
if search is not None or filter or from_user:
if not entity:
if reverse:
raise ValueError('Cannot reverse global search')
reverse = None
request = functions.messages.SearchGlobalRequest(
q=search or '',
offset_date=offset_date,
offset_peer=types.InputPeerEmpty(),
offset_id=offset_id,
limit=1
)
elif search is not None or filter or from_user:
if filter is None:
filter = types.InputMessagesFilterEmpty()
request = functions.messages.SearchRequest(
@ -239,7 +262,9 @@ class MessageMethods(UploadMethods, MessageParseMethods):
or from_id and message.from_id != from_id):
continue
if reverse:
if reverse is None:
pass
elif reverse:
if message.id <= last_id or message.id >= max_id:
return
else:
@ -252,7 +277,8 @@ class MessageMethods(UploadMethods, MessageParseMethods):
# IDs are returned in descending order (or asc if reverse).
last_id = message.id
yield (custom.Message(self, message, entities, entity))
message._finish_init(self, entities, entity)
yield (message)
have += 1
if len(r.messages) < request.limit:
@ -277,12 +303,15 @@ class MessageMethods(UploadMethods, MessageParseMethods):
break
else:
request.offset_id = last_message.id
if isinstance(request, functions.messages.GetHistoryRequest):
request.offset_date = last_message.date
else:
if isinstance(request, functions.messages.SearchRequest):
request.max_date = last_message.date
else:
# getHistory and searchGlobal call it offset_date
request.offset_date = last_message.date
if reverse:
if isinstance(request, functions.messages.SearchGlobalRequest):
request.offset_peer = last_message.input_chat
elif reverse:
# We want to skip the one we already have
request.add_offset -= 1
@ -330,7 +359,8 @@ class MessageMethods(UploadMethods, MessageParseMethods):
def send_message(
self, entity, message='', *, reply_to=None,
parse_mode=utils.Default, link_preview=True, file=None,
force_document=False, clear_draft=False):
force_document=False, clear_draft=False, buttons=None,
silent=None):
"""
Sends the given message to the specified entity (user/chat/channel).
@ -379,13 +409,26 @@ class MessageMethods(UploadMethods, MessageParseMethods):
Whether the existing draft should be cleared or not.
Has no effect when sending a file.
buttons (`list`, `custom.Button <telethon.tl.custom.button.Button>`,
:tl:`KeyboardButton`):
The matrix (list of lists), row list or button to be shown
after sending the message. This parameter will only work if
you have signed in as a bot. You can also pass your own
:tl:`ReplyMarkup` here.
silent (`bool`, optional):
Whether the message should notify people in a broadcast
channel or not. Defaults to ``False``, which means it will
notify them. Set it to ``True`` to alter this behaviour.
Returns:
The sent `telethon.tl.custom.message.Message`.
The sent `custom.Message <telethon.tl.custom.message.Message>`.
"""
if file is not None:
return self.send_file(
entity, file, caption=message, reply_to=reply_to,
parse_mode=parse_mode, force_document=force_document
parse_mode=parse_mode, force_document=force_document,
buttons=buttons
)
elif not message:
raise ValueError(
@ -414,12 +457,20 @@ class MessageMethods(UploadMethods, MessageParseMethods):
else:
reply_id = None
if buttons is None:
markup = message.reply_markup
else:
markup = self.build_reply_markup(buttons)
if silent is None:
silent = message.silent
request = functions.messages.SendMessageRequest(
peer=entity,
message=message.message or '',
silent=message.silent,
silent=silent,
reply_to_msg_id=reply_id,
reply_markup=message.reply_markup,
reply_markup=markup,
entities=message.entities,
clear_draft=clear_draft,
no_webpage=not isinstance(
@ -435,13 +486,15 @@ class MessageMethods(UploadMethods, MessageParseMethods):
entities=msg_ent,
no_webpage=not link_preview,
reply_to_msg_id=utils.get_message_id(reply_to),
clear_draft=clear_draft
clear_draft=clear_draft,
silent=silent,
reply_markup=self.build_reply_markup(buttons)
)
result = self(request)
if isinstance(result, types.UpdateShortSentMessage):
to_id, cls = utils.resolve_id(utils.get_peer_id(entity))
return custom.Message(self, types.Message(
message = types.Message(
id=result.id,
to_id=cls(to_id),
message=message,
@ -449,11 +502,14 @@ class MessageMethods(UploadMethods, MessageParseMethods):
out=result.out,
media=result.media,
entities=result.entities
), {}, input_chat=entity)
)
message._finish_init(self, {}, entity)
return message
return self._get_response_message(request, result, entity)
def forward_messages(self, entity, messages, from_peer=None):
def forward_messages(self, entity, messages, from_peer=None,
*, silent=None):
"""
Forwards the given message(s) to the specified entity.
@ -469,6 +525,11 @@ class MessageMethods(UploadMethods, MessageParseMethods):
of the ``Message`` class, this *must* be specified in
order for the forward to work.
silent (`bool`, optional):
Whether the message should notify people in a broadcast
channel or not. Defaults to ``False``, which means it will
notify them. Set it to ``True`` to alter this behaviour.
Returns:
The list of forwarded `telethon.tl.custom.message.Message`,
or a single one if a list wasn't provided as input.
@ -496,7 +557,8 @@ class MessageMethods(UploadMethods, MessageParseMethods):
req = functions.messages.ForwardMessagesRequest(
from_peer=from_peer,
id=[m if isinstance(m, int) else m.id for m in messages],
to_peer=entity
to_peer=entity,
silent=silent
)
result = self(req)
if isinstance(result, (types.Updates, types.UpdatesCombined)):
@ -512,15 +574,16 @@ class MessageMethods(UploadMethods, MessageParseMethods):
random_to_id[update.random_id] = update.id
elif isinstance(update, (
types.UpdateNewMessage, types.UpdateNewChannelMessage)):
id_to_message[update.message.id] = custom.Message(
self, update.message, entities, input_chat=entity)
update.message._finish_init(self, entities, entity)
id_to_message[update.message.id] = update.message
result = [id_to_message[random_to_id[rnd]] for rnd in req.random_id]
return result[0] if single else result
def edit_message(
self, entity, message=None, text=None,
*, parse_mode=utils.Default, link_preview=True, file=None):
*, parse_mode=utils.Default, link_preview=True, file=None,
buttons=None):
"""
Edits the given message ID (to change its contents or disable preview).
@ -551,6 +614,13 @@ class MessageMethods(UploadMethods, MessageParseMethods):
The file object that should replace the existing media
in the message.
buttons (`list`, `custom.Button <telethon.tl.custom.button.Button>`,
:tl:`KeyboardButton`):
The matrix (list of lists), row list or button to be shown
after sending the message. This parameter will only work if
you have signed in as a bot. You can also pass your own
:tl:`ReplyMarkup` here.
Examples:
>>> client = ...
@ -586,7 +656,8 @@ class MessageMethods(UploadMethods, MessageParseMethods):
message=text,
no_webpage=not link_preview,
entities=msg_entities,
media=media
media=media,
reply_markup=self.build_reply_markup(buttons)
)
msg = self._get_response_message(request, self(request), entity)
self._cache_media(msg, file, file_handle)
@ -703,7 +774,7 @@ class MessageMethods(UploadMethods, MessageParseMethods):
total[0] = len(ids)
from_id = None # By default, no need to validate from_id
if isinstance(entity, types.InputPeerChannel):
if isinstance(entity, (types.InputChannel, types.InputPeerChannel)):
r = self(functions.channels.GetMessagesRequest(entity, ids))
else:
r = self(functions.messages.GetMessagesRequest(ids))
@ -729,6 +800,7 @@ class MessageMethods(UploadMethods, MessageParseMethods):
from_id and utils.get_peer_id(message.to_id) != from_id):
yield (None)
else:
yield (custom.Message(self, message, entities, entity))
message._finish_init(self, entities, entity)
yield (message)
# endregion

View File

@ -1,4 +1,7 @@
import abc
import asyncio
import collections
import inspect
import logging
import platform
import queue
@ -219,6 +222,9 @@ class TelegramBaseClient(abc.ABC):
auto_reconnect_callback=self._handle_auto_reconnect
)
# Remember flood-waited requests to avoid making them again
self._flood_waited_requests = {}
# Cache ``{dc_id: (n, MTProtoSender)}`` for all borrowed senders,
# being ``n`` the amount of borrows a given sender has; once ``n``
# reaches ``0`` it should be disconnected and removed.
@ -252,6 +258,11 @@ class TelegramBaseClient(abc.ABC):
self._events_pending_resolve = []
self._event_resolve_lock = threading.Lock()
# Keep track of how many event builders there are for
# each type {type: count}. If there's at least one then
# the event will be built, and the same event be reused.
self._event_builders_count = collections.defaultdict(int)
# Default parse mode
self._parse_mode = markdown
@ -409,6 +420,9 @@ class TelegramBaseClient(abc.ABC):
if not sender:
sender = self._create_exported_sender(dc_id)
sender.dc_id = dc_id
elif not n:
dc = self._get_dc(dc_id)
sender.connect(dc.ip_address, dc.port)
self._borrowed_senders[dc_id] = (n + 1, sender)
@ -423,12 +437,10 @@ class TelegramBaseClient(abc.ABC):
dc_id = sender.dc_id
n, _ = self._borrowed_senders[dc_id]
n -= 1
if n > 0:
self._borrowed_senders[dc_id] = (n, sender)
else:
if not n:
__log__.info('Disconnecting borrowed sender for DC %d', dc_id)
sender.disconnect()
del self._borrowed_senders[dc_id]
def _get_cdn_client(self, cdn_redirect):
"""Similar to ._borrow_exported_client, but for CDNs"""

View File

@ -1,13 +1,13 @@
from . import (
UpdateMethods, AuthMethods, DownloadMethods, DialogMethods,
ChatMethods, MessageMethods, UploadMethods, MessageParseMethods,
UserMethods
AuthMethods, DownloadMethods, DialogMethods, ChatMethods, BotMethods,
MessageMethods, ButtonMethods, UpdateMethods, UploadMethods,
MessageParseMethods, UserMethods
)
class TelegramClient(
UpdateMethods, AuthMethods, DownloadMethods, DialogMethods,
ChatMethods, MessageMethods, UploadMethods, MessageParseMethods,
UserMethods
AuthMethods, DownloadMethods, DialogMethods, ChatMethods, BotMethods,
MessageMethods, UploadMethods, ButtonMethods, UpdateMethods,
MessageParseMethods, UserMethods
):
pass

View File

@ -79,6 +79,7 @@ class UpdateMethods(UserMethods):
event = events.Raw()
self._events_pending_resolve.append(event)
self._event_builders_count[type(event)] += 1
self._event_builders.append((event, callback))
def remove_event_handler(self, callback, event=None):
@ -97,6 +98,11 @@ class UpdateMethods(UserMethods):
i -= 1
ev, cb = self._event_builders[i]
if cb == callback and (not event or isinstance(ev, event)):
type_ev = type(ev)
self._event_builders_count[type_ev] -= 1
if not self._event_builders_count[type_ev]:
del self._event_builders_count[type_ev]
del self._event_builders[i]
found += 1
@ -164,7 +170,7 @@ class UpdateMethods(UserMethods):
for u in update.updates:
u._entities = entities
self._handle_update(u)
if isinstance(update, types.UpdateShort):
elif isinstance(update, types.UpdateShort):
self._handle_update(update.update)
else:
update._entities = getattr(update, '_entities', {})
@ -177,7 +183,7 @@ class UpdateMethods(UserMethods):
syncio.create_task(self._dispatch_queue_updates)
need_diff = False
if hasattr(update, 'pts'):
if hasattr(update, 'pts') and update.pts is not None:
if self._state.pts and (update.pts - self._state.pts) > 1:
need_diff = True
self._state.pts = update.pts
@ -197,7 +203,7 @@ class UpdateMethods(UserMethods):
continue # We actually just want to act upon timeout
except concurrent.futures.TimeoutError:
pass
except:
except Exception as e:
continue # Any disconnected exception should be ignored
# We also don't really care about their result.
@ -241,9 +247,17 @@ class UpdateMethods(UserMethods):
self._events_pending_resolve.clear()
# TODO We can improve this further
# If we had a way to get all event builders for
# a type instead looping over them all always.
built = {builder: builder.build(update)
for builder in self._event_builders_count}
for builder, callback in self._event_builders:
event = builder.build(update)
if event:
event = built[type(builder)]
if not event or not builder.filter(event):
continue
if hasattr(event, '_set_client'):
event._set_client(self)
else:
@ -253,16 +267,15 @@ class UpdateMethods(UserMethods):
try:
callback(event)
except events.StopPropagation:
name = getattr(callback, '__name__', repr(callback))
__log__.debug(
"Event handler '{}' stopped chain of "
"propagation for event {}."
.format(callback.__name__,
type(event).__name__)
'Event handler "%s" stopped chain of propagation '
'for event %s.', name, type(event).__name__
)
break
except:
__log__.exception('Unhandled exception on {}'
.format(callback.__name__))
except Exception:
name = getattr(callback, '__name__', repr(callback))
__log__.exception('Unhandled exception on %s', name)
def _handle_auto_reconnect(self):
# Upon reconnection, we want to send getState

View File

@ -9,20 +9,14 @@ from mimetypes import guess_type
from .messageparse import MessageParseMethods
from .users import UserMethods
from .buttons import ButtonMethods
from .. import utils, helpers
from ..tl import types, functions, custom
try:
import hachoir
import hachoir.metadata
import hachoir.parser
except ImportError:
hachoir = None
__log__ = logging.getLogger(__name__)
class UploadMethods(MessageParseMethods, UserMethods):
class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods):
# region Public methods
@ -30,7 +24,8 @@ class UploadMethods(MessageParseMethods, UserMethods):
self, entity, file, *, caption='', force_document=False,
progress_callback=None, reply_to=None, attributes=None,
thumb=None, allow_cache=True, parse_mode=utils.Default,
voice_note=False, video_note=False, **kwargs):
voice_note=False, video_note=False, buttons=None, silent=None,
**kwargs):
"""
Sends a file to the specified entity.
@ -46,7 +41,8 @@ class UploadMethods(MessageParseMethods, UserMethods):
Furthermore the file may be any media (a message, document,
photo or similar) so that it can be resent without the need
to download and re-upload it again.
to download and re-upload it again. Bot API ``file_id``
format is also supported.
If a list or similar is provided, the files in it will be
sent as an album in the order in which they appear, sliced
@ -98,6 +94,18 @@ class UploadMethods(MessageParseMethods, UserMethods):
Set `allow_cache` to ``False`` if you sent the same file
without this setting before for it to work.
buttons (`list`, `custom.Button <telethon.tl.custom.button.Button>`,
:tl:`KeyboardButton`):
The matrix (list of lists), row list or button to be shown
after sending the message. This parameter will only work if
you have signed in as a bot. You can also pass your own
:tl:`ReplyMarkup` here.
silent (`bool`, optional):
Whether the message should notify people in a broadcast
channel or not. Defaults to ``False``, which means it will
notify them. Set it to ``True`` to alter this behaviour.
Notes:
If the ``hachoir3`` package (``hachoir`` module) is installed,
it will be used to determine metadata from audio and video files.
@ -126,7 +134,7 @@ class UploadMethods(MessageParseMethods, UserMethods):
result += self._send_album(
entity, images[:10], caption=caption,
progress_callback=progress_callback, reply_to=reply_to,
parse_mode=parse_mode
parse_mode=parse_mode, silent=silent
)
images = images[10:]
@ -136,7 +144,8 @@ class UploadMethods(MessageParseMethods, UserMethods):
caption=caption, force_document=force_document,
progress_callback=progress_callback, reply_to=reply_to,
attributes=attributes, thumb=thumb, voice_note=voice_note,
video_note=video_note, **kwargs
video_note=video_note, buttons=buttons, silent=silent,
**kwargs
))
return result
@ -159,9 +168,10 @@ class UploadMethods(MessageParseMethods, UserMethods):
voice_note=voice_note, video_note=video_note
)
markup = self.build_reply_markup(buttons)
request = functions.messages.SendMediaRequest(
entity, media, reply_to_msg_id=reply_to, message=caption,
entities=msg_entities
entities=msg_entities, reply_markup=markup, silent=silent
)
msg = self._get_response_message(request, self(request), entity)
self._cache_media(msg, file, file_handle, force_document=force_document)
@ -170,7 +180,7 @@ class UploadMethods(MessageParseMethods, UserMethods):
def _send_album(self, entity, files, caption='',
progress_callback=None, reply_to=None,
parse_mode=utils.Default):
parse_mode=utils.Default, silent=None):
"""Specialized version of .send_file for albums"""
# We don't care if the user wants to avoid cache, we will use it
# anyway. Why? The cached version will be exactly the same thing
@ -208,12 +218,15 @@ class UploadMethods(MessageParseMethods, UserMethods):
caption, msg_entities = captions.pop()
else:
caption, msg_entities = '', None
media.append(types.InputSingleMedia(types.InputMediaPhoto(fh), message=caption,
entities=msg_entities))
media.append(types.InputSingleMedia(
types.InputMediaPhoto(fh),
message=caption,
entities=msg_entities
))
# Now we can construct the multi-media request
result = self(functions.messages.SendMultiMediaRequest(
entity, reply_to_msg_id=reply_to, multi_media=media
entity, reply_to_msg_id=reply_to, multi_media=media, silent=silent
))
return [
self._get_response_message(update.id, result, entity)
@ -262,7 +275,7 @@ class UploadMethods(MessageParseMethods, UserMethods):
Returns:
:tl:`InputFileBig` if the file size is larger than 10MB,
`telethon.tl.custom.input_sized_file.InputSizedFile`
`telethon.tl.custom.inputsizedfile.InputSizedFile`
(subclass of :tl:`InputFile`) otherwise.
"""
if isinstance(file, (types.InputFile, types.InputFileBig)):
@ -377,10 +390,15 @@ class UploadMethods(MessageParseMethods, UserMethods):
return None, None # Can't turn whatever was given into media
media = None
file_handle = None
as_image = utils.is_image(file) and not force_document
use_cache = types.InputPhoto if as_image else types.InputDocument
if isinstance(file, str) and re.match('https?://', file):
file_handle = None
if not isinstance(file, str):
file_handle = self.upload_file(
file, progress_callback=progress_callback,
use_cache=use_cache if allow_cache else None
)
elif re.match('https?://', file):
if as_image:
media = types.InputMediaPhotoExternal(file)
elif not force_document and utils.is_gif(file):
@ -388,10 +406,9 @@ class UploadMethods(MessageParseMethods, UserMethods):
else:
media = types.InputMediaDocumentExternal(file)
else:
file_handle = self.upload_file(
file, progress_callback=progress_callback,
use_cache=use_cache if allow_cache else None
)
bot_file = utils.resolve_bot_file_id(file)
if bot_file:
media = utils.get_input_media(bot_file)
if media:
pass # Already have media, don't check the rest
@ -404,75 +421,14 @@ class UploadMethods(MessageParseMethods, UserMethods):
elif as_image:
media = types.InputMediaUploadedPhoto(file_handle)
else:
mime_type = None
if isinstance(file, str):
# Determine mime-type and attributes
# Take the first element by using [0] since it returns a tuple
mime_type = guess_type(file)[0]
attr_dict = {
types.DocumentAttributeFilename:
types.DocumentAttributeFilename(
os.path.basename(file))
}
if utils.is_audio(file) and hachoir:
with hachoir.parser.createParser(file) as parser:
m = hachoir.metadata.extractMetadata(parser)
attr_dict[types.DocumentAttributeAudio] = \
types.DocumentAttributeAudio(
voice=voice_note,
title=m.get('title') if m.has(
'title') else None,
performer=m.get('author') if m.has(
'author') else None,
duration=int(m.get('duration').seconds
if m.has('duration') else 0)
attributes, mime_type = utils.get_attributes(
file,
attributes=attributes,
force_document=force_document,
voice_note=voice_note,
video_note=video_note
)
if not force_document and utils.is_video(file):
if hachoir:
with hachoir.parser.createParser(file) as parser:
m = hachoir.metadata.extractMetadata(parser)
doc = types.DocumentAttributeVideo(
round_message=video_note,
w=m.get('width') if m.has('width') else 0,
h=m.get('height') if m.has('height') else 0,
duration=int(m.get('duration').seconds
if m.has('duration') else 0)
)
else:
doc = types.DocumentAttributeVideo(
0, 1, 1, round_message=video_note)
attr_dict[types.DocumentAttributeVideo] = doc
else:
attr_dict = {
types.DocumentAttributeFilename:
types.DocumentAttributeFilename(
os.path.basename(
getattr(file, 'name',
None) or 'unnamed'))
}
if voice_note:
if types.DocumentAttributeAudio in attr_dict:
attr_dict[types.DocumentAttributeAudio].voice = True
else:
attr_dict[types.DocumentAttributeAudio] = \
types.DocumentAttributeAudio(0, voice=True)
# Now override the attributes if any. As we have a dict of
# {cls: instance}, we can override any class with the list
# of attributes provided by the user easily.
if attributes:
for a in attributes:
attr_dict[type(a)] = a
# Ensure we have a mime type, any; but it cannot be None
# 'The "octet-stream" subtype is used to indicate that a body
# contains arbitrary binary data.'
if not mime_type:
mime_type = 'application/octet-stream'
input_kw = {}
if thumb:
input_kw['thumb'] = self.upload_file(thumb)
@ -480,7 +436,7 @@ class UploadMethods(MessageParseMethods, UserMethods):
media = types.InputMediaUploadedDocument(
file=file_handle,
mime_type=mime_type,
attributes=list(attr_dict.values()),
attributes=attributes,
**input_kw
)
return file_handle, media

View File

@ -18,6 +18,20 @@ class UserMethods(TelegramBaseClient):
raise _NOT_A_REQUEST
r.resolve(self, utils)
# Avoid making the request if it's already in a flood wait
if r.CONSTRUCTOR_ID in self._flood_waited_requests:
due = self._flood_waited_requests[r.CONSTRUCTOR_ID]
diff = round(due - time.time())
if diff <= 3: # Flood waits below 3 seconds are "ignored"
self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None)
elif diff <= self.flood_sleep_threshold:
__log__.info('Sleeping early for %ds on flood wait', diff)
time.sleep(diff)
self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None)
else:
raise errors.FloodWaitError(capture=diff)
request_index = 0
self._last_request = time.time()
for _ in range(self._request_retries):
try:
@ -28,6 +42,7 @@ class UserMethods(TelegramBaseClient):
result = f.result()
self.session.process_entities(result)
results.append(result)
request_index += 1
return results
else:
result = future.result()
@ -37,6 +52,12 @@ class UserMethods(TelegramBaseClient):
__log__.warning('Telegram is having internal issues %s: %s',
e.__class__.__name__, e)
except (errors.FloodWaitError, errors.FloodTestPhoneWaitError) as e:
if utils.is_list_like(request):
request = request[request_index]
self._flood_waited_requests\
[request.CONSTRUCTOR_ID] = time.time() + e.seconds
if e.seconds <= self.flood_sleep_threshold:
__log__.info('Sleeping for %ds on flood wait', e.seconds)
time.sleep(e.seconds)

View File

@ -1,13 +1,29 @@
"""
AES IGE implementation in Python. This module may use libssl if available.
AES IGE implementation in Python.
If available, cryptg will be used instead, otherwise
if available, libssl will be used instead, otherwise
the Python implementation will be used.
"""
import os
import pyaes
import logging
from . import libssl
__log__ = logging.getLogger(__name__)
try:
import cryptg
__log__.info('cryptg detected, it will be used for encryption')
except ImportError:
cryptg = None
if libssl.encrypt_ige and libssl.decrypt_ige:
__log__.info('libssl detected, it will be used for encryption')
else:
__log__.info('cryptg module not installed and libssl not found, '
'falling back to (slower) Python encryption')
class AES:
@ -23,6 +39,8 @@ class AES:
"""
if cryptg:
return cryptg.decrypt_ige(cipher_text, key, iv)
if libssl.decrypt_ige:
return libssl.decrypt_ige(cipher_text, key, iv)
iv1 = iv[:len(iv) // 2]
iv2 = iv[len(iv) // 2:]
@ -56,13 +74,14 @@ class AES:
Encrypts the given text in 16-bytes blocks by using the
given key and 32-bytes initialization vector.
"""
# Add random padding iff it's not evenly divisible by 16 already
if len(plain_text) % 16 != 0:
padding_count = 16 - len(plain_text) % 16
plain_text += os.urandom(padding_count)
padding = len(plain_text) % 16
if padding:
plain_text += os.urandom(16 - padding)
if cryptg:
return cryptg.encrypt_ige(plain_text, key, iv)
if libssl.encrypt_ige:
return libssl.encrypt_ige(plain_text, key, iv)
iv1 = iv[:len(iv) // 2]
iv2 = iv[len(iv) // 2:]

69
telethon/crypto/libssl.py Normal file
View File

@ -0,0 +1,69 @@
"""
Helper module around the system's libssl library if available for IGE mode.
"""
import ctypes
import ctypes.util
lib = ctypes.util.find_library('ssl')
if not lib:
decrypt_ige = None
encrypt_ige = None
else:
_libssl = ctypes.cdll.LoadLibrary(lib)
# https://github.com/openssl/openssl/blob/master/include/openssl/aes.h
AES_ENCRYPT = ctypes.c_int(1)
AES_DECRYPT = ctypes.c_int(0)
AES_MAXNR = 14
class AES_KEY(ctypes.Structure):
"""Helper class representing an AES key"""
_fields_ = [
('rd_key', ctypes.c_uint32 * (4 * (AES_MAXNR + 1))),
('rounds', ctypes.c_uint),
]
def decrypt_ige(cipher_text, key, iv):
aes_key = AES_KEY()
key_len = ctypes.c_int(8 * len(key))
key = (ctypes.c_ubyte * len(key))(*key)
iv = (ctypes.c_ubyte * len(iv))(*iv)
in_len = ctypes.c_size_t(len(cipher_text))
in_ptr = (ctypes.c_ubyte * len(cipher_text))(*cipher_text)
out_ptr = (ctypes.c_ubyte * len(cipher_text))()
_libssl.AES_set_decrypt_key(key, key_len, ctypes.byref(aes_key))
_libssl.AES_ige_encrypt(
ctypes.byref(in_ptr),
ctypes.byref(out_ptr),
in_len,
ctypes.byref(aes_key),
ctypes.byref(iv),
AES_DECRYPT
)
return bytes(out_ptr)
def encrypt_ige(plain_text, key, iv):
aes_key = AES_KEY()
key_len = ctypes.c_int(8 * len(key))
key = (ctypes.c_ubyte * len(key))(*key)
iv = (ctypes.c_ubyte * len(iv))(*iv)
in_len = ctypes.c_size_t(len(plain_text))
in_ptr = (ctypes.c_ubyte * len(plain_text))(*plain_text)
out_ptr = (ctypes.c_ubyte * len(plain_text))()
_libssl.AES_set_encrypt_key(key, key_len, ctypes.byref(aes_key))
_libssl.AES_ige_encrypt(
ctypes.byref(in_ptr),
ctypes.byref(out_ptr),
in_len,
ctypes.byref(aes_key),
ctypes.byref(iv),
AES_ENCRYPT
)
return bytes(out_ptr)

View File

@ -36,7 +36,7 @@ def report_error(code, message, report_method):
)
url.read()
url.close()
except:
except Exception as e:
"We really don't want to crash when just reporting an error"
@ -65,5 +65,8 @@ def rpc_message_to_error(rpc_error, report_method=None):
capture = int(m.group(1)) if m.groups() else None
return cls(capture=capture)
cls = base_errors.get(rpc_error.error_code, RPCError)
cls = base_errors.get(rpc_error.error_code)
if cls:
return cls(rpc_error.error_message)
return RPCError(rpc_error.error_code, rpc_error.error_message)

View File

@ -3,8 +3,13 @@ class RPCError(Exception):
code = None
message = None
def __init__(self, code, message):
super().__init__('RPCError {}: {}'.format(code, message))
self.code = code
self.message = message
def __reduce__(self):
return type(self), ()
return type(self), (self.code, self.message)
class InvalidDCError(RPCError):

View File

@ -5,6 +5,8 @@ from .messageedited import MessageEdited
from .messageread import MessageRead
from .newmessage import NewMessage
from .userupdate import UserUpdate
from .callbackquery import CallbackQuery
from .inlinequery import InlineQuery
class StopPropagation(Exception):

View File

@ -0,0 +1,254 @@
import re
from .common import EventBuilder, EventCommon, name_inner_event
from .. import utils
from ..tl import types, functions
from ..tl.custom.sendergetter import SenderGetter
@name_inner_event
class CallbackQuery(EventBuilder):
"""
Represents a callback query event (when an inline button is clicked).
Note that the `chats` parameter will **not** work with normal
IDs or peers if the clicked inline button comes from a "via bot"
message. The `chats` parameter also supports checking against the
`chat_instance` which should be used for inline callbacks.
Args:
data (`bytes` | `str` | `callable`, optional):
If set, the inline button payload data must match this data.
A UTF-8 string can also be given, a regex or a callable. For
instance, to check against ``'data_1'`` and ``'data_2'`` you
can use ``re.compile(b'data_')``.
"""
def __init__(self, chats=None, *, blacklist_chats=False, data=None):
super().__init__(chats=chats, blacklist_chats=blacklist_chats)
if isinstance(data, bytes):
self.data = data
elif isinstance(data, str):
self.data = data.encode('utf-8')
elif not data or callable(data):
self.data = data
elif hasattr(data, 'match') and callable(data.match):
if not isinstance(getattr(data, 'pattern', b''), bytes):
data = re.compile(data.pattern.encode('utf-8'), data.flags)
self.data = data.match
else:
raise TypeError('Invalid data type given')
@classmethod
def build(cls, update):
if isinstance(update, (types.UpdateBotCallbackQuery,
types.UpdateInlineBotCallbackQuery)):
event = cls.Event(update)
else:
return
event._entities = update._entities
return event
def filter(self, event):
if self.chats is not None:
inside = event.query.chat_instance in self.chats
if event.chat_id:
inside |= event.chat_id in self.chats
if inside == self.blacklist_chats:
return None
if self.data:
if callable(self.data):
event.data_match = self.data(event.query.data)
if not event.data_match:
return None
elif event.query.data != self.data:
return None
return event
class Event(EventCommon, SenderGetter):
"""
Represents the event of a new callback query.
Members:
query (:tl:`UpdateBotCallbackQuery`):
The original :tl:`UpdateBotCallbackQuery`.
data_match (`obj`, optional):
The object returned by the ``data=`` parameter
when creating the event builder, if any. Similar
to ``pattern_match`` for the new message event.
"""
def __init__(self, query):
super().__init__(chat_peer=getattr(query, 'peer', None),
msg_id=query.msg_id)
self.query = query
self.data_match = None
self._sender_id = query.user_id
self._input_sender = None
self._sender = None
self._message = None
self._answered = False
@property
def id(self):
"""
Returns the query ID. The user clicking the inline
button is the one who generated this random ID.
"""
return self.query.query_id
@property
def message_id(self):
"""
Returns the message ID to which the clicked inline button belongs.
"""
return self.query.msg_id
@property
def data(self):
"""
Returns the data payload from the original inline button.
"""
return self.query.data
@property
def chat_instance(self):
"""
Unique identifier for the chat where the callback occurred.
Useful for high scores in games.
"""
return self.query.chat_instance
def get_message(self):
"""
Returns the message to which the clicked inline button belongs.
"""
if self._message is not None:
return self._message
try:
chat = self.get_input_chat() if self.is_channel else None
self._message = self._client.get_messages(
chat, ids=self.query.msg_id)
except ValueError:
return
return self._message
def _refetch_sender(self):
self._sender = self._entities.get(self.sender_id)
if not self._sender:
return
self._input_sender = utils.get_input_peer(self._chat)
if not getattr(self._input_sender, 'access_hash', True):
# getattr with True to handle the InputPeerSelf() case
try:
self._input_sender = self._client.session.get_input_entity(
self._sender_id
)
except ValueError:
m = self.get_message()
if m:
self._sender = m._sender
self._input_sender = m._input_sender
def answer(
self, message=None, cache_time=0, *, url=None, alert=False):
"""
Answers the callback query (and stops the loading circle).
Args:
message (`str`, optional):
The toast message to show feedback to the user.
cache_time (`int`, optional):
For how long this result should be cached on
the user's client. Defaults to 0 for no cache.
url (`str`, optional):
The URL to be opened in the user's client. Note that
the only valid URLs are those of games your bot has,
or alternatively a 't.me/your_bot?start=xyz' parameter.
alert (`bool`, optional):
Whether an alert (a pop-up dialog) should be used
instead of showing a toast. Defaults to ``False``.
"""
if self._answered:
return
self._answered = True
return self._client(
functions.messages.SetBotCallbackAnswerRequest(
query_id=self.query.query_id,
cache_time=cache_time,
alert=alert,
message=message,
url=url
)
)
def respond(self, *args, **kwargs):
"""
Responds to the message (not as a reply). Shorthand for
`telethon.telegram_client.TelegramClient.send_message` with
``entity`` already set.
This method also creates a task to `answer` the callback.
"""
self._client.loop.create_task(self.answer())
return self._client.send_message(
self.get_input_chat(), *args, **kwargs)
def reply(self, *args, **kwargs):
"""
Replies to the message (as a reply). Shorthand for
`telethon.telegram_client.TelegramClient.send_message` with
both ``entity`` and ``reply_to`` already set.
This method also creates a task to `answer` the callback.
"""
self._client.loop.create_task(self.answer())
kwargs['reply_to'] = self.query.msg_id
return self._client.send_message(
self.get_input_chat(), *args, **kwargs)
def edit(self, *args, **kwargs):
"""
Edits the message iff it's outgoing. Shorthand for
`telethon.telegram_client.TelegramClient.edit_message` with
both ``entity`` and ``message`` already set.
Returns the edited :tl:`Message`.
This method also creates a task to `answer` the callback.
"""
self._client.loop.create_task(self.answer())
return self._client.edit_message(
self.get_input_chat(), self.query.msg_id,
*args, **kwargs
)
def delete(self, *args, **kwargs):
"""
Deletes the message. Shorthand for
`telethon.telegram_client.TelegramClient.delete_messages` with
``entity`` and ``message_ids`` already set.
If you need to delete more than one message at once, don't use
this `delete` method. Use a
`telethon.telegram_client.TelegramClient` instance directly.
This method also creates a task to `answer` the callback.
"""
self._client.loop.create_task(self.answer())
return self._client.delete_messages(
self.get_input_chat(), [self.query.msg_id],
*args, **kwargs
)

View File

@ -8,21 +8,22 @@ class ChatAction(EventBuilder):
"""
Represents an action in a chat (such as user joined, left, or new pin).
"""
def build(self, update):
@classmethod
def build(cls, update):
if isinstance(update, types.UpdateChannelPinnedMessage) and update.id == 0:
# Telegram does not always send
# UpdateChannelPinnedMessage for new pins
# but always for unpin, with update.id = 0
event = ChatAction.Event(types.PeerChannel(update.channel_id),
event = cls.Event(types.PeerChannel(update.channel_id),
unpin=True)
elif isinstance(update, types.UpdateChatParticipantAdd):
event = ChatAction.Event(types.PeerChat(update.chat_id),
event = cls.Event(types.PeerChat(update.chat_id),
added_by=update.inviter_id or True,
users=update.user_id)
elif isinstance(update, types.UpdateChatParticipantDelete):
event = ChatAction.Event(types.PeerChat(update.chat_id),
event = cls.Event(types.PeerChat(update.chat_id),
kicked_by=True,
users=update.user_id)
@ -32,44 +33,44 @@ class ChatAction(EventBuilder):
msg = update.message
action = update.message.action
if isinstance(action, types.MessageActionChatJoinedByLink):
event = ChatAction.Event(msg,
event = cls.Event(msg,
added_by=True,
users=msg.from_id)
elif isinstance(action, types.MessageActionChatAddUser):
# If an user adds itself, it means they joined
added_by = ([msg.from_id] == action.users) or msg.from_id
event = ChatAction.Event(msg,
event = cls.Event(msg,
added_by=added_by,
users=action.users)
elif isinstance(action, types.MessageActionChatDeleteUser):
event = ChatAction.Event(msg,
event = cls.Event(msg,
kicked_by=msg.from_id or True,
users=action.user_id)
elif isinstance(action, types.MessageActionChatCreate):
event = ChatAction.Event(msg,
event = cls.Event(msg,
users=action.users,
created=True,
new_title=action.title)
elif isinstance(action, types.MessageActionChannelCreate):
event = ChatAction.Event(msg,
event = cls.Event(msg,
created=True,
users=msg.from_id,
new_title=action.title)
elif isinstance(action, types.MessageActionChatEditTitle):
event = ChatAction.Event(msg,
event = cls.Event(msg,
users=msg.from_id,
new_title=action.title)
elif isinstance(action, types.MessageActionChatEditPhoto):
event = ChatAction.Event(msg,
event = cls.Event(msg,
users=msg.from_id,
new_photo=action.photo)
elif isinstance(action, types.MessageActionChatDeletePhoto):
event = ChatAction.Event(msg,
event = cls.Event(msg,
users=msg.from_id,
new_photo=True)
elif isinstance(action, types.MessageActionPinMessage):
# Telegram always sends this service message for new pins
event = ChatAction.Event(msg,
event = cls.Event(msg,
users=msg.from_id,
new_pin=msg.reply_to_msg_id)
else:
@ -78,7 +79,7 @@ class ChatAction(EventBuilder):
return
event._entities = update._entities
return self._filter_event(event)
return event
class Event(EventCommon):
"""
@ -163,8 +164,7 @@ class ChatAction(EventBuilder):
def _set_client(self, client):
super()._set_client(client)
if self.action_message:
self.action_message = custom.Message(
client, self.action_message, self._entities, None)
self.action_message._finish_init(client, self._entities, None)
def respond(self, *args, **kwargs):
"""

View File

@ -3,6 +3,7 @@ import warnings
from .. import utils
from ..tl import TLObject, types
from ..tl.custom.chatgetter import ChatGetter
def _into_id_set(client, chats):
@ -42,8 +43,8 @@ class EventBuilder(abc.ABC):
Args:
chats (`entity`, optional):
May be one or more entities (username/peer/etc.). By default,
only matching chats will be handled.
May be one or more entities (username/peer/etc.), preferably IDs.
By default, only matching chats will be handled.
blacklist_chats (`bool`, optional):
Whether to treat the chats as a blacklist instead of
@ -51,21 +52,25 @@ class EventBuilder(abc.ABC):
will be handled *except* those specified in ``chats``
which will be ignored if ``blacklist_chats=True``.
"""
self_id = None
def __init__(self, chats=None, blacklist_chats=False):
self.chats = chats
self.blacklist_chats = blacklist_chats
self._self_id = None
@classmethod
@abc.abstractmethod
def build(self, update):
def build(cls, update):
"""Builds an event for the given update if possible, or returns None"""
def resolve(self, client):
"""Helper method to allow event builders to be resolved before usage"""
self.chats = _into_id_set(client, self.chats)
self._self_id = (client.get_me(input_peer=True)).user_id
if not EventBuilder.self_id:
EventBuilder.self_id = client.get_peer_id('me')
def _filter_event(self, event):
def filter(self, event):
"""
If the ID of ``event._chat_peer`` isn't in the chats set (or it is
but the set is a blacklist) returns ``None``, otherwise the event.
@ -79,13 +84,16 @@ class EventBuilder(abc.ABC):
return event
class EventCommon(abc.ABC):
class EventCommon(ChatGetter, abc.ABC):
"""
Intermediate class with common things to all events.
All events (except `Raw`) have ``is_private``, ``is_group``
and ``is_channel`` boolean properties, as well as an
``original_update`` field containing the original :tl:`Update`.
Remember that this class implements `ChatGetter
<telethon.tl.custom.chatgetter.ChatGetter>` which
means you have access to all chat properties and methods.
In addition, you can access the `original_update`
field which contains the original :tl:`Update`.
"""
_event_name = 'Event'
@ -96,64 +104,27 @@ class EventCommon(abc.ABC):
self._message_id = msg_id
self._input_chat = None
self._chat = None
self._broadcast = broadcast
self.original_update = None
self.is_private = isinstance(chat_peer, types.PeerUser)
self.is_group = (
isinstance(chat_peer, (types.PeerChat, types.PeerChannel))
and not broadcast
)
self.is_channel = isinstance(chat_peer, types.PeerChannel)
def _set_client(self, client):
"""
Setter so subclasses can act accordingly when the client is set.
"""
self._client = client
self._chat = self._entities.get(self.chat_id)
if not self._chat:
return
@property
def input_chat(self):
"""
This (:tl:`InputPeer`) is the input version of the chat where the
event occurred. This doesn't have things like username or similar,
but is still useful in some cases.
Note that this might not be available if the library doesn't have
enough information available.
"""
if self._input_chat is None and self._chat_peer is not None:
self._input_chat = utils.get_input_peer(self._chat)
if not getattr(self._input_chat, 'access_hash', True):
# getattr with True to handle the InputPeerSelf() case
try:
self._input_chat =\
self._client.session.get_input_entity(self._chat_peer)
self._input_chat = self._client.session.get_input_entity(
self._chat_peer
)
except ValueError:
pass
return self._input_chat
def get_input_chat(self):
"""
Returns `input_chat`, but will make an API call to find the
input chat unless it's already cached.
"""
if self.input_chat is None and self._chat_peer is not None:
ch = isinstance(self._chat_peer, types.PeerChannel)
if not ch and self._message_id is not None:
msg = self._client.get_messages(
None, ids=self._message_id)
self._chat = msg._chat
self._input_chat = msg._input_chat
else:
target = utils.get_peer_id(self._chat_peer)
for d in self._client.iter_dialogs(100):
if d.id == target:
self._chat = d.entity
self._input_chat = d.input_entity
# TODO Don't break, exhaust the iterator, otherwise
# async_generator raises RuntimeError: partially-
# exhausted async_generator 'xyz' garbage collected
# break
return self._input_chat
self._input_chat = None
@property
def client(self):
@ -162,44 +133,6 @@ class EventCommon(abc.ABC):
"""
return self._client
@property
def chat(self):
"""
The :tl:`User`, :tl:`Chat` or :tl:`Channel` on which
the event occurred. This property may make an API call the first time
to get the most up to date version of the chat (mostly when the event
doesn't belong to a channel), so keep that in mind. You should use
`get_chat` instead, unless you want to avoid an API call.
"""
if not self.input_chat:
return None
if self._chat is None:
self._chat = self._entities.get(utils.get_peer_id(self._chat_peer))
return self._chat
def get_chat(self):
"""
Returns `chat`, but will make an API call to find the
chat unless it's already cached.
"""
if self.chat is None and self.get_input_chat():
try:
self._chat =\
self._client.get_entity(self._input_chat)
except ValueError:
pass
return self._chat
@property
def chat_id(self):
"""
Returns the marked integer ID of the chat, if any.
"""
if self._chat_peer:
return utils.get_peer_id(self._chat_peer)
def __str__(self):
return TLObject.pretty_format(self.to_dict())

View File

@ -0,0 +1,177 @@
import inspect
import re
import asyncio
from .common import EventBuilder, EventCommon, name_inner_event
from .. import utils
from ..tl import types, functions, custom
from ..tl.custom.sendergetter import SenderGetter
@name_inner_event
class InlineQuery(EventBuilder):
"""
Represents an inline query event (when someone writes ``'@my_bot query'``).
Args:
users (`entity`, optional):
May be one or more entities (username/peer/etc.), preferably IDs.
By default, only inline queries from these users will be handled.
blacklist_users (`bool`, optional):
Whether to treat the users as a blacklist instead of
as a whitelist (default). This means that every chat
will be handled *except* those specified in ``users``
which will be ignored if ``blacklist_users=True``.
pattern (`str`, `callable`, `Pattern`, optional):
If set, only queries matching this pattern will be handled.
You can specify a regex-like string which will be matched
against the message, a callable function that returns ``True``
if a message is acceptable, or a compiled regex pattern.
"""
def __init__(self, users=None, *, blacklist_users=False, pattern=None):
super().__init__(chats=users, blacklist_chats=blacklist_users)
if isinstance(pattern, str):
self.pattern = re.compile(pattern).match
elif not pattern or callable(pattern):
self.pattern = pattern
elif hasattr(pattern, 'match') and callable(pattern.match):
self.pattern = pattern.match
else:
raise TypeError('Invalid pattern type given')
@classmethod
def build(cls, update):
if isinstance(update, types.UpdateBotInlineQuery):
event = cls.Event(update)
else:
return
event._entities = update._entities
return event
def filter(self, event):
if self.pattern:
match = self.pattern(event.text)
if not match:
return
event.pattern_match = match
return super().filter(event)
class Event(EventCommon, SenderGetter):
"""
Represents the event of a new callback query.
Members:
query (:tl:`UpdateBotCallbackQuery`):
The original :tl:`UpdateBotCallbackQuery`.
pattern_match (`obj`, optional):
The resulting object from calling the passed ``pattern``
function, which is ``re.compile(...).match`` by default.
"""
def __init__(self, query):
super().__init__(chat_peer=types.PeerUser(query.user_id))
self.query = query
self.pattern_match = None
self._answered = False
self._sender_id = query.user_id
self._input_sender = None
self._sender = None
@property
def id(self):
"""
Returns the unique identifier for the query ID.
"""
return self.query.query_id
@property
def text(self):
"""
Returns the text the user used to make the inline query.
"""
return self.query.query
@property
def offset(self):
"""
???
"""
return self.query.offset
@property
def geo(self):
"""
If the user location is requested when using inline mode
and the user's device is able to send it, this will return
the :tl:`GeoPoint` with the position of the user.
"""
return
@property
def builder(self):
"""
Returns a new `inline result builder
<telethon.tl.custom.inline.InlineBuilder>`.
"""
return custom.InlineBuilder(self._client)
def answer(
self, results=None, cache_time=0, *,
gallery=False, private=False,
switch_pm=None, switch_pm_param=''):
"""
Answers the inline query with the given results.
Args:
results (`list`, optional):
A list of :tl:`InputBotInlineResult` to use.
You should use `builder` to create these:
.. code-block: python
builder = inline.builder
r1 = builder.article('Be nice', text='Have a nice day')
r2 = builder.article('Be bad', text="I don't like you")
inline.answer([r1, r2])
cache_time (`int`, optional):
For how long this result should be cached on
the user's client. Defaults to 0 for no cache.
gallery (`bool`, optional):
Whether the results should show as a gallery (grid) or not.
private (`bool`, optional):
Whether the results should be cached by Telegram
(not private) or by the user's client (private).
switch_pm (`str`, optional):
If set, this text will be shown in the results
to allow the user to switch to private messages.
switch_pm_param (`str`, optional):
Optional parameter to start the bot with if
`switch_pm` was used.
"""
if self._answered:
return
if switch_pm:
switch_pm = types.InlineBotSwitchPM(switch_pm, switch_pm_param)
return self._client(
functions.messages.SetInlineBotResultsRequest(
query_id=self.query.query_id,
results=results,
cache_time=cache_time,
gallery=gallery,
private=private,
switch_pm=switch_pm
)
)

View File

@ -7,14 +7,15 @@ class MessageDeleted(EventBuilder):
"""
Event fired when one or more messages are deleted.
"""
def build(self, update):
@classmethod
def build(cls, update):
if isinstance(update, types.UpdateDeleteMessages):
event = MessageDeleted.Event(
event = cls.Event(
deleted_ids=update.messages,
peer=None
)
elif isinstance(update, types.UpdateDeleteChannelMessages):
event = MessageDeleted.Event(
event = cls.Event(
deleted_ids=update.messages,
peer=types.PeerChannel(update.channel_id)
)
@ -22,19 +23,12 @@ class MessageDeleted(EventBuilder):
return
event._entities = update._entities
return self._filter_event(event)
return event
class Event(EventCommon):
def __init__(self, deleted_ids, peer):
super().__init__(
chat_peer=peer, msg_id=(deleted_ids or [0])[0]
)
if peer is None:
# If it's not a channel ID, then it was private/small group.
# We can't know which one was exactly unless we logged all
# messages, but we can indicate that it was maybe either of
# both by setting them both to True.
self.is_private = self.is_group = True
self.deleted_id = None if not deleted_ids else deleted_ids[0]
self.deleted_ids = deleted_ids

View File

@ -8,15 +8,16 @@ class MessageEdited(NewMessage):
"""
Event fired when a message has been edited.
"""
def build(self, update):
@classmethod
def build(cls, update):
if isinstance(update, (types.UpdateEditMessage,
types.UpdateEditChannelMessage)):
event = MessageEdited.Event(update.message)
event = cls.Event(update.message)
else:
return
event._entities = update._entities
return self._message_filter_event(event)
return event
class Event(NewMessage.Event):
pass # Required if we want a different name for it

View File

@ -18,32 +18,36 @@ class MessageRead(EventBuilder):
super().__init__(chats, blacklist_chats)
self.inbox = inbox
def build(self, update):
@classmethod
def build(cls, update):
if isinstance(update, types.UpdateReadHistoryInbox):
event = MessageRead.Event(update.peer, update.max_id, False)
event = cls.Event(update.peer, update.max_id, False)
elif isinstance(update, types.UpdateReadHistoryOutbox):
event = MessageRead.Event(update.peer, update.max_id, True)
event = cls.Event(update.peer, update.max_id, True)
elif isinstance(update, types.UpdateReadChannelInbox):
event = MessageRead.Event(types.PeerChannel(update.channel_id),
event = cls.Event(types.PeerChannel(update.channel_id),
update.max_id, False)
elif isinstance(update, types.UpdateReadChannelOutbox):
event = MessageRead.Event(types.PeerChannel(update.channel_id),
event = cls.Event(types.PeerChannel(update.channel_id),
update.max_id, True)
elif isinstance(update, types.UpdateReadMessagesContents):
event = MessageRead.Event(message_ids=update.messages,
event = cls.Event(message_ids=update.messages,
contents=True)
elif isinstance(update, types.UpdateChannelReadMessagesContents):
event = MessageRead.Event(types.PeerChannel(update.channel_id),
event = cls.Event(types.PeerChannel(update.channel_id),
message_ids=update.messages,
contents=True)
else:
return
event._entities = update._entities
return event
def filter(self, event):
if self.inbox == event.outbox:
return
event._entities = update._entities
return self._filter_event(event)
return super().filter(event)
class Event(EventCommon):
"""

View File

@ -40,13 +40,12 @@ class NewMessage(EventBuilder):
def __init__(self, chats=None, *, blacklist_chats=False,
incoming=None, outgoing=None,
from_users=None, forwards=None, pattern=None):
if incoming is not None and outgoing is None:
if incoming and outgoing:
incoming = outgoing = None # Same as no filter
elif incoming is not None and outgoing is None:
outgoing = not incoming
elif outgoing is not None and incoming is None:
incoming = not outgoing
if incoming and outgoing:
self.incoming = self.outgoing = None # Same as no filter
elif all(x is not None and not x for x in (incoming, outgoing)):
raise ValueError("Don't create an event handler if you "
"don't want neither incoming or outgoing!")
@ -75,14 +74,15 @@ class NewMessage(EventBuilder):
super().resolve(client)
self.from_users = _into_id_set(client, self.from_users)
def build(self, update):
@classmethod
def build(cls, update):
if isinstance(update,
(types.UpdateNewMessage, types.UpdateNewChannelMessage)):
if not isinstance(update.message, types.Message):
return # We don't care about MessageService's here
event = NewMessage.Event(update.message)
event = cls.Event(update.message)
elif isinstance(update, types.UpdateShortMessage):
event = NewMessage.Event(types.Message(
event = cls.Event(types.Message(
out=update.out,
mentioned=update.mentioned,
media_unread=update.media_unread,
@ -91,9 +91,9 @@ class NewMessage(EventBuilder):
# Note that to_id/from_id complement each other in private
# messages, depending on whether the message was outgoing.
to_id=types.PeerUser(
update.user_id if update.out else self._self_id
update.user_id if update.out else cls.self_id
),
from_id=self._self_id if update.out else update.user_id,
from_id=cls.self_id if update.out else update.user_id,
message=update.message,
date=update.date,
fwd_from=update.fwd_from,
@ -102,7 +102,7 @@ class NewMessage(EventBuilder):
entities=update.entities
))
elif isinstance(update, types.UpdateShortChatMessage):
event = NewMessage.Event(types.Message(
event = cls.Event(types.Message(
out=update.out,
mentioned=update.mentioned,
media_unread=update.media_unread,
@ -120,8 +120,6 @@ class NewMessage(EventBuilder):
else:
return
event._entities = update._entities
# Make messages sent to ourselves outgoing unless they're forwarded.
# This makes it consistent with official client's appearance.
ori = event.message
@ -129,9 +127,10 @@ class NewMessage(EventBuilder):
if ori.from_id == ori.to_id.user_id and not ori.fwd_from:
event.message.out = True
return self._message_filter_event(event)
event._entities = update._entities
return event
def _message_filter_event(self, event):
def filter(self, event):
if self._no_check:
return event
@ -153,7 +152,7 @@ class NewMessage(EventBuilder):
return
event.pattern_match = match
return self._filter_event(event)
return super().filter(event)
class Event(EventCommon):
"""
@ -204,8 +203,7 @@ class NewMessage(EventBuilder):
def _set_client(self, client):
super()._set_client(client)
self.message = custom.Message(
client, self.message, self._entities, None)
self.message._finish_init(client, self._entities, None)
self.__dict__['_init'] = True # No new attributes can be set
def __getattr__(self, item):

View File

@ -25,6 +25,10 @@ class Raw(EventBuilder):
def resolve(self, client):
pass
def build(self, update):
if not self.types or isinstance(update, self.types):
@classmethod
def build(cls, update):
return update
def filter(self, event):
if not self.types or isinstance(event, self.types):
return event

View File

@ -9,15 +9,16 @@ class UserUpdate(EventBuilder):
"""
Represents an user update (gone online, offline, joined Telegram).
"""
def build(self, update):
@classmethod
def build(cls, update):
if isinstance(update, types.UpdateUserStatus):
event = UserUpdate.Event(update.user_id,
event = cls.Event(update.user_id,
status=update.status)
else:
return
event._entities = update._entities
return self._filter_event(event)
return event
class Event(EventCommon):
"""
@ -95,7 +96,8 @@ class UserUpdate(EventBuilder):
isinstance(status, types.UserStatusOnline) else None
if self.last_seen:
diff = datetime.datetime.now() - self.last_seen
now = datetime.datetime.now(tz=datetime.timezone.utc)
diff = now - self.last_seen
if diff < datetime.timedelta(days=30):
self.within_months = True
if diff < datetime.timedelta(days=7):

View File

@ -2,7 +2,7 @@
This module contains the BinaryReader utility class.
"""
import os
from datetime import datetime
from datetime import datetime, timezone
from io import BufferedReader, BytesIO
from struct import unpack
@ -120,7 +120,10 @@ class BinaryReader:
into a Python datetime object.
"""
value = self.read_int()
return None if value == 0 else datetime.utcfromtimestamp(value)
if value == 0:
return None
else:
return datetime.fromtimestamp(value, tz=timezone.utc)
def tgread_object(self):
"""Reads a Telegram object."""

View File

@ -67,6 +67,7 @@ class TcpClient:
if proxy is None:
s = socket.socket(mode, socket.SOCK_STREAM)
else:
__log__.info('Connection will be made through proxy %s', proxy)
import socks
s = socks.socksocket(mode, socket.SOCK_STREAM)
if isinstance(proxy, dict):
@ -92,11 +93,16 @@ class TcpClient:
try:
if self._socket is None:
self._socket = self._create_socket(mode, self.proxy)
if self.ssl and port == SSL_PORT:
self._socket = ssl.wrap_socket(self._socket, **self.ssl)
wrap_ssl = self.ssl and port == SSL_PORT
else:
wrap_ssl = False
self._socket.settimeout(self.timeout)
self._socket.connect(address)
if wrap_ssl:
self._socket = ssl.wrap_socket(
self._socket, do_handshake_on_connect=True, **self.ssl)
self._closed.clear()
except OSError as e:
if e.errno in CONN_RESET_ERRNOS:

View File

@ -1,6 +1,7 @@
"""Various helpers not related to the Telegram API itself"""
import os
import struct
import collections
from hashlib import sha1, sha256
@ -65,3 +66,22 @@ def get_password_hash(pw, current_salt):
return sha256(pw_hash).digest()
# endregion
# region Custom Classes
class TotalList(collections.UserList):
"""
A list with an extra `total` property, which may not match its `len`
since the total represents the total amount of items *available*
somewhere else, not the items *in this list*.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.total = 0
def __str__(self):
return '[{}, total={}]'.format(
', '.join(repr(x) for x in self), self.total)
# endregion

View File

@ -392,6 +392,7 @@ class MTProtoSender:
except socket.timeout:
continue
except concurrent.futures.CancelledError:
self.disconnect()
return
except Exception as e:
if isinstance(e, ConnectionError):
@ -431,6 +432,7 @@ class MTProtoSender:
except socket.timeout:
continue
except concurrent.futures.CancelledError:
self.disconnect()
return
except Exception as e:
if isinstance(e, ConnectionError):
@ -473,15 +475,19 @@ class MTProtoSender:
__log__.info('Server replied with an unknown type {:08x}: {!r}'
.format(e.invalid_constructor_id, e.remaining))
continue
except:
__log__.exception('Unhandled exception while unpacking')
except concurrent.futures.CancelledError:
self.disconnect()
return
except Exception as e:
__log__.exception('Unhandled exception while unpacking %s',e)
time.sleep(1)
else:
try:
self._process_message(message)
except concurrent.futures.CancelledError:
self.disconnect()
return
except:
except Exception as e:
__log__.exception('Unhandled exception while '
'processing %s', message)
time.sleep(1)
@ -512,6 +518,12 @@ class MTProtoSender:
__log__.debug('Handling RPC result for message %d',
rpc_result.req_msg_id)
if not message:
# TODO We should not get responses to things we never sent
__log__.info('Received response without parent request: {}'
.format(rpc_result.body))
return
if rpc_result.error:
error = rpc_message_to_error(rpc_result.error)
self._send_queue.put_nowait(self.state.create_message(
@ -520,8 +532,7 @@ class MTProtoSender:
if not message.future.cancelled():
message.future.set_exception(error)
return
elif message:
else:
# TODO Would be nice to avoid accessing a per-obj read_result
# Instead have a variable that indicated how the result should
# be read (an enum) and dispatch to read the result, mostly
@ -531,11 +542,6 @@ class MTProtoSender:
if not message.future.cancelled():
message.future.set_result(result)
return
else:
# TODO We should not get responses to things we never sent
__log__.info('Received response without parent request: {}'
.format(rpc_result.body))
def _handle_container(self, message):
"""
@ -611,7 +617,7 @@ class MTProtoSender:
bad_msg = message.obj
msg = self._pending_messages.get(bad_msg.bad_msg_id)
__log__.debug('Handling bad msg for message %d', bad_msg.bad_msg_id)
__log__.debug('Handling bad msg %s', bad_msg)
if bad_msg.error_code in (16, 17):
# Sent msg_id too low or too high (respectively).
# Use the current msg_id to determine the right time offset.

View File

@ -157,10 +157,20 @@ class MTProtoState:
Updates the time offset to the correct
one given a known valid message ID.
"""
bad = self._get_new_msg_id()
old = self.time_offset
now = int(time.time())
correct = correct_msg_id >> 32
self.time_offset = correct - now
if self.time_offset != old:
self._last_msg_id = 0
__log__.debug(
'Updated time offset (old offset %d, bad %d, good %d, new %d)',
old, bad, correct_msg_id, self.time_offset
)
return self.time_offset
def _get_seq_no(self, content_related):

View File

@ -42,6 +42,10 @@ class MemorySession(Session):
self._server_address = server_address
self._port = port
@property
def dc_id(self):
return self._dc_id
@property
def server_address(self):
return self._server_address

View File

@ -211,7 +211,8 @@ class SQLiteSession(MemorySession):
'where id = ?', entity_id)
if row:
pts, qts, date, seq = row
date = datetime.datetime.utcfromtimestamp(date)
date = datetime.datetime.fromtimestamp(
date, tz=datetime.timezone.utc)
return types.updates.State(pts, qts, date, seq, unread_count=0)
def set_update_state(self, entity_id, state):

View File

@ -1,6 +1,9 @@
from .draft import Draft
from .dialog import Dialog
from .input_sized_file import InputSizedFile
from .inputsizedfile import InputSizedFile
from .messagebutton import MessageButton
from .forward import Forward
from .message import Message
from .button import Button
from .inline import InlineBuilder
from .inlineresult import InlineResult

View File

@ -0,0 +1,134 @@
from .. import types
class Button:
"""
Helper class to allow defining ``reply_markup`` when
sending a message with inline or keyboard buttons.
You should make use of the defined class methods to create button
instances instead making them yourself (i.e. don't do ``Button(...)``
but instead use methods line `Button.inline(...) <inline>` etc.)
You can use `inline`, `switch_inline` and `url`
together to create inline buttons (under the message).
You can use `text`, `request_location` and `request_phone`
together to create a reply markup (replaces the user keyboard).
You **cannot** mix the two type of buttons together,
and it will error if you try to do so.
The text for all buttons may be at most 142 characters.
If more characters are given, Telegram will cut the text
to 128 characters and add the ellipsis () character as
the 129.
"""
def __init__(self, button, callback=None):
self.button = button
self.callback = callback
self.is_inline = self._is_inline(button)
@property
def data(self):
if isinstance(self.button, types.KeyboardButtonCallback):
return self.button.data
@classmethod
def _is_inline(cls, button):
"""
Returns ``True`` if the button belongs to an inline keyboard.
"""
if isinstance(button, cls):
return button.is_inline
else:
return isinstance(button, (
types.KeyboardButtonCallback,
types.KeyboardButtonSwitchInline,
types.KeyboardButtonUrl
))
@classmethod
def inline(cls, text, callback=None, data=None):
"""
Creates a new inline button.
The `callback` parameter should be a function callback accepting
a single parameter (the triggered event on click) if specified.
Otherwise, you should register the event manually.
If `data` is omitted, the given `text` will be used as `data`.
In any case `data` should be either ``bytes`` or ``str``.
Note that the given `data` must be less or equal to 64 bytes.
If more than 64 bytes are passed as data, ``ValueError`` is raised.
"""
if not data:
data = text.encode('utf-8')
if len(data) > 64:
raise ValueError('Too many bytes for the data')
return cls(types.KeyboardButtonCallback(text, data), callback)
@classmethod
def switch_inline(cls, text, query='', same_peer=False):
"""
Creates a new button to switch to inline query.
If `query` is given, it will be the default text to be used
when making the inline query.
If ``same_peer is True`` the inline query will directly be
set under the currently opened chat. Otherwise, the user will
have to select a different dialog to make the query.
"""
return cls(types.KeyboardButtonSwitchInline(text, query, same_peer))
@classmethod
def url(cls, text, url=None):
"""
Creates a new button to open the desired URL upon clicking it.
If no `url` is given, the `text` will be used as said URL instead.
"""
return cls(types.KeyboardButtonUrl(text, url or text))
@classmethod
def text(cls, text):
"""
Creates a new button with the given text.
"""
return cls(types.KeyboardButton(text))
@classmethod
def request_location(cls, text):
"""
Creates a new button that will request
the user's location upon being clicked.
"""
return cls(types.KeyboardButtonRequestGeoLocation(text))
@classmethod
def request_phone(cls, text):
"""
Creates a new button that will request
the user's phone number upon being clicked.
"""
return cls(types.KeyboardButtonRequestPhone(text))
@classmethod
def clear(cls):
"""
Clears all the buttons. When used, no other
button should be present or it will be ignored.
"""
return types.ReplyKeyboardHide()
@classmethod
def force_reply(cls):
"""
Forces a reply. If used, no other button
should be present or it will be ignored.
"""
return types.ReplyKeyboardForceReply()

View File

@ -0,0 +1,114 @@
import abc
from ... import errors, utils
from ...tl import types
class ChatGetter(abc.ABC):
"""
Helper base class that introduces the `chat`, `input_chat`
and `chat_id` properties and `get_chat` and `get_input_chat`
methods.
Subclasses **must** have the following private members: `_chat`,
`_input_chat`, `_chat_peer`, `_broadcast` and `_client`. As an end
user, you should not worry about this.
"""
@property
def chat(self):
"""
Returns the :tl:`User`, :tl:`Chat` or :tl:`Channel` where this object
belongs to. It may be ``None`` if Telegram didn't send the chat.
If you're using `telethon.events`, use `get_chat` instead.
"""
return self._chat
def get_chat(self):
"""
Returns `chat`, but will make an API call to find the
chat unless it's already cached.
"""
if self._chat is None and self.get_input_chat():
try:
self._chat =\
self._client.get_entity(self._input_chat)
except ValueError:
self._refetch_chat()
return self._chat
@property
def input_chat(self):
"""
This :tl:`InputPeer` is the input version of the chat where the
message was sent. Similarly to `input_sender`, this doesn't have
things like username or similar, but still useful in some cases.
Note that this might not be available if the library doesn't
have enough information available.
"""
if self._input_chat is None and self._chat_peer:
try:
self._input_chat =\
self._client.session.get_input_entity(self._chat_peer)
except ValueError:
pass
return self._input_chat
def get_input_chat(self):
"""
Returns `input_chat`, but will make an API call to find the
input chat unless it's already cached.
"""
if self.input_chat is None and self.chat_id:
try:
# The chat may be recent, look in dialogs
target = self.chat_id
for d in self._client.iter_dialogs(100):
if d.id == target:
self._chat = d.entity
self._input_chat = d.input_entity
break
except errors.RPCError:
pass
return self._input_chat
@property
def chat_id(self):
"""
Returns the marked chat integer ID. Note that this value **will
be different** from `to_id` for incoming private messages, since
the chat *to* which the messages go is to your own person, but
the *chat* itself is with the one who sent the message.
TL;DR; this gets the ID that you expect.
"""
return utils.get_peer_id(self._chat_peer) if self._chat_peer else None
@property
def is_private(self):
"""True if the message was sent as a private message."""
return isinstance(self._chat_peer, types.PeerUser)
@property
def is_group(self):
"""True if the message was sent on a group or megagroup."""
if self._broadcast is None and self.chat:
self._broadcast = getattr(self.chat, 'broadcast', None)
return (
isinstance(self._chat_peer, (types.PeerChat, types.PeerChannel))
and not self._broadcast
)
@property
def is_channel(self):
"""True if the message was sent on a megagroup or channel."""
return isinstance(self._chat_peer, types.PeerChannel)
def _refetch_chat(self):
"""
Re-fetches chat information through other means.
"""

View File

@ -1,11 +1,19 @@
from ...utils import get_input_peer
from .chatgetter import ChatGetter
from .sendergetter import SenderGetter
from ... import utils
from ...tl import types
class Forward:
class Forward(ChatGetter, SenderGetter):
"""
Custom class that encapsulates a :tl:`MessageFwdHeader` providing an
abstraction to easily access information like the original sender.
Remember that this class implements `ChatGetter
<telethon.tl.custom.chatgetter.ChatGetter>` and `SenderGetter
<telethon.tl.custom.sendergetter.SenderGetter>` which means you
have access to all their sender and chat properties and methods.
Attributes:
original_fwd (:tl:`MessageFwdHeader`):
@ -19,105 +27,21 @@ class Forward:
self.__dict__ = original.__dict__
self._client = client
self.original_fwd = original
self._sender_id = original.from_id
self._sender = entities.get(original.from_id)
self._chat = entities.get(original.channel_id)
self._input_sender =\
get_input_peer(self._sender) if self._sender else None
self._input_chat =\
get_input_peer(self._chat) if self._chat else None
utils.get_input_peer(self._sender) if self._sender else None
# TODO The pattern to get sender and chat is very similar
# and copy pasted in/to several places. Reuse the code.
#
# It could be an ABC with some ``resolve_sender`` abstract,
# so every subclass knew what tricks it can make to get
# the sender.
self._broadcast = None
if original.channel_id:
self._chat_peer = types.PeerChannel(original.channel_id)
self._chat = entities.get(utils.get_peer_id(self._chat_peer))
else:
self._chat_peer = None
self._chat = None
@property
def sender(self):
"""
The :tl:`User` that sent the original message. This may be ``None``
if it couldn't be found or the message wasn't forwarded from an user
but instead was forwarded from e.g. a channel.
"""
return self._sender
self._input_chat = \
utils.get_input_peer(self._chat) if self._chat else None
def get_sender(self):
"""
Returns `sender` but will make an API if necessary.
"""
if not self.sender and self.original_fwd.from_id:
try:
self._sender = self._client.get_entity(
self.get_input_sender())
except ValueError:
# TODO We could reload the message
pass
return self._sender
@property
def input_sender(self):
"""
Returns the input version of `user`.
"""
if not self._input_sender and self.original_fwd.from_id:
try:
self._input_sender = self._client.session.get_input_entity(
self.original_fwd.from_id)
except ValueError:
pass
return self._input_sender
def get_input_sender(self):
"""
Returns `input_sender` but will make an API call if necessary.
"""
# TODO We could reload the message
return self.input_sender
@property
def chat(self):
"""
The :tl:`Channel` where the original message was sent. This may be
``None`` if it couldn't be found or the message wasn't forwarded
from a channel but instead was forwarded from e.g. an user.
"""
return self._chat
def get_chat(self):
"""
Returns `chat` but will make an API if necessary.
"""
if not self.chat and self.original_fwd.channel_id:
try:
self._chat = self._client.get_entity(
self.get_input_chat())
except ValueError:
# TODO We could reload the message
pass
return self._chat
@property
def input_chat(self):
"""
Returns the input version of `chat`.
"""
if not self._input_chat and self.original_fwd.channel_id:
try:
self._input_chat = self._client.session.get_input_entity(
self.original_fwd.channel_id)
except ValueError:
pass
return self._input_chat
def get_input_chat(self):
"""
Returns `input_chat` but will make an API call if necessary.
"""
# TODO We could reload the message
return self.input_chat

View File

@ -0,0 +1,302 @@
import hashlib
from .. import functions, types
from ... import utils
class InlineBuilder:
"""
Helper class to allow defining inline queries ``results``.
Common arguments to all methods are
explained here to avoid repetition:
text (`str`, optional):
If present, the user will send a text
message with this text upon being clicked.
link_preview (`bool`, optional):
Whether to show a link preview in the sent
text message or not.
geo (:tl:`InputGeoPoint`, :tl:`GeoPoint`,
:tl:`InputMediaVenue`, :tl:`MessageMediaVenue`,
optional):
If present, it may either be a geo point or a venue.
period (int, optional):
The period in seconds to be used for geo points.
contact (:tl:`InputMediaContact`, :tl:`MessageMediaContact`,
optional):
If present, it must be the contact information to send.
game (`bool`, optional):
May be ``True`` to indicate that the game will be sent.
buttons (`list`, `custom.Button <telethon.tl.custom.button.Button>`,
:tl:`KeyboardButton`, optional):
Same as ``buttons`` for `client.send_message
<telethon.client.messages.MessageMethods.send_message>`.
parse_mode (`str`, optional):
Same as ``parse_mode`` for `client.send_message
<telethon.client.messageparse.MessageParseMethods.parse_mode>`.
id (`str`, optional):
The string ID to use for this result. If not present, it
will be the SHA256 hexadecimal digest of converting the
request with empty ID to ``bytes()``, so that the ID will
be deterministic for the same input.
"""
def __init__(self, client):
self._client = client
def article(
self, title, description=None,
*, url=None, thumb=None, content=None,
id=None, text=None, parse_mode=utils.Default, link_preview=True,
geo=None, period=60, contact=None, game=False, buttons=None
):
"""
Creates new inline result of article type.
Args:
title (`str`):
The title to be shown for this result.
description (`str`, optional):
Further explanation of what this result means.
url (`str`, optional):
The URL to be shown for this result.
thumb (:tl:`InputWebDocument`, optional):
The thumbnail to be shown for this result.
For now it has to be a :tl:`InputWebDocument` if present.
content (:tl:`InputWebDocument`, optional):
The content to be shown for this result.
For now it has to be a :tl:`InputWebDocument` if present.
"""
# TODO Does 'article' work always?
# article, photo, gif, mpeg4_gif, video, audio,
# voice, document, location, venue, contact, game
result = types.InputBotInlineResult(
id=id or '',
type='article',
send_message=self._message(
text=text, parse_mode=parse_mode, link_preview=link_preview,
geo=geo, period=period,
contact=contact,
game=game,
buttons=buttons
),
title=title,
description=description,
url=url,
thumb=thumb,
content=content
)
if id is None:
result.id = hashlib.sha256(bytes(result)).hexdigest()
return result
def photo(
self, file, *, id=None,
text=None, parse_mode=utils.Default, link_preview=True,
geo=None, period=60, contact=None, game=False, buttons=None
):
"""
Creates a new inline result of photo type.
Args:
file (`obj`, optional):
Same as ``file`` for `client.send_file
<telethon.client.uploads.UploadMethods.send_file>`.
"""
fh = self._client.upload_file(file, use_cache=types.InputPhoto)
if not isinstance(fh, types.InputPhoto):
r = self._client(functions.messages.UploadMediaRequest(
types.InputPeerEmpty(), media=types.InputMediaUploadedPhoto(fh)
))
fh = utils.get_input_photo(r.photo)
result = types.InputBotInlineResultPhoto(
id=id or '',
type='photo',
photo=fh,
send_message=self._message(
text=text, parse_mode=parse_mode, link_preview=link_preview,
geo=geo, period=period,
contact=contact,
game=game,
buttons=buttons
)
)
if id is None:
result.id = hashlib.sha256(bytes(result)).hexdigest()
return result
def document(
self, file, title=None, *, description=None, type=None,
mime_type=None, attributes=None, force_document=False,
voice_note=False, video_note=False, use_cache=True, id=None,
text=None, parse_mode=utils.Default, link_preview=True,
geo=None, period=60, contact=None, game=False, buttons=None
):
"""
Creates a new inline result of document type.
`use_cache`, `mime_type`, `attributes`, `force_document`,
`voice_note` and `video_note` are described in `client.send_file
<telethon.client.uploads.UploadMethods.send_file>`.
Args:
file (`obj`):
Same as ``file`` for `<client.send_file>
telethon.client.uploads.UploadMethods.send_file`.
title (`str`, optional):
The title to be shown for this result.
description (`str`, optional):
Further explanation of what this result means.
type (`str`, optional):
The type of the document. May be one of: photo, gif,
mpeg4_gif, video, audio, voice, document, sticker.
See "Type of the result" in https://core.telegram.org/bots/api.
"""
if type is None:
if voice_note:
type = 'voice'
else:
type = 'document'
use_cache = types.InputDocument if use_cache else None
fh = self._client.upload_file(file, use_cache=use_cache)
if not isinstance(fh, types.InputDocument):
attributes, mime_type = utils.get_attributes(
file,
mime_type=mime_type,
attributes=attributes,
force_document=force_document,
voice_note=voice_note,
video_note=video_note
)
r = self._client(functions.messages.UploadMediaRequest(
types.InputPeerEmpty(), media=types.InputMediaUploadedDocument(
fh,
mime_type=mime_type,
attributes=attributes,
nosound_video=None,
thumb=None
)))
fh = utils.get_input_document(r.document)
result = types.InputBotInlineResultDocument(
id=id or '',
type=type,
document=fh,
send_message=self._message(
text=text, parse_mode=parse_mode, link_preview=link_preview,
geo=geo, period=period,
contact=contact,
game=game,
buttons=buttons
),
title=title,
description=description
)
if id is None:
result.id = hashlib.sha256(bytes(result)).hexdigest()
return result
def game(
self, short_name, *, id=None,
text=None, parse_mode=utils.Default, link_preview=True,
geo=None, period=60, contact=None, game=False, buttons=None
):
"""
Creates a new inline result of game type.
Args:
short_name (`str`):
The short name of the game to use.
"""
result = types.InputBotInlineResultGame(
id=id or '',
short_name=short_name,
send_message=self._message(
text=text, parse_mode=parse_mode, link_preview=link_preview,
geo=geo, period=period,
contact=contact,
game=game,
buttons=buttons
)
)
if id is None:
result.id = hashlib.sha256(bytes(result)).hexdigest()
return result
def _message(
self, *,
text=None, parse_mode=utils.Default, link_preview=True,
geo=None, period=60, contact=None, game=False, buttons=None
):
if sum(1 for x in (text, geo, contact, game) if x) != 1:
raise ValueError('Can only use one of text, geo, contact or game')
markup = self._client.build_reply_markup(buttons, inline_only=True)
if text:
text, msg_entities = self._client._parse_message_text(
text, parse_mode
)
return types.InputBotInlineMessageText(
message=text,
no_webpage=not link_preview,
entities=msg_entities,
reply_markup=markup
)
elif isinstance(geo, (types.InputGeoPoint, types.GeoPoint)):
return types.InputBotInlineMessageMediaGeo(
geo_point=utils.get_input_geo(geo),
period=period,
reply_markup=markup
)
elif isinstance(geo, (types.InputMediaVenue, types.MessageMediaVenue)):
if isinstance(geo, types.InputMediaVenue):
geo_point = geo.geo_point
else:
geo_point = geo.geo
return types.InputBotInlineMessageMediaVenue(
geo_point=geo_point,
title=geo.title,
address=geo.address,
provider=geo.provider,
venue_id=geo.venue_id,
venue_type=geo.venue_type,
reply_markup=markup
)
elif isinstance(contact, (
types.InputMediaContact, types.MessageMediaContact)):
return types.InputBotInlineMessageMediaContact(
phone_number=contact.phone_number,
first_name=contact.first_name,
last_name=contact.last_name,
vcard=contact.vcard,
reply_markup=markup
)
elif game:
return types.InputBotInlineMessageGame(
reply_markup=markup
)
else:
raise ValueError('No text, game or valid geo or contact given')

View File

@ -0,0 +1,142 @@
from .. import types, functions
from ... import utils
class InlineResult:
"""
Custom class that encapsulates a bot inline result providing
an abstraction to easily access some commonly needed features
(such as clicking a result to select it).
Attributes:
result (:tl:`BotInlineResult`):
The original :tl:`BotInlineResult` object.
"""
ARTICLE = 'article'
PHOTO = 'photo'
GIF = 'gif'
VIDEO = 'video'
VIDEO_GIF = 'mpeg4_gif'
AUDIO = 'audio'
DOCUMENT = 'document'
LOCATION = 'location'
VENUE = 'venue'
CONTACT = 'contact'
GAME = 'game'
def __init__(self, client, original, query_id=None):
self._client = client
self.result = original
self._query_id = query_id
@property
def type(self):
"""
The always-present type of this result. It will be one of:
``'article'``, ``'photo'``, ``'gif'``, ``'mpeg4_gif'``, ``'video'``,
``'audio'``, ``'voice'``, ``'document'``, ``'location'``, ``'venue'``,
``'contact'``, ``'game'``.
You can access all of these constants through `InlineResult`,
such as `InlineResult.ARTICLE`, `InlineResult.VIDEO_GIF`, etc.
"""
return self.result.type
@property
def message(self):
"""
The always-present :tl:`BotInlineMessage` that
will be sent if `click` is called on this result.
"""
return self.result.send_message
@property
def title(self):
"""
The title for this inline result. It may be ``None``.
"""
return self.result.title
@property
def description(self):
"""
The description for this inline result. It may be ``None``.
"""
return self.result.description
@property
def url(self):
"""
The URL present in this inline results. If you want to "click"
this URL to open it in your browser, you should use Python's
`webbrowser.open(url)` for such task.
"""
if isinstance(self.result, types.BotInlineResult):
return self.result.url
@property
def photo(self):
"""
Returns either the :tl:`WebDocument` thumbnail for
normal results or the :tl:`Photo` for media results.
"""
if isinstance(self.result, types.BotInlineResult):
return self.result.thumb
elif isinstance(self.result, types.BotInlineMediaResult):
return self.result.photo
@property
def document(self):
"""
Returns either the :tl:`WebDocument` content for
normal results or the :tl:`Document` for media results.
"""
if isinstance(self.result, types.BotInlineResult):
return self.result.content
elif isinstance(self.result, types.BotInlineMediaResult):
return self.result.document
def click(self, entity, reply_to=None,
silent=False, clear_draft=False):
"""
Clicks this result and sends the associated `message`.
Args:
entity (`entity`):
The entity to which the message of this result should be sent.
reply_to (`int` | :tl:`Message`, optional):
If present, the sent message will reply to this ID or message.
silent (`bool`, optional):
If ``True``, the sent message will not notify the user(s).
clear_draft (`bool`, optional):
Whether the draft should be removed after sending the
message from this result or not. Defaults to ``False``.
"""
entity = self._client.get_input_entity(entity)
reply_id = None if reply_to is None else utils.get_message_id(reply_to)
req = functions.messages.SendInlineBotResultRequest(
peer=entity,
query_id=self._query_id,
id=self.result.id,
silent=silent,
clear_draft=clear_draft,
reply_to_msg_id=reply_id
)
return self._client._get_response_message(
req, self._client(req), entity)
def download_media(self, *args, **kwargs):
"""
Downloads the media in this result (if there is a document, the
document will be downloaded; otherwise, the photo will if present).
This is a wrapper around `client.download_media
<telethon.client.downloads.DownloadMethods.download_media>`.
"""
if self.document or self.photo:
return self._client.download_media(
self.document or self.photo, *args, **kwargs)

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,9 @@ import webbrowser
class MessageButton:
"""
Custom class that encapsulates a message providing an abstraction to
easily access some commonly needed features (such as the markdown text
or the text for a given message entity).
Custom class that encapsulates a message button providing
an abstraction to easily access some commonly needed features
(such as clicking the button itself).
Attributes:

View File

@ -0,0 +1,74 @@
import abc
class SenderGetter(abc.ABC):
"""
Helper base class that introduces the `sender`, `input_sender`
and `sender_id` properties and `get_sender` and `get_input_sender`
methods.
Subclasses **must** have the following private members: `_sender`,
`_input_sender`, `_sender_id` and `_client`. As an end user, you
should not worry about this.
"""
@property
def sender(self):
"""
Returns the :tl:`User` that created this object. It may be ``None``
if the object has no sender or if Telegram didn't send the sender.
If you're using `telethon.events`, use `get_sender` instead.
"""
return self._sender
def get_sender(self):
"""
Returns `sender`, but will make an API call to find the
sender unless it's already cached.
"""
if self._sender is None and self.get_input_sender():
try:
self._sender =\
self._client.get_entity(self._input_sender)
except ValueError:
self._reload_message()
return self._sender
@property
def input_sender(self):
"""
This :tl:`InputPeer` is the input version of the user who
sent the message. Similarly to `input_chat`, this doesn't have
things like username or similar, but still useful in some cases.
Note that this might not be available if the library can't
find the input chat, or if the message a broadcast on a channel.
"""
if self._input_sender is None and self._sender_id:
try:
self._input_sender = self._client.session\
.get_input_entity(self._sender_id)
except ValueError:
pass
return self._input_sender
def get_input_sender(self):
"""
Returns `input_sender`, but will make an API call to find the
input sender unless it's already cached.
"""
if self.input_sender is None and self._sender_id:
self._refetch_sender()
return self._input_sender
@property
def sender_id(self):
"""
Returns the marked sender integer ID, if present.
"""
return self._sender_id
def _refetch_sender(self):
"""
Re-fetches sender information through other means.
"""

View File

@ -1,8 +1,12 @@
import abc
import struct
from datetime import datetime, date
from datetime import datetime, date, timedelta
class TLObject:
CONSTRUCTOR_ID = None
SUBCLASS_OF_ID = None
@staticmethod
def pretty_format(obj, indent=None):
"""
@ -24,10 +28,6 @@ class TLObject:
return '[{}]'.format(
', '.join(TLObject.pretty_format(x) for x in obj)
)
elif isinstance(obj, datetime):
return 'datetime.utcfromtimestamp({})'.format(
int(obj.timestamp())
)
else:
return repr(obj)
else:
@ -69,11 +69,6 @@ class TLObject:
result.append('\t' * indent)
result.append(']')
elif isinstance(obj, datetime):
result.append('datetime.utcfromtimestamp(')
result.append(repr(int(obj.timestamp())))
result.append(')')
else:
result.append(repr(obj))
@ -125,6 +120,9 @@ class TLObject:
dt = int(datetime(dt.year, dt.month, dt.day).timestamp())
elif isinstance(dt, float):
dt = int(dt)
elif isinstance(dt, timedelta):
# Timezones are tricky. datetime.now() + ... timestamp() works
dt = int((datetime.now() + dt).timestamp())
if isinstance(dt, int):
return struct.pack('<I', dt)

View File

@ -2,36 +2,28 @@
Utilities for working with the Telegram API itself (such as handy methods
to convert between an entity like an User, Chat, etc. into its Input version)
"""
import base64
import binascii
import itertools
import math
import mimetypes
import os
import re
import types
import struct
from collections import UserList
from mimetypes import guess_extension
from types import GeneratorType
from .extensions import markdown, html
from .helpers import add_surrogate, del_surrogate
from .tl.types import (
Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull,
ChatPhoto, InputPeerChannel, InputPeerChat, InputPeerUser, InputPeerEmpty,
MessageMediaDocument, MessageMediaPhoto, PeerChannel, InputChannel,
UserEmpty, InputUser, InputUserEmpty, InputUserSelf, InputPeerSelf,
PeerChat, PeerUser, User, UserFull, UserProfilePhoto, Document,
MessageMediaContact, MessageMediaEmpty, MessageMediaGame, MessageMediaGeo,
MessageMediaUnsupported, MessageMediaVenue, InputMediaContact,
InputMediaDocument, InputMediaEmpty, InputMediaGame,
InputMediaGeoPoint, InputMediaPhoto, InputMediaVenue, InputDocument,
DocumentEmpty, InputDocumentEmpty, Message, GeoPoint, InputGeoPoint,
GeoPointEmpty, InputGeoPointEmpty, Photo, InputPhoto, PhotoEmpty,
InputPhotoEmpty, FileLocation, ChatPhotoEmpty, UserProfilePhotoEmpty,
FileLocationUnavailable, InputMediaUploadedDocument, ChannelFull,
InputMediaUploadedPhoto, DocumentAttributeFilename, photos,
TopPeer, InputNotifyPeer, InputMessageID, InputFileLocation,
InputDocumentFileLocation, PhotoSizeEmpty, InputDialogPeer
)
from .tl.types.contacts import ResolvedPeer
from .tl import types
try:
import hachoir
import hachoir.metadata
import hachoir.parser
except ImportError:
hachoir = None
USERNAME_RE = re.compile(
r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(joinchat/)?'
@ -73,7 +65,7 @@ def get_display_name(entity):
Gets the display name for the given entity, if it's an :tl:`User`,
:tl:`Chat` or :tl:`Channel`. Returns an empty string otherwise.
"""
if isinstance(entity, User):
if isinstance(entity, types.User):
if entity.last_name and entity.first_name:
return '{} {}'.format(entity.first_name, entity.last_name)
elif entity.first_name:
@ -83,7 +75,7 @@ def get_display_name(entity):
else:
return ''
elif isinstance(entity, (Chat, Channel)):
elif isinstance(entity, (types.Chat, types.Channel)):
return entity.title
return ''
@ -93,13 +85,15 @@ def get_extension(media):
"""Gets the corresponding extension for any Telegram media."""
# Photos are always compressed as .jpg by Telegram
if isinstance(media, (UserProfilePhoto, ChatPhoto, MessageMediaPhoto)):
if isinstance(media, (types.UserProfilePhoto,
types.ChatPhoto, types.MessageMediaPhoto)):
return '.jpg'
# Documents will come with a mime type
if isinstance(media, MessageMediaDocument):
if isinstance(media, types.MessageMediaDocument):
media = media.document
if isinstance(media, Document):
if isinstance(media, (
types.Document, types.WebDocument, types.WebDocumentNoProxy)):
if media.mime_type == 'application/octet-stream':
# Octet stream are just bytes, which have no default extension
return ''
@ -131,38 +125,38 @@ def get_input_peer(entity, allow_self=True):
else:
_raise_cast_fail(entity, 'InputPeer')
if isinstance(entity, User):
if isinstance(entity, types.User):
if entity.is_self and allow_self:
return InputPeerSelf()
return types.InputPeerSelf()
else:
return InputPeerUser(entity.id, entity.access_hash or 0)
return types.InputPeerUser(entity.id, entity.access_hash or 0)
if isinstance(entity, (Chat, ChatEmpty, ChatForbidden)):
return InputPeerChat(entity.id)
if isinstance(entity, (types.Chat, types.ChatEmpty, types.ChatForbidden)):
return types.InputPeerChat(entity.id)
if isinstance(entity, (Channel, ChannelForbidden)):
return InputPeerChannel(entity.id, entity.access_hash or 0)
if isinstance(entity, (types.Channel, types.ChannelForbidden)):
return types.InputPeerChannel(entity.id, entity.access_hash or 0)
if isinstance(entity, InputUser):
return InputPeerUser(entity.user_id, entity.access_hash)
if isinstance(entity, types.InputUser):
return types.InputPeerUser(entity.user_id, entity.access_hash)
if isinstance(entity, InputChannel):
return InputPeerChannel(entity.channel_id, entity.access_hash)
if isinstance(entity, types.InputChannel):
return types.InputPeerChannel(entity.channel_id, entity.access_hash)
if isinstance(entity, InputUserSelf):
return InputPeerSelf()
if isinstance(entity, types.InputUserSelf):
return types.InputPeerSelf()
if isinstance(entity, UserEmpty):
return InputPeerEmpty()
if isinstance(entity, types.UserEmpty):
return types.InputPeerEmpty()
if isinstance(entity, UserFull):
if isinstance(entity, types.UserFull):
return get_input_peer(entity.user)
if isinstance(entity, ChatFull):
return InputPeerChat(entity.id)
if isinstance(entity, types.ChatFull):
return types.InputPeerChat(entity.id)
if isinstance(entity, PeerChat):
return InputPeerChat(entity.chat_id)
if isinstance(entity, types.PeerChat):
return types.InputPeerChat(entity.chat_id)
_raise_cast_fail(entity, 'InputPeer')
@ -175,11 +169,11 @@ def get_input_channel(entity):
except AttributeError:
_raise_cast_fail(entity, 'InputChannel')
if isinstance(entity, (Channel, ChannelForbidden)):
return InputChannel(entity.id, entity.access_hash or 0)
if isinstance(entity, (types.Channel, types.ChannelForbidden)):
return types.InputChannel(entity.id, entity.access_hash or 0)
if isinstance(entity, InputPeerChannel):
return InputChannel(entity.channel_id, entity.access_hash)
if isinstance(entity, types.InputPeerChannel):
return types.InputChannel(entity.channel_id, entity.access_hash)
_raise_cast_fail(entity, 'InputChannel')
@ -192,23 +186,23 @@ def get_input_user(entity):
except AttributeError:
_raise_cast_fail(entity, 'InputUser')
if isinstance(entity, User):
if isinstance(entity, types.User):
if entity.is_self:
return InputUserSelf()
return types.InputUserSelf()
else:
return InputUser(entity.id, entity.access_hash or 0)
return types.InputUser(entity.id, entity.access_hash or 0)
if isinstance(entity, InputPeerSelf):
return InputUserSelf()
if isinstance(entity, types.InputPeerSelf):
return types.InputUserSelf()
if isinstance(entity, (UserEmpty, InputPeerEmpty)):
return InputUserEmpty()
if isinstance(entity, (types.UserEmpty, types.InputPeerEmpty)):
return types.InputUserEmpty()
if isinstance(entity, UserFull):
if isinstance(entity, types.UserFull):
return get_input_user(entity.user)
if isinstance(entity, InputPeerUser):
return InputUser(entity.user_id, entity.access_hash)
if isinstance(entity, types.InputPeerUser):
return types.InputUser(entity.user_id, entity.access_hash)
_raise_cast_fail(entity, 'InputUser')
@ -219,12 +213,12 @@ def get_input_dialog(dialog):
if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer')
return dialog
if dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer')
return InputDialogPeer(dialog)
return types.InputDialogPeer(dialog)
except AttributeError:
_raise_cast_fail(dialog, 'InputDialogPeer')
try:
return InputDialogPeer(get_input_peer(dialog))
return types.InputDialogPeer(get_input_peer(dialog))
except TypeError:
pass
@ -239,16 +233,17 @@ def get_input_document(document):
except AttributeError:
_raise_cast_fail(document, 'InputDocument')
if isinstance(document, Document):
return InputDocument(id=document.id, access_hash=document.access_hash)
if isinstance(document, types.Document):
return types.InputDocument(
id=document.id, access_hash=document.access_hash)
if isinstance(document, DocumentEmpty):
return InputDocumentEmpty()
if isinstance(document, types.DocumentEmpty):
return types.InputDocumentEmpty()
if isinstance(document, MessageMediaDocument):
if isinstance(document, types.MessageMediaDocument):
return get_input_document(document.document)
if isinstance(document, Message):
if isinstance(document, types.Message):
return get_input_document(document.media)
_raise_cast_fail(document, 'InputDocument')
@ -262,14 +257,14 @@ def get_input_photo(photo):
except AttributeError:
_raise_cast_fail(photo, 'InputPhoto')
if isinstance(photo, photos.Photo):
if isinstance(photo, types.photos.Photo):
photo = photo.photo
if isinstance(photo, Photo):
return InputPhoto(id=photo.id, access_hash=photo.access_hash)
if isinstance(photo, types.Photo):
return types.InputPhoto(id=photo.id, access_hash=photo.access_hash)
if isinstance(photo, PhotoEmpty):
return InputPhotoEmpty()
if isinstance(photo, types.PhotoEmpty):
return types.InputPhotoEmpty()
_raise_cast_fail(photo, 'InputPhoto')
@ -282,16 +277,16 @@ def get_input_geo(geo):
except AttributeError:
_raise_cast_fail(geo, 'InputGeoPoint')
if isinstance(geo, GeoPoint):
return InputGeoPoint(lat=geo.lat, long=geo.long)
if isinstance(geo, types.GeoPoint):
return types.InputGeoPoint(lat=geo.lat, long=geo.long)
if isinstance(geo, GeoPointEmpty):
return InputGeoPointEmpty()
if isinstance(geo, types.GeoPointEmpty):
return types.InputGeoPointEmpty()
if isinstance(geo, MessageMediaGeo):
if isinstance(geo, types.MessageMediaGeo):
return get_input_geo(geo.geo)
if isinstance(geo, Message):
if isinstance(geo, types.Message):
return get_input_geo(geo.media)
_raise_cast_fail(geo, 'InputGeoPoint')
@ -308,67 +303,67 @@ def get_input_media(media, is_photo=False):
if media.SUBCLASS_OF_ID == 0xfaf846f4: # crc32(b'InputMedia')
return media
elif media.SUBCLASS_OF_ID == 0x846363e0: # crc32(b'InputPhoto')
return InputMediaPhoto(media)
return types.InputMediaPhoto(media)
elif media.SUBCLASS_OF_ID == 0xf33fdb68: # crc32(b'InputDocument')
return InputMediaDocument(media)
return types.InputMediaDocument(media)
except AttributeError:
_raise_cast_fail(media, 'InputMedia')
if isinstance(media, MessageMediaPhoto):
return InputMediaPhoto(
if isinstance(media, types.MessageMediaPhoto):
return types.InputMediaPhoto(
id=get_input_photo(media.photo),
ttl_seconds=media.ttl_seconds
)
if isinstance(media, (Photo, photos.Photo, PhotoEmpty)):
return InputMediaPhoto(
if isinstance(media, (types.Photo, types.photos.Photo, types.PhotoEmpty)):
return types.InputMediaPhoto(
id=get_input_photo(media)
)
if isinstance(media, MessageMediaDocument):
return InputMediaDocument(
if isinstance(media, types.MessageMediaDocument):
return types.InputMediaDocument(
id=get_input_document(media.document),
ttl_seconds=media.ttl_seconds
)
if isinstance(media, (Document, DocumentEmpty)):
return InputMediaDocument(
if isinstance(media, (types.Document, types.DocumentEmpty)):
return types.InputMediaDocument(
id=get_input_document(media)
)
if isinstance(media, FileLocation):
if isinstance(media, types.FileLocation):
if is_photo:
return InputMediaUploadedPhoto(file=media)
return types.InputMediaUploadedPhoto(file=media)
else:
return InputMediaUploadedDocument(
return types.InputMediaUploadedDocument(
file=media,
mime_type='application/octet-stream', # unknown, assume bytes
attributes=[DocumentAttributeFilename('unnamed')]
attributes=[types.DocumentAttributeFilename('unnamed')]
)
if isinstance(media, MessageMediaGame):
return InputMediaGame(id=media.game.id)
if isinstance(media, types.MessageMediaGame):
return types.InputMediaGame(id=media.game.id)
if isinstance(media, (ChatPhoto, UserProfilePhoto)):
if isinstance(media.photo_big, FileLocationUnavailable):
if isinstance(media, (types.ChatPhoto, types.UserProfilePhoto)):
if isinstance(media.photo_big, types.FileLocationUnavailable):
media = media.photo_small
else:
media = media.photo_big
return get_input_media(media, is_photo=True)
if isinstance(media, MessageMediaContact):
return InputMediaContact(
if isinstance(media, types.MessageMediaContact):
return types.InputMediaContact(
phone_number=media.phone_number,
first_name=media.first_name,
last_name=media.last_name,
vcard=''
)
if isinstance(media, MessageMediaGeo):
return InputMediaGeoPoint(geo_point=get_input_geo(media.geo))
if isinstance(media, types.MessageMediaGeo):
return types.InputMediaGeoPoint(geo_point=get_input_geo(media.geo))
if isinstance(media, MessageMediaVenue):
return InputMediaVenue(
if isinstance(media, types.MessageMediaVenue):
return types.InputMediaVenue(
geo_point=get_input_geo(media.geo),
title=media.title,
address=media.address,
@ -378,11 +373,12 @@ def get_input_media(media, is_photo=False):
)
if isinstance(media, (
MessageMediaEmpty, MessageMediaUnsupported,
ChatPhotoEmpty, UserProfilePhotoEmpty, FileLocationUnavailable)):
return InputMediaEmpty()
types.MessageMediaEmpty, types.MessageMediaUnsupported,
types.ChatPhotoEmpty, types.UserProfilePhotoEmpty,
types.FileLocationUnavailable)):
return types.InputMediaEmpty()
if isinstance(media, Message):
if isinstance(media, types.Message):
return get_input_media(media.media, is_photo=is_photo)
_raise_cast_fail(media, 'InputMedia')
@ -392,11 +388,11 @@ def get_input_message(message):
"""Similar to :meth:`get_input_peer`, but for input messages."""
try:
if isinstance(message, int): # This case is really common too
return InputMessageID(message)
return types.InputMessageID(message)
elif message.SUBCLASS_OF_ID == 0x54b6bcc5: # crc32(b'InputMessage'):
return message
elif message.SUBCLASS_OF_ID == 0x790009e3: # crc32(b'Message'):
return InputMessageID(message.id)
return types.InputMessageID(message.id)
except AttributeError:
pass
@ -404,16 +400,13 @@ def get_input_message(message):
def get_message_id(message):
"""Sanitizes the 'reply_to' parameter a user may send"""
"""Similar to :meth:`get_input_peer`, but for message IDs."""
if message is None:
return None
if isinstance(message, int):
return message
if hasattr(message, 'original_message'):
return message.original_message.id
try:
if message.SUBCLASS_OF_ID == 0x790009e3:
# hex(crc32(b'Message')) = 0x790009e3
@ -424,6 +417,71 @@ def get_message_id(message):
raise TypeError('Invalid message type: {}'.format(type(message)))
def get_attributes(file, *, attributes=None, mime_type=None,
force_document=False, voice_note=False, video_note=False):
"""
Get a list of attributes for the given file and
the mime type as a tuple ([attribute], mime_type).
"""
name = file if isinstance(file, str) else getattr(file, 'name', 'unnamed')
if mime_type is None:
mime_type = mimetypes.guess_type(name)[0]
attr_dict = {types.DocumentAttributeFilename:
types.DocumentAttributeFilename(os.path.basename(name))}
if is_audio(file) and hachoir is not None:
with hachoir.parser.createParser(file) as parser:
m = hachoir.metadata.extractMetadata(parser)
attr_dict[types.DocumentAttributeAudio] = \
types.DocumentAttributeAudio(
voice=voice_note,
title=m.get('title') if m.has('title') else None,
performer=m.get('author') if m.has('author') else None,
duration=int(m.get('duration').seconds
if m.has('duration') else 0)
)
if not force_document and is_video(file):
if hachoir:
with hachoir.parser.createParser(file) as parser:
m = hachoir.metadata.extractMetadata(parser)
doc = types.DocumentAttributeVideo(
round_message=video_note,
w=m.get('width') if m.has('width') else 0,
h=m.get('height') if m.has('height') else 0,
duration=int(m.get('duration').seconds
if m.has('duration') else 0)
)
else:
doc = types.DocumentAttributeVideo(
0, 1, 1, round_message=video_note)
attr_dict[types.DocumentAttributeVideo] = doc
if voice_note:
if types.DocumentAttributeAudio in attr_dict:
attr_dict[types.DocumentAttributeAudio].voice = True
else:
attr_dict[types.DocumentAttributeAudio] = \
types.DocumentAttributeAudio(0, voice=True)
# Now override the attributes if any. As we have a dict of
# {cls: instance}, we can override any class with the list
# of attributes provided by the user easily.
if attributes:
for a in attributes:
attr_dict[type(a)] = a
# Ensure we have a mime type, any; but it cannot be None
# 'The "octet-stream" subtype is used to indicate that a body
# contains arbitrary binary data.'
if not mime_type:
mime_type = 'application/octet-stream'
return list(attr_dict.values()), mime_type
def sanitize_parse_mode(mode):
"""
Converts the given parse mode into an object with
@ -458,34 +516,42 @@ def sanitize_parse_mode(mode):
def get_input_location(location):
"""Similar to :meth:`get_input_peer`, but for input messages."""
"""
Similar to :meth:`get_input_peer`, but for input messages.
Note that this returns a tuple ``(dc_id, location)``, the
``dc_id`` being present if known.
"""
try:
if location.SUBCLASS_OF_ID == 0x1523d462:
return location # crc32(b'InputFileLocation'):
return None, location # crc32(b'InputFileLocation'):
except AttributeError:
_raise_cast_fail(location, 'InputFileLocation')
if isinstance(location, Message):
if isinstance(location, types.Message):
location = location.media
if isinstance(location, MessageMediaDocument):
if isinstance(location, types.MessageMediaDocument):
location = location.document
elif isinstance(location, MessageMediaPhoto):
elif isinstance(location, types.MessageMediaPhoto):
location = location.photo
if isinstance(location, Document):
return InputDocumentFileLocation(
location.id, location.access_hash, location.version)
elif isinstance(location, Photo):
if isinstance(location, types.Document):
return (location.dc_id, types.InputDocumentFileLocation(
location.id, location.access_hash, location.version))
elif isinstance(location, types.Photo):
try:
location = next(x for x in reversed(location.sizes)
if not isinstance(x, PhotoSizeEmpty)).location
location = next(
x for x in reversed(location.sizes)
if not isinstance(x, types.PhotoSizeEmpty)
).location
except StopIteration:
pass
if isinstance(location, (FileLocation, FileLocationUnavailable)):
return InputFileLocation(
location.volume_id, location.local_id, location.secret)
if isinstance(location, (
types.FileLocation, types.FileLocationUnavailable)):
return (getattr(location, 'dc_id', None), types.InputFileLocation(
location.volume_id, location.local_id, location.secret))
_raise_cast_fail(location, 'InputFileLocation')
@ -538,7 +604,7 @@ def is_list_like(obj):
other things), so just support the commonly known list-like objects.
"""
return isinstance(obj, (list, tuple, set, dict,
UserList, types.GeneratorType))
UserList, GeneratorType))
def parse_phone(phone):
@ -609,7 +675,9 @@ def get_peer_id(peer, add_mark=True):
try:
if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6):
if isinstance(peer, (ResolvedPeer, InputNotifyPeer, TopPeer)):
if isinstance(peer, (
types.contacts.ResolvedPeer, types.InputNotifyPeer,
types.TopPeer)):
peer = peer.peer
else:
# Not a Peer or an InputPeer, so first get its Input version
@ -618,16 +686,17 @@ def get_peer_id(peer, add_mark=True):
_raise_cast_fail(peer, 'int')
# Set the right ID/kind, or raise if the TLObject is not recognised
if isinstance(peer, (PeerUser, InputPeerUser)):
if isinstance(peer, (types.PeerUser, types.InputPeerUser)):
return peer.user_id
elif isinstance(peer, (PeerChat, InputPeerChat)):
elif isinstance(peer, (types.PeerChat, types.InputPeerChat)):
# Check in case the user mixed things up to avoid blowing up
if not (0 < peer.chat_id <= 0x7fffffff):
peer.chat_id = resolve_id(peer.chat_id)[0]
return -peer.chat_id if add_mark else peer.chat_id
elif isinstance(peer, (PeerChannel, InputPeerChannel, ChannelFull)):
if isinstance(peer, ChannelFull):
elif isinstance(peer, (
types.PeerChannel, types.InputPeerChannel, types.ChannelFull)):
if isinstance(peer, types.ChannelFull):
# Special case: .get_input_peer can't return InputChannel from
# ChannelFull since it doesn't have an .access_hash attribute.
i = peer.id
@ -637,7 +706,7 @@ def get_peer_id(peer, add_mark=True):
# Check in case the user mixed things up to avoid blowing up
if not (0 < i <= 0x7fffffff):
i = resolve_id(i)[0]
if isinstance(peer, ChannelFull):
if isinstance(peer, types.ChannelFull):
peer.id = i
else:
peer.channel_id = i
@ -655,7 +724,7 @@ def get_peer_id(peer, add_mark=True):
def resolve_id(marked_id):
"""Given a marked ID, returns the original ID and its :tl:`Peer` type."""
if marked_id >= 0:
return marked_id, PeerUser
return marked_id, types.PeerUser
# There have been report of chat IDs being 10000xyz, which means their
# marked version is -10000xyz, which in turn looks like a channel but
@ -663,9 +732,105 @@ def resolve_id(marked_id):
# two zeroes.
m = re.match(r'-100([^0]\d*)', str(marked_id))
if m:
return int(m.group(1)), PeerChannel
return int(m.group(1)), types.PeerChannel
return -marked_id, PeerChat
return -marked_id, types.PeerChat
def _rle_decode(data):
"""
Decodes run-length-encoded `data`.
"""
new = b''
last = b''
for cur in data:
cur = bytes([cur])
if last == b'\0':
new += last * ord(cur)
last = b''
else:
new += last
last = cur
return new + last
def resolve_bot_file_id(file_id):
"""
Given a Bot API-style `file_id`, returns the media it represents.
If the `file_id` is not valid, ``None`` is returned instead.
Note that the `file_id` does not have information such as image
dimensions or file size, so these will be zero if present.
For thumbnails, the photo ID and hash will always be zero.
"""
try:
data = file_id.encode('ascii')
except (UnicodeEncodeError, AttributeError):
return None
data.replace(b'-', b'+').replace(b'_', b'/') + b'=' * (len(data) % 4)
try:
data = base64.b64decode(data)
except binascii.Error:
return None
data = _rle_decode(data)
if data[-1] == b'\x02':
return None
data = data[:-1]
if len(data) == 24:
file_type, dc_id, media_id, access_hash = struct.unpack('<iiqq', data)
attributes = []
if file_type == 3 or file_type == 9:
attributes.append(types.DocumentAttributeAudio(
duration=0,
voice=file_type == 3
))
elif file_type == 4 or file_type == 13:
attributes.append(types.DocumentAttributeVideo(
duration=0,
w=0,
h=0,
round_message=file_type == 13
))
# elif file_type == 5: # other, cannot know which
elif file_type == 8:
attributes.append(types.DocumentAttributeSticker(
alt='',
stickerset=types.InputStickerSetEmpty()
))
elif file_type == 10:
attributes.append(types.DocumentAttributeAnimated())
print(file_type)
return types.Document(
id=media_id,
access_hash=access_hash,
date=None,
mime_type='',
size=0,
thumb=types.PhotoSizeEmpty('s'),
dc_id=dc_id,
version=0,
attributes=attributes
)
elif len(data) == 44:
(file_type, dc_id, media_id, access_hash,
volume_id, secret, local_id) = struct.unpack('<iiqqqqi', data)
# Thumbnails (small) always have ID 0; otherwise size 'x'
photo_size = 's' if media_id or access_hash else 'x'
return types.Photo(id=media_id, access_hash=access_hash, sizes=[
types.PhotoSize(photo_size, location=types.FileLocation(
dc_id=dc_id,
volume_id=volume_id,
secret=secret,
local_id=local_id
), w=0, h=0, size=0)
], date=None)
def get_appropriated_part_size(file_size):

View File

@ -1,3 +1,3 @@
# Versions should comply with PEP440.
# This line is parsed in setup.py:
__version__ = '1.0.4'
__version__ = '1.1.1'

View File

@ -66,3 +66,5 @@ FLOOD_WAIT_X=A wait of {} seconds is required
FLOOD_TEST_PHONE_WAIT_X=A wait of {} seconds is required in the test servers
TAKEOUT_INIT_DELAY_X=A wait of {} seconds is required before being able to initiate the takeout
CHAT_NOT_MODIFIED=The chat or channel wasn't modified (title, invites, username, admins, etc. are the same)
URL_INVALID=The URL used was invalid (e.g. when answering a callback with an URL that's not t.me/yourbot or your game's URL)
USER_NOT_PARTICIPANT=The target user is not a member of the specified megagroup or channel

File diff suppressed because one or more lines are too long

View File

@ -149,7 +149,7 @@ users.getUsers#0d91a548 id:Vector&lt;InputUser&gt; = Vector&lt;User&gt;</pre>
<li id="bool"><b>Bool</b>:
Either <code>True</code> or <code>False</code>.
</li>
<li id="true"><b>true</b>:
<li id="true"><b>flag</b>:
These arguments aren't actually sent but rather encoded as flags.
Any truthy value (<code>True</code>, <code>7</code>) will enable
this flag, although it's recommended to use <code>True</code> or

View File

@ -188,7 +188,8 @@ def _get_description(arg):
desc.append('If left unspecified, it will be inferred automatically.')
otherwise = True
elif arg.is_flag:
desc.append('This argument can be omitted.')
desc.append('This argument defaults to '
'<code>None</code> and can be omitted.')
otherwise = True
if arg.type in {'InputPeer', 'InputUser', 'InputChannel'}:
@ -370,11 +371,12 @@ def _write_html_pages(tlobjects, errors, layer, input_res, output_dir):
bold=True)
# Type row
friendly_type = 'flag' if arg.type == 'true' else arg.type
if arg.is_generic:
docs.add_row('!' + arg.type, align='center')
docs.add_row('!' + friendly_type, align='center')
else:
docs.add_row(
arg.type, align='center', link=
friendly_type, align='center', link=
path_for_type(arg.type, relative_to=filename)
)

View File

@ -36,6 +36,13 @@ NAMED_AUTO_CASTS = {
BASE_TYPES = ('string', 'bytes', 'int', 'long', 'int128',
'int256', 'double', 'Bool', 'true', 'date')
# Patched types {fullname: custom.ns.Name}
PATCHED_TYPES = {
'messageEmpty': 'message.Message',
'message': 'message.Message',
'messageService': 'message.Message'
}
def _write_modules(
out_dir, depth, kind, namespace_tlobjects, type_constructors):
@ -130,11 +137,14 @@ def _write_modules(
# Generate the class for every TLObject
for t in tlobjects:
if t.fullname in PATCHED_TYPES:
builder.writeln('{} = None # Patched', t.class_name)
else:
_write_source_code(t, kind, builder, type_constructors)
builder.current_indent = 0
# Write the type definitions generated earlier.
builder.writeln('')
builder.writeln()
for line in type_defs:
builder.writeln(line)
@ -618,11 +628,38 @@ def _write_arg_read_code(builder, arg, args, name):
arg.is_flag = True
def _write_patched(out_dir, namespace_tlobjects):
os.makedirs(out_dir, exist_ok=True)
for ns, tlobjects in namespace_tlobjects.items():
file = os.path.join(out_dir, '{}.py'.format(ns or '__init__'))
with open(file, 'w', encoding='utf-8') as f,\
SourceBuilder(f) as builder:
builder.writeln(AUTO_GEN_NOTICE)
builder.writeln('import struct')
builder.writeln('from .. import types, custom')
builder.writeln()
for t in tlobjects:
builder.writeln('class {}(custom.{}):', t.class_name,
PATCHED_TYPES[t.fullname])
_write_to_dict(t, builder)
_write_to_bytes(t, builder)
_write_from_reader(t, builder)
builder.current_indent = 0
builder.writeln()
builder.writeln(
'types.{1}{0} = {0}', t.class_name,
'{}.'.format(t.namespace) if t.namespace else ''
)
builder.writeln()
def _write_all_tlobjects(tlobjects, layer, builder):
builder.writeln(AUTO_GEN_NOTICE)
builder.writeln()
builder.writeln('from . import types, functions')
builder.writeln('from . import types, functions, patched')
builder.writeln()
# Create a constant variable to indicate which layer this is
@ -636,9 +673,14 @@ def _write_all_tlobjects(tlobjects, layer, builder):
# Fill the dictionary (0x1a2b3c4f: tl.full.type.path.Class)
for tlobject in tlobjects:
builder.write('{:#010x}: ', tlobject.id)
# TODO Probably circular dependency
if tlobject.fullname in PATCHED_TYPES:
builder.write('patched')
else:
builder.write('functions' if tlobject.is_function else 'types')
if tlobject.namespace:
builder.write('.' + tlobject.namespace)
builder.write('.{}', tlobject.namespace)
builder.writeln('.{},', tlobject.class_name)
@ -647,13 +689,10 @@ def _write_all_tlobjects(tlobjects, layer, builder):
def generate_tlobjects(tlobjects, layer, import_depth, output_dir):
get_file = functools.partial(os.path.join, output_dir)
os.makedirs(get_file('functions'), exist_ok=True)
os.makedirs(get_file('types'), exist_ok=True)
# Group everything by {namespace: [tlobjects]} to generate __init__.py
namespace_functions = defaultdict(list)
namespace_types = defaultdict(list)
namespace_patched = defaultdict(list)
# Group {type: [constructors]} to generate the documentation
type_constructors = defaultdict(list)
@ -663,11 +702,15 @@ def generate_tlobjects(tlobjects, layer, import_depth, output_dir):
else:
namespace_types[tlobject.namespace].append(tlobject)
type_constructors[tlobject.result].append(tlobject)
if tlobject.fullname in PATCHED_TYPES:
namespace_patched[tlobject.namespace].append(tlobject)
get_file = functools.partial(os.path.join, output_dir)
_write_modules(get_file('functions'), import_depth, 'TLRequest',
namespace_functions, type_constructors)
_write_modules(get_file('types'), import_depth, 'TLObject',
namespace_types, type_constructors)
_write_patched(get_file('patched'), namespace_patched)
filename = os.path.join(get_file('alltlobjects.py'))
with open(filename, 'w', encoding='utf-8') as file: