Merge branch 'master' into asyncio

This commit is contained in:
Tulir Asokan 2018-02-25 01:27:34 +02:00
commit c2fba26ad9
19 changed files with 573 additions and 370 deletions

View File

@ -0,0 +1,3 @@
cryptg
pysocks
hachoir3

View File

@ -33,6 +33,13 @@ If you don't have root access, simply pass the ``--user`` flag to the pip
command. If you want to install a specific branch, append ``@branch`` to
the end of the first install command.
By default the library will use a pure Python implementation for encryption,
which can be really slow when uploading or downloading files. If you don't
mind using a C extension, install `cryptg <https://github.com/Lonami/cryptg>`__
via ``pip`` or as an extra:
``pip3 install telethon[cryptg]``
Manual Installation
*******************

View File

@ -14,6 +14,76 @@ it can take advantage of new goodies!
.. contents:: List of All Versions
Further easing library usage (v0.17.4)
======================================
*Published at 2018/02/24*
Some new things and patches that already deserved their own release.
Additions
~~~~~~~~~
- New ``pattern`` argument to ``NewMessage`` to easily filter messages.
- New ``.get_participants()`` convenience method to get members from chats.
- ``.send_message()`` now accepts a ``Message`` as the ``message`` parameter.
- You can now ``.get_entity()`` through exact name match instead username.
- Raise ``ProxyConnectionError`` instead looping forever so you can
``except`` it on your own code and behave accordingly.
Bug fixes
~~~~~~~~~
- ``.parse_username`` would fail with ``www.`` or a trailing slash.
- ``events.MessageChanged`` would fail with ``UpdateDeleteMessages``.
- You can now send ``b'byte strings'`` directly as files again.
- ``.send_file()`` was not respecting the original captions when passing
another message (or media) as the file.
- Downloading media from a different data center would always log a warning
for the first time.
Internal changes
~~~~~~~~~~~~~~~~
- Use ``req_pq_multi`` instead ``req_pq`` when generating ``auth_key``.
- You can use ``.get_me(input_peer=True)`` if all you need is your self ID.
- New addition to the interactive client example to show peer information.
- Avoid special casing ``InputPeerSelf`` on some ``NewMessage`` events, so
you can always safely rely on ``.sender`` to get the right ID.
New small convenience functions (v0.17.3)
=========================================
*Published at 2018/02/18*
More bug fixes and a few others addition to make events easier to use.
Additions
~~~~~~~~~
- Use ``hachoir`` to extract video and audio metadata before upload.
- New ``.add_event_handler``, ``.add_update_handler`` now deprecated.
Bug fixes
~~~~~~~~~
- ``bot_token`` wouldn't work on ``.start()``, and changes to ``password``
(now it will ask you for it if you don't provide it, as docstring hinted).
- ``.edit_message()`` was ignoring the formatting (e.g. markdown).
- Added missing case to the ``NewMessage`` event for normal groups.
- Accessing the ``.text`` of the ``NewMessage`` event was failing due
to a bug with the markdown unparser.
Internal changes
~~~~~~~~~~~~~~~~
- ``libssl`` is no longer an optional dependency. Use ``cryptg`` instead,
which you can find on https://github.com/Lonami/cryptg.
New small convenience functions (v0.17.2)
=========================================

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
pyaes
rsa

View File

@ -110,7 +110,7 @@ def main():
long_description = f.read()
with open('telethon/version.py', encoding='utf-8') as f:
version = re.search(r"^__version__\s+=\s+'(.*)'$",
version = re.search(r"^__version__\s*=\s*'(.*)'.*$",
f.read(), flags=re.MULTILINE).group(1)
setup(
name='Telethon',
@ -149,7 +149,10 @@ def main():
'telethon_generator', 'telethon_tests', 'run_tests.py',
'try_telethon.py'
]),
install_requires=['pyaes', 'rsa']
install_requires=['pyaes', 'rsa'],
extras_require={
'cryptg': ['cryptg']
}
)

View File

@ -1,6 +1,7 @@
import abc
import datetime
import itertools
import re
from .. import utils
from ..errors import RPCError
@ -8,14 +9,62 @@ from ..extensions import markdown
from ..tl import types, functions
async def _into_id_set(client, chats):
"""Helper util to turn the input chat or chats into a set of IDs."""
if chats is None:
return None
if not hasattr(chats, '__iter__') or isinstance(chats, str):
chats = (chats,)
result = set()
for chat in chats:
chat = await client.get_input_entity(chat)
if isinstance(chat, types.InputPeerSelf):
chat = await client.get_me(input_peer=True)
result.add(utils.get_peer_id(chat))
return result
class _EventBuilder(abc.ABC):
"""
The common event builder, with builtin support to filter per chat.
Args:
chats (:obj:`entity`, optional):
May be one or more entities (username/peer/etc.). By default,
only matching chats will be handled.
blacklist_chats (:obj:`bool`, optional):
Whether to treat the the list of chats as a blacklist (if
it matches it will NOT be handled) or a whitelist (default).
"""
def __init__(self, chats=None, blacklist_chats=False):
self.chats = chats
self.blacklist_chats = blacklist_chats
self._self_id = None
@abc.abstractmethod
def build(self, update):
"""Builds an event for the given update if possible, or returns None"""
@abc.abstractmethod
async def resolve(self, client):
"""Helper method to allow event builders to be resolved before usage"""
self.chats = await _into_id_set(client, self.chats)
self._self_id = (await client.get_me(input_peer=True)).user_id
def _filter_event(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.
"""
if self.chats is not None:
inside = utils.get_peer_id(event._chat_peer) in self.chats
if inside == self.blacklist_chats:
# If this chat matches but it's a blacklist ignore.
# If it doesn't match but it's a whitelist ignore.
return None
return event
class _EventCommon(abc.ABC):
@ -98,7 +147,7 @@ class _EventCommon(abc.ABC):
there is no caching besides local caching yet.
"""
if self._chat is None and await self.input_chat:
self._chat = await self._client.get_entity(self._input_chat)
self._chat = await self._client.get_entity(await self._input_chat)
return self._chat
@ -106,8 +155,6 @@ class Raw(_EventBuilder):
"""
Represents a raw event. The event is the update itself.
"""
async def resolve(self, client):
pass
def build(self, update):
return update
@ -129,36 +176,28 @@ class NewMessage(_EventBuilder):
If set to ``True``, only **outgoing** messages will be handled.
Mutually exclusive with ``incoming`` (can only set one of either).
chats (:obj:`entity`, optional):
May be one or more entities (username/peer/etc.). By default,
only matching chats will be handled.
blacklist_chats (:obj:`bool`, optional):
Whether to treat the the list of chats as a blacklist (if
it matches it will NOT be handled) or a whitelist (default).
Notes:
The ``message.from_id`` might not only be an integer or ``None``,
but also ``InputPeerSelf()`` for short private messages (the API
would not return such thing, this is a custom modification).
pattern (:obj:`str`, :obj:`callable`, :obj:`Pattern`, optional):
If set, only messages 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, incoming=None, outgoing=None,
chats=None, blacklist_chats=False):
chats=None, blacklist_chats=False, pattern=None):
if incoming and outgoing:
raise ValueError('Can only set either incoming or outgoing')
super().__init__(chats=chats, blacklist_chats=blacklist_chats)
self.incoming = incoming
self.outgoing = outgoing
self.chats = chats
self.blacklist_chats = blacklist_chats
async def resolve(self, client):
if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str):
self.chats = set(utils.get_peer_id(await client.get_input_entity(x))
for x in self.chats)
elif self.chats is not None:
self.chats = {utils.get_peer_id(
await client.get_input_entity(self.chats))}
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')
def build(self, update):
if isinstance(update,
@ -174,7 +213,23 @@ class NewMessage(_EventBuilder):
silent=update.silent,
id=update.id,
to_id=types.PeerUser(update.user_id),
from_id=types.InputPeerSelf() if update.out else update.user_id,
from_id=self._self_id if update.out else update.user_id,
message=update.message,
date=update.date,
fwd_from=update.fwd_from,
via_bot_id=update.via_bot_id,
reply_to_msg_id=update.reply_to_msg_id,
entities=update.entities
))
elif isinstance(update, types.UpdateShortChatMessage):
event = NewMessage.Event(types.Message(
out=update.out,
mentioned=update.mentioned,
media_unread=update.media_unread,
silent=update.silent,
id=update.id,
from_id=update.from_id,
to_id=types.PeerChat(update.chat_id),
message=update.message,
date=update.date,
fwd_from=update.fwd_from,
@ -186,23 +241,18 @@ class NewMessage(_EventBuilder):
return
# Short-circuit if we let pass all events
if all(x is None for x in (self.incoming, self.outgoing, self.chats)):
if all(x is None for x in (self.incoming, self.outgoing, self.chats,
self.pattern)):
return event
if self.incoming and event.message.out:
return
if self.outgoing and not event.message.out:
return
if self.chats is not None:
inside = utils.get_peer_id(event.message.to_id) in self.chats
if inside == self.blacklist_chats:
# If this chat matches but it's a blacklist ignore.
# If it doesn't match but it's a whitelist ignore.
if self.pattern and not self.pattern(event.message.message or ''):
return
# Tests passed so return the event
return event
return self._filter_event(event)
class Event(_EventCommon):
"""
@ -264,9 +314,13 @@ class NewMessage(_EventBuilder):
or the edited message otherwise.
"""
if not self.message.out:
if not isinstance(self.message.to_id, types.PeerUser):
return None
me = await self._client.get_me(input_peer=True)
if self.message.to_id.user_id != me.user_id:
return None
return await self._client.edit_message(self.input_chat,
return await self._client.edit_message(await self.input_chat,
self.message,
*args, **kwargs)
@ -277,7 +331,7 @@ class NewMessage(_EventBuilder):
This is a shorthand for
``client.delete_messages(event.chat, event.message, ...)``.
"""
return await self._client.delete_messages(self.input_chat,
return await self._client.delete_messages(await self.input_chat,
[self.message],
*args, **kwargs)
@ -413,30 +467,7 @@ class NewMessage(_EventBuilder):
class ChatAction(_EventBuilder):
"""
Represents an action in a chat (such as user joined, left, or new pin).
Args:
chats (:obj:`entity`, optional):
May be one or more entities (username/peer/etc.). By default,
only matching chats will be handled.
blacklist_chats (:obj:`bool`, optional):
Whether to treat the the list of chats as a blacklist (if
it matches it will NOT be handled) or a whitelist (default).
"""
def __init__(self, chats=None, blacklist_chats=False):
# TODO This can probably be reused in all builders
self.chats = chats
self.blacklist_chats = blacklist_chats
async def resolve(self, client):
if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str):
self.chats = set(utils.get_peer_id(await client.get_input_entity(x))
for x in self.chats)
elif self.chats is not None:
self.chats = {utils.get_peer_id(
await client.get_input_entity(self.chats))}
def build(self, update):
if isinstance(update, types.UpdateChannelPinnedMessage):
# Telegram sends UpdateChannelPinnedMessage and then
@ -494,16 +525,7 @@ class ChatAction(_EventBuilder):
else:
return
if self.chats is None:
return event
else:
inside = utils.get_peer_id(event._chat_peer) in self.chats
if inside == self.blacklist_chats:
# If this chat matches but it's a blacklist ignore.
# If it doesn't match but it's a whitelist ignore.
return
return event
return self._filter_event(event)
class Event(_EventCommon):
"""
@ -649,7 +671,6 @@ class UserUpdate(_EventBuilder):
"""
Represents an user update (gone online, offline, joined Telegram).
"""
def build(self, update):
if isinstance(update, types.UpdateUserStatus):
event = UserUpdate.Event(update.user_id,
@ -657,10 +678,7 @@ class UserUpdate(_EventBuilder):
else:
return
return event
async def resolve(self, client):
pass
return self._filter_event(event)
class Event(_EventCommon):
"""
@ -800,13 +818,16 @@ class MessageChanged(_EventBuilder):
"""
Represents a message changed (edited or deleted).
"""
def build(self, update):
if isinstance(update, (types.UpdateEditMessage,
types.UpdateEditChannelMessage)):
event = MessageChanged.Event(edit_msg=update.message)
elif isinstance(update, (types.UpdateDeleteMessages,
types.UpdateDeleteChannelMessages)):
elif isinstance(update, types.UpdateDeleteMessages):
event = MessageChanged.Event(
deleted_ids=update.messages,
peer=None
)
elif isinstance(update, types.UpdateDeleteChannelMessages):
event = MessageChanged.Event(
deleted_ids=update.messages,
peer=types.PeerChannel(update.channel_id)
@ -814,91 +835,32 @@ class MessageChanged(_EventBuilder):
else:
return
return event
return self._filter_event(event)
async def resolve(self, client):
pass
class Event(_EventCommon):
class Event(NewMessage.Event):
"""
Represents the event of an user status update (last seen, joined).
Please note that the ``message`` member will be ``None`` if the
action was a deletion and not an edit.
Members:
edited (:obj:`bool`):
``True`` if the message was edited.
message (:obj:`Message`, optional):
The new edited message, if any.
deleted (:obj:`bool`):
``True`` if the message IDs were deleted.
deleted_ids (:obj:`List[int]`):
A list containing the IDs of the messages that were deleted.
input_sender (:obj:`InputPeer`):
This is the input version of the user who edited 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.
sender (:obj:`User`):
This property will make an API call the first time to get the
most up to date version of the sender, so use with care as
there is no caching besides local caching yet.
``input_sender`` needs to be available (often the case).
"""
def __init__(self, edit_msg=None, deleted_ids=None, peer=None):
super().__init__(peer if not edit_msg else edit_msg.to_id)
if edit_msg is None:
msg = types.Message((deleted_ids or [0])[0], peer, None, '')
else:
msg = edit_msg
super().__init__(msg)
self.edited = bool(edit_msg)
self.message = edit_msg
self.deleted = bool(deleted_ids)
self.deleted_ids = deleted_ids or []
self._input_sender = None
self._sender = None
@property
async def input_sender(self):
"""
This (:obj:`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.
"""
# TODO Code duplication
if self._input_sender is None:
if self.is_channel and not self.is_group:
return None
try:
self._input_sender = await self._client.get_input_entity(
self.message.from_id
)
except (ValueError, TypeError):
# We can rely on self.input_chat for this
self._input_sender = await self._get_input_entity(
self.message.id,
self.message.from_id,
chat=await self.input_chat
)
return self._input_sender
@property
async def sender(self):
"""
This (:obj:`User`) will make an API call the first time to get
the most up to date version of the sender, so use with care as
there is no caching besides local caching yet.
``input_sender`` needs to be available (often the case).
"""
if self._sender is None and await self.input_sender:
self._sender = await self._client.get_entity(self._input_sender)
return self._sender

View File

@ -169,6 +169,7 @@ def unparse(text, entities, delimiters=None, url_fmt=None):
entities = tuple(sorted(entities, key=lambda e: e.offset, reverse=True))
text = _add_surrogate(text)
delimiters = {v: k for k, v in delimiters.items()}
for entity in entities:
s = entity.offset
e = entity.offset + entity.length

View File

@ -15,6 +15,11 @@ CONN_RESET_ERRNOS = {
errno.EINVAL, errno.ENOTCONN
}
try:
import socks
except ImportError:
socks = None
MAX_TIMEOUT = 15 # in seconds
CONN_RESET_ERRNOS = {
errno.EBADF, errno.ENOTSOCK, errno.ENETUNREACH,
@ -81,6 +86,9 @@ class TcpClient:
await asyncio.sleep(timeout)
timeout = min(timeout * 2, MAX_TIMEOUT)
except OSError as e:
# Stop retrying to connect if proxy connection error occurred
if socks and isinstance(e, socks.ProxyConnectionError):
raise
# There are some errors that we know how to handle, and
# the loop will allow us to retry
if e.errno in (errno.EBADF, errno.ENOTSOCK, errno.EINVAL,

View File

@ -17,7 +17,7 @@ from ..errors import SecurityError
from ..extensions import BinaryReader
from ..network import MtProtoPlainSender
from ..tl.functions import (
ReqPqRequest, ReqDHParamsRequest, SetClientDHParamsRequest
ReqPqMultiRequest, ReqDHParamsRequest, SetClientDHParamsRequest
)
@ -53,7 +53,7 @@ async def _do_authentication(connection):
sender = MtProtoPlainSender(connection)
# Step 1 sending: PQ Request, endianness doesn't matter since it's random
req_pq_request = ReqPqRequest(
req_pq_request = ReqPqMultiRequest(
nonce=int.from_bytes(os.urandom(16), 'big', signed=True)
)
await sender.send(bytes(req_pq_request))

View File

@ -221,7 +221,7 @@ class Session:
c = self._cursor()
c.execute('select auth_key from sessions')
tuple_ = c.fetchone()
if tuple_:
if tuple_ and tuple_[0]:
self._auth_key = AuthKey(data=tuple_[0])
else:
self._auth_key = None
@ -424,6 +424,7 @@ class Session:
(phone,))
else:
username, _ = utils.parse_username(key)
if username:
c.execute('select id, hash from entities where username=?',
(username,))
@ -431,6 +432,11 @@ class Session:
c.execute('select id, hash from entities where id=?', (key,))
result = c.fetchone()
if not result and isinstance(key, str):
# Try exact match by name if phone/username failed
c.execute('select id, hash from entities where name=?', (key,))
result = c.fetchone()
c.close()
if result:
i, h = result # unpack resulting tuple

View File

@ -3,7 +3,6 @@ import logging
import os
from asyncio import Lock
from datetime import timedelta
from . import version, utils
from .crypto import rsa
from .errors import (
@ -554,17 +553,6 @@ class TelegramBareClient:
"""
self.updates.process(await self(GetStateRequest()))
def add_update_handler(self, handler):
"""Adds an update handler (a function which takes a TLObject,
an update, as its parameter) and listens for updates"""
self.updates.handlers.append(handler)
def remove_update_handler(self, handler):
self.updates.handlers.remove(handler)
def list_update_handlers(self):
return self.updates.handlers[:]
# endregion
# Constant read

View File

@ -1,4 +1,5 @@
import asyncio
import getpass
import hashlib
import io
import itertools
@ -6,6 +7,7 @@ import logging
import os
import re
import sys
import warnings
from collections import OrderedDict, UserList
from datetime import datetime, timedelta
from io import BytesIO
@ -23,8 +25,15 @@ try:
except ImportError:
socks = None
try:
import hachoir
import hachoir.metadata
import hachoir.parser
except ImportError:
hachoir = None
from . import TelegramBareClient
from . import helpers, utils
from . import helpers, utils, events
from .errors import (
RPCError, UnauthorizedError, PhoneCodeEmptyError, PhoneCodeExpiredError,
PhoneCodeHashEmptyError, PhoneCodeInvalidError, LocationInvalidError,
@ -47,7 +56,7 @@ from .tl.functions.messages import (
GetDialogsRequest, GetHistoryRequest, SendMediaRequest,
SendMessageRequest, GetChatsRequest, GetAllDraftsRequest,
CheckChatInviteRequest, ReadMentionsRequest, SendMultiMediaRequest,
UploadMediaRequest, EditMessageRequest
UploadMediaRequest, EditMessageRequest, GetFullChatRequest
)
from .tl.functions import channels
@ -57,7 +66,7 @@ from .tl.functions.users import (
GetUsersRequest
)
from .tl.functions.channels import (
GetChannelsRequest, GetFullChannelRequest
GetChannelsRequest, GetFullChannelRequest, GetParticipantsRequest
)
from .tl.types import (
DocumentAttributeAudio, DocumentAttributeFilename,
@ -71,7 +80,8 @@ from .tl.types import (
InputSingleMedia, InputMediaPhoto, InputPhoto, InputFile, InputFileBig,
InputDocument, InputMediaDocument, Document, MessageEntityTextUrl,
InputMessageEntityMentionName, DocumentAttributeVideo,
UpdateEditMessage, UpdateEditChannelMessage, UpdateShort, Updates
UpdateEditMessage, UpdateEditChannelMessage, UpdateShort, Updates,
MessageMediaWebPage, ChannelParticipantsSearch
)
from .tl.types.messages import DialogsSlice
from .extensions import markdown, html
@ -168,6 +178,9 @@ class TelegramClient(TelegramBareClient):
self._phone_code_hash = {}
self._phone = None
# Sometimes we need to know who we are, cache the self peer
self._self_input_peer = None
# endregion
# region Telegram requests functions
@ -207,8 +220,9 @@ class TelegramClient(TelegramBareClient):
async def start(self,
phone=lambda: input('Please enter your phone: '),
password=None, bot_token=None,
force_sms=False, code_callback=None,
password=lambda: getpass.getpass(
'Please enter your password: '),
bot_token=None, force_sms=False, code_callback=None,
first_name='New User', last_name=''):
"""
Convenience method to interactively connect and sign in if required,
@ -265,7 +279,7 @@ class TelegramClient(TelegramBareClient):
if not phone and not bot_token:
raise ValueError('No phone number or bot token provided.')
if phone and bot_token:
if phone and bot_token and not callable(phone):
raise ValueError('Both a phone and a bot token provided, '
'must only provide one of either')
@ -322,6 +336,9 @@ class TelegramClient(TelegramBareClient):
"Two-step verification is enabled for this account. "
"Please provide the 'password' argument to 'start()'."
)
# TODO If callable given make it retry on invalid
if callable(password):
password = password()
me = await self.sign_in(phone=phone, password=password)
# We won't reach here if any step failed (exit by exception)
@ -393,6 +410,9 @@ class TelegramClient(TelegramBareClient):
'and a password only if an RPCError was raised before.'
)
self._self_input_peer = utils.get_input_peer(
result.user, allow_self=False
)
self._set_connected_and_authorized()
return result.user
@ -422,6 +442,9 @@ class TelegramClient(TelegramBareClient):
last_name=last_name
))
self._self_input_peer = utils.get_input_peer(
result.user, allow_self=False
)
self._set_connected_and_authorized()
return result.user
@ -441,16 +464,31 @@ class TelegramClient(TelegramBareClient):
self.session.delete()
return True
async def get_me(self):
async def get_me(self, input_peer=False):
"""
Gets "me" (the self user) which is currently authenticated,
or None if the request fails (hence, not authenticated).
Args:
input_peer (:obj:`bool`, optional):
Whether to return the ``InputPeerUser`` version or the normal
``User``. This can be useful if you just need to know the ID
of yourself.
Returns:
:obj:`User`: Your own user.
"""
if input_peer and self._self_input_peer:
return self._self_input_peer
try:
return (await self(GetUsersRequest([InputUserSelf()])))[0]
me = (await self(GetUsersRequest([InputUserSelf()])))[0]
if not self._self_input_peer:
self._self_input_peer = utils.get_input_peer(
me, allow_self=False
)
if input_peer:
return self._self_input_peer
return me
except UnauthorizedError:
return None
@ -627,8 +665,8 @@ class TelegramClient(TelegramBareClient):
entity (:obj:`entity`):
To who will it be sent.
message (:obj:`str`):
The message to be sent.
message (:obj:`str` | :obj:`Message`):
The message to be sent, or another message object to resend.
reply_to (:obj:`int` | :obj:`Message`, optional):
Whether to reply to a message or not. If an integer is provided,
@ -646,16 +684,37 @@ class TelegramClient(TelegramBareClient):
Returns:
the sent message
"""
entity = await self.get_input_entity(entity)
message, msg_entities = await self._parse_message_text(message, parse_mode)
entity = await self.get_input_entity(entity)
if isinstance(message, Message):
if (message.media
and not isinstance(message.media, MessageMediaWebPage)):
return await self.send_file(entity, message.media)
if utils.get_peer_id(entity) == utils.get_peer_id(message.to_id):
reply_id = message.reply_to_msg_id
else:
reply_id = None
request = SendMessageRequest(
peer=entity,
message=message.message or '',
silent=message.silent,
reply_to_msg_id=reply_id,
reply_markup=message.reply_markup,
entities=message.entities,
no_webpage=not isinstance(message.media, MessageMediaWebPage)
)
message = message.message
else:
message, msg_ent = await self._parse_message_text(message, parse_mode)
request = SendMessageRequest(
peer=entity,
message=message,
entities=msg_entities,
entities=msg_ent,
no_webpage=not link_preview,
reply_to_msg_id=self._get_message_id(reply_to)
)
result = await self(request)
if isinstance(result, UpdateShortSentMessage):
@ -956,11 +1015,64 @@ class TelegramClient(TelegramBareClient):
raise TypeError('Invalid message type: {}'.format(type(message)))
def get_participants(self, entity, limit=None, search=''):
"""
Gets the list of participants from the specified entity
Args:
entity (:obj:`entity`):
The entity from which to retrieve the participants list.
limit (:obj: `int`):
Limits amount of participants fetched.
search (:obj: `str`, optional):
Look for participants with this string in name/username.
Returns:
A list of participants with an additional .total variable on the list
indicating the total amount of members in this group/channel.
"""
entity = self.get_input_entity(entity)
limit = float('inf') if limit is None else int(limit)
if isinstance(entity, InputPeerChannel):
offset = 0
all_participants = {}
search = ChannelParticipantsSearch(search)
while True:
loop_limit = min(limit - offset, 200)
participants = self(GetParticipantsRequest(
entity, search, offset, loop_limit, hash=0
))
if not participants.users:
break
for user in participants.users:
if len(all_participants) < limit:
all_participants[user.id] = user
offset += len(participants.users)
if offset > limit:
break
users = UserList(all_participants.values())
users.total = self(GetFullChannelRequest(
entity)).full_chat.participants_count
elif isinstance(entity, InputPeerChat):
users = self(GetFullChatRequest(entity.chat_id)).users
if len(users) > limit:
users = users[:limit]
users = UserList(users)
users.total = len(users)
else:
users = UserList([entity])
users.total = 1
return users
# endregion
# region Uploading files
async def send_file(self, entity, file, caption='',
async def send_file(self, entity, file, caption=None,
force_document=False, progress_callback=None,
reply_to=None,
attributes=None,
@ -1019,6 +1131,10 @@ class TelegramClient(TelegramBareClient):
If "is_voice_note" in kwargs, despite its value, and the file is
sent as a document, it will be sent as a voice note.
Notes:
If the ``hachoir3`` package (``hachoir`` module) is installed,
it will be used to determine metadata from audio and video files.
Returns:
The message (or messages) containing the sent file.
"""
@ -1068,11 +1184,11 @@ class TelegramClient(TelegramBareClient):
if isinstance(file_handle, use_cache):
# File was cached, so an instance of use_cache was returned
if as_image:
media = InputMediaPhoto(file_handle, caption)
media = InputMediaPhoto(file_handle, caption or '')
else:
media = InputMediaDocument(file_handle, caption)
media = InputMediaDocument(file_handle, caption or '')
elif as_image:
media = InputMediaUploadedPhoto(file_handle, caption)
media = InputMediaUploadedPhoto(file_handle, caption or '')
else:
mime_type = None
if isinstance(file, str):
@ -1082,12 +1198,32 @@ class TelegramClient(TelegramBareClient):
attr_dict = {
DocumentAttributeFilename:
DocumentAttributeFilename(os.path.basename(file))
# TODO If the input file is an audio, find out:
# Performer and song title and add DocumentAttributeAudio
}
if utils.is_audio(file) and hachoir:
m = hachoir.metadata.extractMetadata(
hachoir.parser.createParser(file)
)
attr_dict[DocumentAttributeAudio] = DocumentAttributeAudio(
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 utils.is_video(file):
attr_dict[DocumentAttributeVideo] = \
DocumentAttributeVideo(0, 0, 0)
if hachoir:
m = hachoir.metadata.extractMetadata(
hachoir.parser.createParser(file)
)
doc = DocumentAttributeVideo(
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 = DocumentAttributeVideo(0, 0, 0)
attr_dict[DocumentAttributeVideo] = doc
else:
attr_dict = {
DocumentAttributeFilename:
@ -1095,6 +1231,9 @@ class TelegramClient(TelegramBareClient):
}
if 'is_voice_note' in kwargs:
if DocumentAttributeAudio in attr_dict:
attr_dict[DocumentAttributeAudio].voice = True
else:
attr_dict[DocumentAttributeAudio] = \
DocumentAttributeAudio(0, voice=True)
@ -1119,7 +1258,7 @@ class TelegramClient(TelegramBareClient):
file=file_handle,
mime_type=mime_type,
attributes=list(attr_dict.values()),
caption=caption,
caption=caption or '',
**input_kw
)
@ -1139,7 +1278,7 @@ class TelegramClient(TelegramBareClient):
return msg
async def send_voice_note(self, entity, file, caption='',
async def send_voice_note(self, entity, file, caption=None,
progress_callback=None, reply_to=None):
"""Wrapper method around .send_file() with is_voice_note=()"""
return await self.send_file(entity, file, caption,
@ -1147,7 +1286,7 @@ class TelegramClient(TelegramBareClient):
reply_to=reply_to,
is_voice_note=()) # empty tuple is enough
async def _send_album(self, entity, files, caption='',
async def _send_album(self, entity, files, caption=None,
progress_callback=None, reply_to=None):
"""Specialized version of .send_file for albums"""
# We don't care if the user wants to avoid cache, we will use it
@ -1156,6 +1295,7 @@ class TelegramClient(TelegramBareClient):
# cache only makes a difference for documents where the user may
# want the attributes used on them to change. Caption's ignored.
entity = await self.get_input_entity(entity)
caption = caption or ''
reply_to = self._get_message_id(reply_to)
# Need to upload the media first, but only if they're not cached yet
@ -1479,18 +1619,27 @@ class TelegramClient(TelegramBareClient):
file_size = document.size
kind = 'document'
possible_names = []
for attr in document.attributes:
if isinstance(attr, DocumentAttributeFilename):
possible_names.insert(0, attr.file_name)
elif isinstance(attr, DocumentAttributeAudio):
kind = 'audio'
if attr.performer and attr.title:
possible_names.append('{} - {}'.format(
attr.performer, attr.title
))
elif attr.performer:
possible_names.append(attr.performer)
elif attr.title:
possible_names.append(attr.title)
elif attr.voice:
kind = 'voice'
file = self._get_proper_filename(
file, 'document', utils.get_extension(document),
file, kind, utils.get_extension(document),
date=date, possible_names=possible_names
)
@ -1711,28 +1860,19 @@ class TelegramClient(TelegramBareClient):
# region Event handling
async def on(self, event):
def on(self, event):
"""
Turns the given entity into a valid Telegram user or chat.
Decorator helper method around add_event_handler().
Args:
event (:obj:`_EventBuilder` | :obj:`type`):
The event builder class or instance to be used,
for instance ``events.NewMessage``.
"""
if isinstance(event, type):
event = event()
await event.resolve(self)
def decorator(f):
self._event_builders.append((event, f))
async def decorator(f):
await self.add_event_handler(f, event)
return f
if self._on_handler not in self.updates.handlers:
self.add_update_handler(self._on_handler)
return decorator
async def _on_handler(self, update):
@ -1742,6 +1882,48 @@ class TelegramClient(TelegramBareClient):
event._client = self
await callback(event)
async def add_event_handler(self, callback, event=None):
"""
Registers the given callback to be called on the specified event.
Args:
callback (:obj:`callable`):
The callable function accepting one parameter to be used.
event (:obj:`_EventBuilder` | :obj:`type`, optional):
The event builder class or instance to be used,
for instance ``events.NewMessage``.
If left unspecified, ``events.Raw`` (the ``Update`` objects
with no further processing) will be passed instead.
"""
self.updates.handler = self._on_handler
if isinstance(event, type):
event = event()
elif not event:
event = events.Raw()
await event.resolve(self)
self._event_builders.append((event, callback))
def add_update_handler(self, handler):
"""Adds an update handler (a function which takes a TLObject,
an update, as its parameter) and listens for updates"""
warnings.warn(
'add_update_handler is deprecated, use the @client.on syntax '
'or add_event_handler(callback, events.Raw) instead (see '
'https://telethon.rtfd.io/en/latest/extra/basic/working-'
'with-updates.html)'
)
self.add_event_handler(handler, events.Raw)
def remove_update_handler(self, handler):
pass
def list_update_handlers(self):
return []
# endregion
# region Small utilities to make users' life easier
@ -1831,9 +2013,9 @@ class TelegramClient(TelegramBareClient):
if user.phone == phone:
return user
else:
string, is_join_chat = utils.parse_username(string)
username, is_join_chat = utils.parse_username(string)
if is_join_chat:
invite = await self(CheckChatInviteRequest(string))
invite = await self(CheckChatInviteRequest(username))
if isinstance(invite, ChatInvite):
raise ValueError(
'Cannot get entity from a channel '
@ -1841,13 +2023,19 @@ class TelegramClient(TelegramBareClient):
)
elif isinstance(invite, ChatInviteAlready):
return invite.chat
else:
if string in ('me', 'self'):
elif username:
if username in ('me', 'self'):
return await self.get_me()
result = await self(ResolveUsernameRequest(string))
result = await self(ResolveUsernameRequest(username))
for entity in itertools.chain(result.users, result.chats):
if entity.username.lower() == string:
if entity.username.lower() == username:
return entity
try:
# Nobody with this username, maybe it's an exact name/title
return await self.get_entity(
self.session.get_input_entity(string))
except ValueError:
pass
raise TypeError(
'Cannot turn "{}" into any entity (user or chat)'.format(string)

View File

@ -16,15 +16,15 @@ class UpdateState:
WORKER_POLL_TIMEOUT = 5.0 # Avoid waiting forever on the workers
def __init__(self, loop=None):
self.handlers = []
self.handler = None
self._loop = loop if loop else asyncio.get_event_loop()
# https://core.telegram.org/api/updates
self._state = tl.updates.State(0, 0, datetime.now(), 0, 0)
def handle_update(self, update):
for handler in self.handlers:
asyncio.ensure_future(handler(update), loop=self._loop)
if self.handler:
asyncio.ensure_future(self.handler(update), loop=self._loop)
def process(self, update):
"""Processes an update object. This method is normally called by

View File

@ -27,9 +27,11 @@ from .tl.types import (
from .tl.types.contacts import ResolvedPeer
USERNAME_RE = re.compile(
r'@|(?:https?://)?(?:telegram\.(?:me|dog)|t\.me)/(joinchat/)?'
r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(joinchat/)?'
)
VALID_USERNAME_RE = re.compile(r'^[a-zA-Z][\w\d]{3,30}[a-zA-Z\d]$')
def get_display_name(entity):
"""Gets the input peer for the given "entity" (user, chat or channel)
@ -323,12 +325,20 @@ def get_input_media(media, user_caption=None, is_photo=False):
def is_image(file):
"""Returns True if the file extension looks like an image file"""
return (mimetypes.guess_type(file)[0] or '').startswith('image/')
return (isinstance(file, str) and
(mimetypes.guess_type(file)[0] or '').startswith('image/'))
def is_audio(file):
"""Returns True if the file extension looks like an audio file"""
return (isinstance(file, str) and
(mimetypes.guess_type(file)[0] or '').startswith('audio/'))
def is_video(file):
"""Returns True if the file extension looks like a video file"""
return (mimetypes.guess_type(file)[0] or '').startswith('video/')
return (isinstance(file, str) and
(mimetypes.guess_type(file)[0] or '').startswith('video/'))
def parse_phone(phone):
@ -346,15 +356,23 @@ def parse_username(username):
a string, username or URL. Returns a tuple consisting of
both the stripped, lowercase username and whether it is
a joinchat/ hash (in which case is not lowercase'd).
Returns None if the username is not valid.
"""
username = username.strip()
m = USERNAME_RE.match(username)
if m:
result = username[m.end():]
username = username[m.end():]
is_invite = bool(m.group(1))
return result if is_invite else result.lower(), is_invite
if is_invite:
return username, True
else:
username = username.rstrip('/')
if VALID_USERNAME_RE.match(username):
return username.lower(), False
else:
return None, False
def get_peer_id(peer):

View File

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

View File

@ -1,12 +1,13 @@
import os
from getpass import getpass
from telethon import TelegramClient, ConnectionMode
from telethon.utils import get_display_name
from telethon import ConnectionMode, TelegramClient
from telethon.errors import SessionPasswordNeededError
from telethon.tl.types import (
UpdateShortChatMessage, UpdateShortMessage, PeerChat
PeerChat, UpdateShortChatMessage, UpdateShortMessage
)
from telethon.utils import get_display_name
def sprint(string, *args, **kwargs):
@ -47,6 +48,7 @@ class InteractiveTelegramClient(TelegramClient):
Telegram through Telethon, such as listing dialogs (open chats),
talking to people, downloading media, and receiving updates.
"""
def __init__(self, session_user_id, user_phone, api_id, api_hash,
proxy=None):
"""
@ -190,6 +192,7 @@ class InteractiveTelegramClient(TelegramClient):
print(' !d <msg-id>: Deletes a message by its id')
print(' !dm <msg-id>: Downloads the given message Media (if any).')
print(' !dp: Downloads the current dialog Profile picture.')
print(' !i: Prints information about this chat..')
print()
# And start a while loop to chat
@ -234,8 +237,7 @@ class InteractiveTelegramClient(TelegramClient):
# And print it to the user
sprint('[{}:{}] (ID={}) {}: {}'.format(
msg.date.hour, msg.date.minute, msg.id, name,
content))
msg.date.hour, msg.date.minute, msg.id, name, content))
# Send photo
elif msg.startswith('!up '):
@ -264,12 +266,16 @@ class InteractiveTelegramClient(TelegramClient):
os.makedirs('usermedia', exist_ok=True)
output = self.download_profile_photo(entity, 'usermedia')
if output:
print(
'Profile picture downloaded to {}'.format(output)
)
print('Profile picture downloaded to', output)
else:
print('No profile picture found for this user!')
elif msg == '!i':
attributes = list(entity.to_dict().items())
pad = max(len(x) for x, _ in attributes)
for name, val in attributes:
print("{:<{width}} : {}".format(name, val, width=pad))
# Send chat message (if any)
elif msg:
self.send_message(entity, msg, link_preview=False)
@ -356,6 +362,5 @@ class InteractiveTelegramClient(TelegramClient):
else:
who = self.get_entity(update.from_id)
sprint('<< {} @ {} sent "{}"'.format(
get_display_name(which), get_display_name(who),
update.message
get_display_name(which), get_display_name(who), update.message
))

View File

@ -1,46 +1,36 @@
#!/usr/bin/env python3
# A simple script to print all updates received
from getpass import getpass
from os import environ
# environ is used to get API information from environment variables
# You could also use a config file, pass them as arguments,
# or even hardcode them (not recommended)
from telethon import TelegramClient
from telethon.errors import SessionPasswordNeededError
def main():
session_name = environ.get('TG_SESSION', 'session')
user_phone = environ['TG_PHONE']
client = TelegramClient(session_name,
int(environ['TG_API_ID']),
environ['TG_API_HASH'],
proxy=None,
update_workers=4)
update_workers=4,
spawn_read_thread=False)
print('INFO: Connecting to Telegram Servers...', end='', flush=True)
client.connect()
print('Done!')
if not client.is_user_authorized():
print('INFO: Unauthorized user')
client.send_code_request(user_phone)
code_ok = False
while not code_ok:
code = input('Enter the auth code: ')
try:
code_ok = client.sign_in(user_phone, code)
except SessionPasswordNeededError:
password = getpass('Two step verification enabled. Please enter your password: ')
code_ok = client.sign_in(password=password)
print('INFO: Client initialized succesfully!')
if 'TG_PHONE' in environ:
client.start(phone=environ['TG_PHONE'])
else:
client.start()
client.add_update_handler(update_handler)
input('Press Enter to stop this!\n')
print('(Press Ctrl+C to stop this)')
client.idle()
def update_handler(update):
print(update)
print('Press Enter to stop this!')
if __name__ == '__main__':
main()

View File

@ -9,17 +9,12 @@ file, including TG_API_ID, TG_API_HASH, TG_PHONE and optionally TG_SESSION.
This script assumes that you have certain files on the working directory,
such as "xfiles.m4a" or "anytime.png" for some of the automated replies.
"""
from getpass import getpass
import re
from collections import defaultdict
from datetime import datetime, timedelta
from os import environ
import re
from telethon import TelegramClient
from telethon.errors import SessionPasswordNeededError
from telethon.tl.types import UpdateNewChannelMessage, UpdateShortMessage, MessageService
from telethon.tl.functions.messages import EditMessageRequest
from telethon import TelegramClient, events, utils
"""Uncomment this for debugging
import logging
@ -35,103 +30,57 @@ REACTS = {'emacs': 'Needs more vim',
recent_reacts = defaultdict(list)
def update_handler(update):
global recent_reacts
try:
msg = update.message
except AttributeError:
# print(update, 'did not have update.message')
return
if isinstance(msg, MessageService):
print(msg, 'was service msg')
return
if __name__ == '__main__':
# TG_API_ID and TG_API_HASH *must* exist or this won't run!
session_name = environ.get('TG_SESSION', 'session')
client = TelegramClient(
session_name, int(environ['TG_API_ID']), environ['TG_API_HASH'],
spawn_read_thread=False, proxy=None, update_workers=4
)
# React to messages in supergroups and PMs
if isinstance(update, UpdateNewChannelMessage):
words = re.split('\W+', msg.message)
@client.on(events.NewMessage)
def my_handler(event: events.NewMessage.Event):
global recent_reacts
# This utils function gets the unique identifier from peers (to_id)
to_id = utils.get_peer_id(event.message.to_id)
# Through event.raw_text we access the text of messages without format
words = re.split('\W+', event.raw_text)
# Try to match some reaction
for trigger, response in REACTS.items():
if len(recent_reacts[msg.to_id.channel_id]) > 3:
if len(recent_reacts[to_id]) > 3:
# Silently ignore triggers if we've recently sent 3 reactions
break
if trigger in words:
# Remove recent replies older than 10 minutes
recent_reacts[msg.to_id.channel_id] = [
a for a in recent_reacts[msg.to_id.channel_id] if
recent_reacts[to_id] = [
a for a in recent_reacts[to_id] if
datetime.now() - a < timedelta(minutes=10)
]
# Send a reaction
client.send_message(msg.to_id, response, reply_to=msg.id)
# Send a reaction as a reply (otherwise, event.respond())
event.reply(response)
# Add this reaction to the list of recent actions
recent_reacts[msg.to_id.channel_id].append(datetime.now())
if isinstance(update, UpdateShortMessage):
words = re.split('\W+', msg)
for trigger, response in REACTS.items():
if len(recent_reacts[update.user_id]) > 3:
# Silently ignore triggers if we've recently sent 3 reactions
break
if trigger in words:
# Send a reaction
client.send_message(update.user_id, response, reply_to=update.id)
# Add this reaction to the list of recent reactions
recent_reacts[update.user_id].append(datetime.now())
recent_reacts[to_id].append(datetime.now())
# Automatically send relevant media when we say certain things
# When invoking requests, get_input_entity needs to be called manually
if isinstance(update, UpdateNewChannelMessage) and msg.out:
if msg.message.lower() == 'x files theme':
client.send_voice_note(msg.to_id, 'xfiles.m4a', reply_to=msg.id)
if msg.message.lower() == 'anytime':
client.send_file(msg.to_id, 'anytime.png', reply_to=msg.id)
if '.shrug' in msg.message:
client(EditMessageRequest(
client.get_input_entity(msg.to_id), msg.id,
message=msg.message.replace('.shrug', r'¯\_(ツ)_/¯')
))
if event.out:
if event.raw_text.lower() == 'x files theme':
client.send_voice_note(event.message.to_id, 'xfiles.m4a',
reply_to=event.message.id)
if event.raw_text.lower() == 'anytime':
client.send_file(event.message.to_id, 'anytime.png',
reply_to=event.message.id)
if '.shrug' in event.text:
event.edit(event.text.replace('.shrug', r'¯\_(ツ)_/¯'))
if isinstance(update, UpdateShortMessage) and update.out:
if msg.lower() == 'x files theme':
client.send_voice_note(update.user_id, 'xfiles.m4a', reply_to=update.id)
if msg.lower() == 'anytime':
client.send_file(update.user_id, 'anytime.png', reply_to=update.id)
if '.shrug' in msg:
client(EditMessageRequest(
client.get_input_entity(update.user_id), update.id,
message=msg.replace('.shrug', r'¯\_(ツ)_/¯')
))
if 'TG_PHONE' in environ:
client.start(phone=environ['TG_PHONE'])
else:
client.start()
if __name__ == '__main__':
session_name = environ.get('TG_SESSION', 'session')
user_phone = environ['TG_PHONE']
client = TelegramClient(
session_name, int(environ['TG_API_ID']), environ['TG_API_HASH'],
proxy=None, update_workers=4
)
try:
print('INFO: Connecting to Telegram Servers...', end='', flush=True)
client.connect()
print('Done!')
if not client.is_user_authorized():
print('INFO: Unauthorized user')
client.send_code_request(user_phone)
code_ok = False
while not code_ok:
code = input('Enter the auth code: ')
try:
code_ok = client.sign_in(user_phone, code)
except SessionPasswordNeededError:
password = getpass('Two step verification enabled. '
'Please enter your password: ')
code_ok = client.sign_in(password=password)
print('INFO: Client initialized successfully!')
client.add_update_handler(update_handler)
input('Press Enter to stop this!\n')
except KeyboardInterrupt:
pass
finally:
client.disconnect()
print('(Press Ctrl+C to stop this)')
client.idle()

View File

@ -53,7 +53,10 @@ destroy_auth_key_fail#ea109b13 = DestroyAuthKeyRes;
---functions---
// Deprecated since somewhere around February of 2018
// See https://core.telegram.org/mtproto/auth_key
req_pq#60469778 nonce:int128 = ResPQ;
req_pq_multi#be7e8ef1 nonce:int128 = ResPQ;
req_DH_params#d712e4be nonce:int128 server_nonce:int128 p:bytes q:bytes public_key_fingerprint:long encrypted_data:bytes = Server_DH_Params;