Merge branch 'master' into asyncio

This commit is contained in:
Lonami Exo 2018-03-18 10:23:48 +01:00
commit 1047e9c3d5
25 changed files with 664 additions and 448 deletions

View File

@ -1,4 +1,3 @@
cryptg
pysocks
hachoir3
sqlalchemy

View File

@ -36,77 +36,26 @@ one of the other implementations or implement your own storage.
To use a custom session storage, simply pass the custom session instance to
``TelegramClient`` instead of the session name.
Currently, there are three implementations of the abstract ``Session`` class:
* ``MemorySession``. Stores session data in Python variables.
* ``SQLiteSession``, (default). Stores sessions in their own SQLite databases.
* ``AlchemySession``. Stores all sessions in a single database via SQLAlchemy.
Telethon contains two implementations of the abstract ``Session`` class:
Using AlchemySession
~~~~~~~~~~~~~~~~~~~~
The ``AlchemySession`` implementation can store multiple Sessions in the same
database, but to do this, each session instance needs to have access to the
same models and database session.
* ``MemorySession``: stores session data in Python variables.
* ``SQLiteSession``, (default): stores sessions in their own SQLite databases.
To get started, you need to create an ``AlchemySessionContainer`` which will
contain that shared data. The simplest way to use ``AlchemySessionContainer``
is to simply pass it the database URL:
There are other community-maintained implementations available:
.. code-block:: python
container = AlchemySessionContainer('mysql://user:pass@localhost/telethon')
If you already have SQLAlchemy set up for your own project, you can also pass
the engine separately:
.. code-block:: python
my_sqlalchemy_engine = sqlalchemy.create_engine('...')
container = AlchemySessionContainer(engine=my_sqlalchemy_engine)
By default, the session container will manage table creation/schema updates/etc
automatically. If you want to manage everything yourself, you can pass your
SQLAlchemy Session and ``declarative_base`` instances and set ``manage_tables``
to ``False``:
.. code-block:: python
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import orm
import sqlalchemy
...
session_factory = orm.sessionmaker(bind=my_sqlalchemy_engine)
session = session_factory()
my_base = declarative_base()
...
container = AlchemySessionContainer(
session=session, table_base=my_base, manage_tables=False
)
You always need to provide either ``engine`` or ``session`` to the container.
If you set ``manage_tables=False`` and provide a ``session``, ``engine`` is not
needed. In any other case, ``engine`` is always required.
After you have your ``AlchemySessionContainer`` instance created, you can
create new sessions by calling ``new_session``:
.. code-block:: python
session = container.new_session('some session id')
client = TelegramClient(session)
where ``some session id`` is an unique identifier for the session.
* `SQLAlchemy <https://github.com/tulir/telethon-session-sqlalchemy>`_: stores all sessions in a single database via SQLAlchemy.
* `Redis <https://github.com/ezdev128/telethon-session-redis>`_: stores all sessions in a single Redis data store.
Creating your own storage
~~~~~~~~~~~~~~~~~~~~~~~~~
The easiest way to create your own implementation is to use ``MemorySession``
as the base and check out how ``SQLiteSession`` or ``AlchemySession`` work.
You can find the relevant Python files under the ``sessions`` directory.
The easiest way to create your own storage implementation is to use ``MemorySession``
as the base and check out how ``SQLiteSession`` or one of the community-maintained
implementations work. You can find the relevant Python files under the ``sessions``
directory in Telethon.
After you have made your own implementation, you can add it to the community-maintained
session implementation list above with a pull request.
SQLite Sessions and Heroku
--------------------------

View File

@ -10,6 +10,14 @@ The library widely uses the concept of "entities". An entity will refer
to any ``User``, ``Chat`` or ``Channel`` object that the API may return
in response to certain methods, such as ``GetUsersRequest``.
.. note::
When something "entity-like" is required, it means that you need to
provide something that can be turned into an entity. These things include,
but are not limited to, usernames, exact titles, IDs, ``Peer`` objects,
or even entire ``User``, ``Chat`` and ``Channel`` objects and even phone
numbers from people you have in your contacts.
Getting entities
****************

View File

@ -8,6 +8,11 @@ TelegramClient
Introduction
************
.. note::
Check the :ref:`telethon-package` if you're looking for the methods
reference instead of this tutorial.
The ``TelegramClient`` is the central class of the library, the one
you will be using most of the time. For this reason, it's important
to know what it offers.
@ -86,13 +91,7 @@ Please refer to :ref:`accessing-the-full-api` if these aren't enough,
and don't be afraid to read the source code of the InteractiveTelegramClient_
or even the TelegramClient_ itself to learn how it works.
To see the methods available in the client, see :ref:`telethon-package`.
.. _InteractiveTelegramClient: https://github.com/LonamiWebs/Telethon/blob/master/telethon_examples/interactive_telegram_client.py
.. _TelegramClient: https://github.com/LonamiWebs/Telethon/blob/master/telethon/telegram_client.py
.. automodule:: telethon.telegram_client
:members:
:undoc-members:
:show-inheritance:

View File

@ -7,7 +7,9 @@ Working with Updates
The library comes with the :mod:`events` module. *Events* are an abstraction
over what Telegram calls `updates`__, and are meant to ease simple and common
usage when dealing with them, since there are many updates. Let's dive in!
usage when dealing with them, since there are many updates. If you're looking
for the method reference, check :ref:`telethon-events-package`, otherwise,
let's dive in!
.. note::
@ -114,12 +116,15 @@ for example:
import random
@client.on(events.NewMessage(chats='TelethonOffTopic', incoming=True))
# Either a single item or a list of them will work for the chats.
# You can also use the IDs, Peers, or even User/Chat/Channel objects.
@client.on(events.NewMessage(chats=('TelethonChat', 'TelethonOffTopic')))
def normal_handler(event):
if 'roll' in event.raw_text:
event.reply(str(random.randint(1, 6)))
# Similarly, you can use incoming=True for messages that you receive
@client.on(events.NewMessage(chats='TelethonOffTopic', outgoing=True))
def admin_handler(event):
if event.raw_text.startswith('eval'):
@ -135,6 +140,20 @@ random number, while if you say ``'eval 4+4'``, you will reply with the
solution. Try it!
Events without decorators
*************************
If for any reason you can't use the ``@client.on`` syntax, don't worry.
You can call ``client.add_event_handler(callback, event)`` to achieve
the same effect.
Similar to that method, you also have :meth:`client.remove_event_handler`
and :meth:`client.list_event_handlers` which do as they names indicate.
The ``event`` type is optional in all methods and defaults to ``events.Raw``
for adding, and ``None`` when removing (so all callbacks would be removed).
Stopping propagation of Updates
*******************************
@ -162,14 +181,8 @@ propagation of the update through your handlers to stop:
pass
Events module
*************
.. automodule:: telethon.events
:members:
:undoc-members:
:show-inheritance:
Remember to check :ref:`telethon-events-package` if you're looking for
the methods reference.
__ https://lonamiwebs.github.io/Telethon/types/update.html

View File

@ -14,6 +14,69 @@ it can take advantage of new goodies!
.. contents:: List of All Versions
Iterator methods (v0.18.1)
==========================
*Published at 2018/03/17*
All the ``.get_`` methods in the ``TelegramClient`` now have a ``.iter_``
counterpart, so you can do operations while retrieving items from them.
For instance, you can ``client.iter_dialogs()`` and ``break`` once you
find what you're looking for instead fetching them all at once.
Another big thing, you can get entities by just their positive ID. This
may cause some collisions (although it's very unlikely), and you can (should)
still be explicit about the type you want. However, it's a lot more convenient
and less confusing.
Breaking changes
~~~~~~~~~~~~~~~~
- The library only offers the default ``SQLiteSession`` again.
See :ref:`sessions` for more on how to use a different storage from now on.
Additions
~~~~~~~~~
- Events now override ``__str__`` and implement ``.stringify()``, just like
every other ``TLObject`` does.
- ``events.ChatAction`` now has :meth:`respond`, :meth:`reply` and
:meth:`delete` for the message that triggered it.
- :meth:`client.iter_participants` (and its :meth:`client.get_participants`
counterpart) now expose the ``filter`` argument, and the returned users
also expose the ``.participant`` they are.
- You can now use :meth:`client.remove_event_handler` and
:meth:`client.list_event_handlers` similar how you could with normal updates.
- New properties on ``events.NewMessage``, like ``.video_note`` and ``.gif``
to access only specific types of documents.
- The ``Draft`` class now exposes ``.text`` and ``.raw_text``, as well as a
new :meth:`Draft.send` to send it.
Bug fixes
~~~~~~~~~
- ``MessageEdited`` was ignoring ``NewMessage`` constructor arguments.
- Fixes for ``Event.delete_messages`` which wouldn't handle ``MessageService``.
- Bot API style IDs not working on :meth:`client.get_input_entity`.
- :meth:`client.download_media` didn't support ``PhotoSize``.
Enhancements
~~~~~~~~~~~~
- Less RPC are made when accessing the ``.sender`` and ``.chat`` of some
events (mostly those that occur in a channel).
- You can send albums larger than 10 items (they will be sliced for you),
as well as mixing normal files with photos.
- ``TLObject`` now have Python type hints.
Internal changes
~~~~~~~~~~~~~~~~
- Several documentation corrections.
- :meth:`client.get_dialogs` is only called once again when an entity is
not found to avoid flood waits.
Sessions overhaul (v0.18)
=========================

View File

@ -1,3 +1,5 @@
.. _api-status:
==========
API Status
==========
@ -10,11 +12,9 @@ anyone can query, made by `Daniil <https://github.com/danog>`__. All the
information sent is a ``GET`` request with the error code, error message
and method used.
If you still would like to opt out, simply set
``client.session.report_errors = False`` to disable this feature, or
pass ``report_errors=False`` as a named parameter when creating a
``TelegramClient`` instance. However Daniil would really thank you if
you helped him (and everyone) by keeping it on!
If you still would like to opt out, you can disable this feature by setting
``client.session.report_errors = False``. However Daniil would really thank
you if you helped him (and everyone) by keeping it on!
Querying the API status
***********************

View File

@ -38,9 +38,9 @@ Kotlin
******
`Kotlogram <https://github.com/badoualy/kotlogram>`__ is a Telegram
implementation written in Kotlin (the now
implementation written in Kotlin (one of the
`official <https://blog.jetbrains.com/kotlin/2017/05/kotlin-on-android-now-official/>`__
language for
languages for
`Android <https://developer.android.com/kotlin/index.html>`__) by
`@badoualy <https://github.com/badoualy>`__, currently as a beta
yet working.

View File

@ -8,8 +8,7 @@ To run Telethon on a test server, use the following code:
.. code-block:: python
client = TelegramClient(None, api_id, api_hash)
client.session.server_address = '149.154.167.40'
client.connect()
client.session.set_dc(dc_id, '149.154.167.40', 80)
You can check your ``'test ip'`` on https://my.telegram.org.
@ -17,16 +16,20 @@ You should set ``None`` session so to ensure you're generating a new
authorization key for it (it would fail if you used a session where you
had previously connected to another data center).
Once you're connected, you'll likely need to ``.sign_up()``. Remember
`anyone can access the phone you
Note that port 443 might not work, so you can try with 80 instead.
Once you're connected, you'll likely be asked to either sign in or sign up.
Remember `anyone can access the phone you
choose <https://core.telegram.org/api/datacenter#testing-redirects>`__,
so don't store sensitive data here:
so don't store sensitive data here.
Valid phone numbers are ``99966XYYYY``, where ``X`` is the ``dc_id`` and
``YYYY`` is any number you want, for example, ``1234`` in ``dc_id = 2`` would
be ``9996621234``. The code sent by Telegram will be ``dc_id`` repeated five
times, in this case, ``22222`` so we can hardcode that:
.. code-block:: python
from random import randint
dc_id = '2' # Change this to the DC id of the test server you chose
phone = '99966' + dc_id + str(randint(9999)).zfill(4)
client.send_code_request(phone)
client.sign_up(dc_id * 5, 'Some', 'Name')
client = TelegramClient(None, api_id, api_hash)
client.session.set_dc(2, '149.154.167.40', 80)
client.start(phone='9996621234', code_callback=lambda: '22222')

View File

@ -15,7 +15,8 @@ or use the menu on the left. Remember to read the :ref:`changelog`
when you upgrade!
.. important::
If you're new here, you want to read :ref:`getting-started`.
If you're new here, you want to read :ref:`getting-started`. If you're
looking for the method reference, you should check :ref:`telethon-package`.
What is this?

View File

@ -42,14 +42,6 @@ telethon\.crypto\.factorization module
:undoc-members:
:show-inheritance:
telethon\.crypto\.libssl module
-------------------------------
.. automodule:: telethon.crypto.libssl
:members:
:undoc-members:
:show-inheritance:
telethon\.crypto\.rsa module
----------------------------

View File

@ -1,4 +1,9 @@
.. _telethon-events-package:
telethon\.events package
========================
.. automodule:: telethon.events
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,11 +1,14 @@
.. _telethon-package:
telethon package
================
telethon\.helpers module
------------------------
telethon\.telegram\_client module
---------------------------------
.. automodule:: telethon.helpers
.. automodule:: telethon.telegram_client
:members:
:undoc-members:
:show-inheritance:
@ -18,10 +21,18 @@ telethon\.telegram\_bare\_client module
:undoc-members:
:show-inheritance:
telethon\.telegram\_client module
---------------------------------
telethon\.utils module
----------------------
.. automodule:: telethon.telegram_client
.. automodule:: telethon.utils
:members:
:undoc-members:
:show-inheritance:
telethon\.helpers module
------------------------
.. automodule:: telethon.helpers
:members:
:undoc-members:
:show-inheritance:
@ -42,18 +53,10 @@ telethon\.update\_state module
:undoc-members:
:show-inheritance:
telethon\.utils module
----------------------
telethon\.sessions module
-------------------------
.. automodule:: telethon.utils
:members:
:undoc-members:
:show-inheritance:
telethon\.session module
------------------------
.. automodule:: telethon.session
.. automodule:: telethon.sessions
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,2 +1,3 @@
pyaes
rsa
typing

View File

@ -13,7 +13,7 @@ Extra supported commands are:
# To use a consistent encoding
from codecs import open
from sys import argv
from sys import argv, version_info
import os
import re
@ -154,10 +154,10 @@ def main():
'telethon_generator/parser/tl_object.py',
'telethon_generator/parser/tl_parser.py',
]),
install_requires=['pyaes', 'rsa'],
install_requires=['pyaes', 'rsa',
'typing' if version_info < (3, 5) else ""],
extras_require={
'cryptg': ['cryptg'],
'sqlalchemy': ['sqlalchemy']
'cryptg': ['cryptg']
}
)

150
telethon/update_state.py Normal file
View File

@ -0,0 +1,150 @@
import itertools
import logging
from datetime import datetime
from queue import Queue, Empty
from threading import RLock, Thread
from . import utils
from .tl import types as tl
__log__ = logging.getLogger(__name__)
class UpdateState:
"""Used to hold the current state of processed updates.
To retrieve an update, .poll() should be called.
"""
WORKER_POLL_TIMEOUT = 5.0 # Avoid waiting forever on the workers
def __init__(self, workers=None):
"""
:param workers: This integer parameter has three possible cases:
workers is None: Updates will *not* be stored on self.
workers = 0: Another thread is responsible for calling self.poll()
workers > 0: 'workers' background threads will be spawned, any
any of them will invoke the self.handler.
"""
self._workers = workers
self._worker_threads = []
self.handler = None
self._updates_lock = RLock()
self._updates = Queue()
# https://core.telegram.org/api/updates
self._state = tl.updates.State(0, 0, datetime.now(), 0, 0)
def can_poll(self):
"""Returns True if a call to .poll() won't lock"""
return not self._updates.empty()
def poll(self, timeout=None):
"""
Polls an update or blocks until an update object is available.
If 'timeout is not None', it should be a floating point value,
and the method will 'return None' if waiting times out.
"""
try:
return self._updates.get(timeout=timeout)
except Empty:
return None
def get_workers(self):
return self._workers
def set_workers(self, n):
"""Changes the number of workers running.
If 'n is None', clears all pending updates from memory.
"""
if n is None:
self.stop_workers()
else:
self._workers = n
self.setup_workers()
workers = property(fget=get_workers, fset=set_workers)
def stop_workers(self):
"""
Waits for all the worker threads to stop.
"""
# Put dummy ``None`` objects so that they don't need to timeout.
n = self._workers
self._workers = None
if n:
with self._updates_lock:
for _ in range(n):
self._updates.put(None)
for t in self._worker_threads:
t.join()
self._worker_threads.clear()
self._workers = n
def setup_workers(self):
if self._worker_threads or not self._workers:
# There already are workers, or workers is None or 0. Do nothing.
return
for i in range(self._workers):
thread = Thread(
target=UpdateState._worker_loop,
name='UpdateWorker{}'.format(i),
daemon=True,
args=(self, i)
)
self._worker_threads.append(thread)
thread.start()
def _worker_loop(self, wid):
while self._workers is not None:
try:
update = self.poll(timeout=UpdateState.WORKER_POLL_TIMEOUT)
if update and self.handler:
self.handler(update)
except StopIteration:
break
except:
# We don't want to crash a worker thread due to any reason
__log__.exception('Unhandled exception on worker %d', wid)
def process(self, update):
"""Processes an update object. This method is normally called by
the library itself.
"""
if self._workers is None:
return # No processing needs to be done if nobody's working
with self._updates_lock:
if isinstance(update, tl.updates.State):
__log__.debug('Saved new updates state')
self._state = update
return # Nothing else to be done
if hasattr(update, 'pts'):
self._state.pts = update.pts
# After running the script for over an hour and receiving over
# 1000 updates, the only duplicates received were users going
# online or offline. We can trust the server until new reports.
#
# TODO Note somewhere that all updates are modified to include
# .entities, which is a dictionary you can access but may be empty.
# This should only be used as read-only.
if isinstance(update, tl.UpdateShort):
update.update.entities = {}
self._updates.put(update.update)
# Expand "Updates" into "Update", and pass these to callbacks.
# Since .users and .chats have already been processed, we
# don't need to care about those either.
elif isinstance(update, (tl.Updates, tl.UpdatesCombined)):
entities = {utils.get_peer_id(x): x for x in
itertools.chain(update.users, update.chats)}
for u in update.updates:
u.entities = entities
self._updates.put(u)
# TODO Handle "tl.UpdatesTooLong"
else:
update.entities = {}
self._updates.put(update)

View File

@ -2,11 +2,12 @@ import abc
import datetime
import itertools
import re
import warnings
from .. import utils
from ..errors import RPCError
from ..extensions import markdown
from ..tl import types, functions
from ..tl import TLObject, types, functions
async def _into_id_set(client, chats):
@ -71,6 +72,7 @@ class _EventCommon(abc.ABC):
"""Intermediate class with common things to all events"""
def __init__(self, chat_peer=None, msg_id=None, broadcast=False):
self._entities = {}
self._client = None
self._chat_peer = chat_peer
self._message_id = msg_id
@ -86,12 +88,15 @@ class _EventCommon(abc.ABC):
)
self.is_channel = isinstance(chat_peer, types.PeerChannel)
async def _get_input_entity(self, msg_id, entity_id, chat=None):
async def _get_entity(self, msg_id, entity_id, chat=None):
"""
Helper function to call GetMessages on the give msg_id and
return the input entity whose ID is the given entity ID.
If ``chat`` is present it must be an InputPeer.
Returns a tuple of (entity, input_peer) if it was found, or
a tuple of (None, None) if it couldn't be.
"""
try:
if isinstance(chat, types.InputPeerChannel):
@ -103,14 +108,17 @@ class _EventCommon(abc.ABC):
functions.messages.GetMessagesRequest([msg_id])
)
except RPCError:
return
return None, None
entity = {
utils.get_peer_id(x): x for x in itertools.chain(
getattr(result, 'chats', []),
getattr(result, 'users', []))
}.get(entity_id)
if entity:
return utils.get_input_peer(entity)
return entity, utils.get_input_peer(entity)
else:
return None, None
@property
async def input_chat(self):
@ -134,7 +142,7 @@ class _EventCommon(abc.ABC):
# TODO For channels, getDifference? Maybe looking
# in the dialogs (which is already done) is enough.
if self._message_id is not None:
self._input_chat = await self._get_input_entity(
self._chat, self._input_chat = await self._get_entity(
self._message_id,
utils.get_peer_id(self._chat_peer)
)
@ -147,14 +155,32 @@ class _EventCommon(abc.ABC):
async def chat(self):
"""
The (:obj:`User` | :obj:`Chat` | :obj:`Channel`, optional) on which
the event occurred. This property will make an API call the first time
to get the most up to date version of the chat, so use with care as
there is no caching besides local caching yet.
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.
"""
if self._chat is None and await self.input_chat:
self._chat = await self._client.get_entity(await self._input_chat)
if not await self.input_chat:
return None
if self._chat is None:
self._chat = self._entities.get(utils.get_peer_id(self._input_chat))
if self._chat is None:
self._chat = await self._client.get_entity(self._input_chat)
return self._chat
def __str__(self):
return TLObject.pretty_format(self.to_dict())
def stringify(self):
return TLObject.pretty_format(self.to_dict(), indent=0)
def to_dict(self):
d = {k: v for k, v in self.__dict__.items() if k[0] != '_'}
d['_'] = self.__class__.__name__
return d
class Raw(_EventBuilder):
"""
@ -167,9 +193,19 @@ class Raw(_EventBuilder):
return update
def _name_inner_event(cls):
"""Decorator to rename cls.Event 'Event' as 'cls.Event'"""
if hasattr(cls, 'Event'):
cls.Event.__name__ = '{}.Event'.format(cls.__name__)
else:
warnings.warn('Class {} does not have a inner Event'.format(cls))
return cls
# Classes defined here are actually Event builders
# for their inner Event classes. Inner ._client is
# set later by the creator TelegramClient.
@_name_inner_event
class NewMessage(_EventBuilder):
"""
Represents a new message event builder.
@ -247,6 +283,10 @@ class NewMessage(_EventBuilder):
else:
return
event._entities = update.entities
return self._message_filter_event(event)
def _message_filter_event(self, event):
# Short-circuit if we let pass all events
if all(x is None for x in (self.incoming, self.outgoing, self.chats,
self.pattern)):
@ -299,8 +339,6 @@ class NewMessage(_EventBuilder):
self.message = message
self._text = None
self._input_chat = None
self._chat = None
self._input_sender = None
self._sender = None
@ -384,7 +422,7 @@ class NewMessage(_EventBuilder):
)
except (ValueError, TypeError):
# We can rely on self.input_chat for this
self._input_sender = await self._get_input_entity(
self._sender, self._input_sender = await self._get_entity(
self.message.id,
self.message.from_id,
chat=await self.input_chat
@ -395,14 +433,22 @@ class NewMessage(_EventBuilder):
@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.
This (:obj:`User`) may make an API call the first time to get
the most up to date version of the sender (mostly when the event
doesn't belong to a channel), so keep that in mind.
``input_sender`` needs to be available (often the case).
"""
if self._sender is None and await self.input_sender:
if not await self.input_sender:
return None
if self._sender is None:
self._sender = \
self._entities.get(utils.get_peer_id(self._input_sender))
if self._sender is None:
self._sender = await self._client.get_entity(self._input_sender)
return self._sender
@property
@ -556,6 +602,7 @@ class NewMessage(_EventBuilder):
return self.message.out
@_name_inner_event
class ChatAction(_EventBuilder):
"""
Represents an action in a chat (such as user joined, left, or new pin).
@ -621,6 +668,7 @@ class ChatAction(_EventBuilder):
else:
return
event._entities = update.entities
return self._filter_event(event)
class Event(_EventCommon):
@ -764,7 +812,13 @@ class ChatAction(_EventBuilder):
The user who added ``users``, if applicable (``None`` otherwise).
"""
if self._added_by and not isinstance(self._added_by, types.User):
self._added_by = await self._client.get_entity(self._added_by)
self._added_by =\
self._entities.get(utils.get_peer_id(self._added_by))
if not self._added_by:
self._added_by =\
await self._client.get_entity(self._added_by)
return self._added_by
@property
@ -773,7 +827,13 @@ class ChatAction(_EventBuilder):
The user who kicked ``users``, if applicable (``None`` otherwise).
"""
if self._kicked_by and not isinstance(self._kicked_by, types.User):
self._kicked_by = await self._client.get_entity(self._kicked_by)
self._kicked_by =\
self._entities.get(utils.get_peer_id(self._kicked_by))
if not self._kicked_by:
self._kicked_by =\
await self._client.get_entity(self._kicked_by)
return self._kicked_by
@property
@ -803,11 +863,24 @@ class ChatAction(_EventBuilder):
Might be empty if the information can't be retrieved or there
are no users taking part.
"""
if self._users is None and self._user_peers:
if not self._user_peers:
return []
if self._users is None:
have, missing = [], []
for peer in self._user_peers:
user = self._entities.get(utils.get_peer_id(peer))
if user:
have.append(user)
else:
missing.append(peer)
try:
self._users = await self._client.get_entity(self._user_peers)
missing = await self._client.get_entity(missing)
except (TypeError, ValueError):
self._users = []
missing = []
self._user_peers = have + missing
return self._users
@ -828,6 +901,7 @@ class ChatAction(_EventBuilder):
return self._input_users
@_name_inner_event
class UserUpdate(_EventBuilder):
"""
Represents an user update (gone online, offline, joined Telegram).
@ -839,6 +913,7 @@ class UserUpdate(_EventBuilder):
else:
return
event._entities = update.entities
return self._filter_event(event)
class Event(_EventCommon):
@ -975,6 +1050,7 @@ class UserUpdate(_EventBuilder):
return self.chat
@_name_inner_event
class MessageEdited(NewMessage):
"""
Event fired when a message has been edited.
@ -986,9 +1062,11 @@ class MessageEdited(NewMessage):
else:
return
return self._filter_event(event)
event._entities = update.entities
return self._message_filter_event(event)
@_name_inner_event
class MessageDeleted(_EventBuilder):
"""
Event fired when one or more messages are deleted.
@ -1007,6 +1085,7 @@ class MessageDeleted(_EventBuilder):
else:
return
event._entities = update.entities
return self._filter_event(event)
class Event(_EventCommon):

View File

@ -1,4 +1,3 @@
from .abstract import Session
from .memory import MemorySession
from .sqlite import SQLiteSession
from .sqlalchemy import AlchemySessionContainer, AlchemySession

View File

@ -1,236 +0,0 @@
try:
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, String, Integer, LargeBinary, orm
import sqlalchemy as sql
except ImportError:
sql = None
from .memory import MemorySession, _SentFileType
from .. import utils
from ..crypto import AuthKey
from ..tl.types import (
InputPhoto, InputDocument, PeerUser, PeerChat, PeerChannel
)
LATEST_VERSION = 1
class AlchemySessionContainer:
def __init__(self, engine=None, session=None, table_prefix='',
table_base=None, manage_tables=True):
if not sql:
raise ImportError('SQLAlchemy not imported')
if isinstance(engine, str):
engine = sql.create_engine(engine)
self.db_engine = engine
if not session:
db_factory = orm.sessionmaker(bind=self.db_engine)
self.db = orm.scoping.scoped_session(db_factory)
else:
self.db = session
table_base = table_base or declarative_base()
(self.Version, self.Session, self.Entity,
self.SentFile) = self.create_table_classes(self.db, table_prefix,
table_base)
if manage_tables:
table_base.metadata.bind = self.db_engine
if not self.db_engine.dialect.has_table(self.db_engine,
self.Version.__tablename__):
table_base.metadata.create_all()
self.db.add(self.Version(version=LATEST_VERSION))
self.db.commit()
else:
self.check_and_upgrade_database()
@staticmethod
def create_table_classes(db, prefix, Base):
class Version(Base):
query = db.query_property()
__tablename__ = '{prefix}version'.format(prefix=prefix)
version = Column(Integer, primary_key=True)
class Session(Base):
query = db.query_property()
__tablename__ = '{prefix}sessions'.format(prefix=prefix)
session_id = Column(String, primary_key=True)
dc_id = Column(Integer, primary_key=True)
server_address = Column(String)
port = Column(Integer)
auth_key = Column(LargeBinary)
class Entity(Base):
query = db.query_property()
__tablename__ = '{prefix}entities'.format(prefix=prefix)
session_id = Column(String, primary_key=True)
id = Column(Integer, primary_key=True)
hash = Column(Integer, nullable=False)
username = Column(String)
phone = Column(Integer)
name = Column(String)
class SentFile(Base):
query = db.query_property()
__tablename__ = '{prefix}sent_files'.format(prefix=prefix)
session_id = Column(String, primary_key=True)
md5_digest = Column(LargeBinary, primary_key=True)
file_size = Column(Integer, primary_key=True)
type = Column(Integer, primary_key=True)
id = Column(Integer)
hash = Column(Integer)
return Version, Session, Entity, SentFile
def check_and_upgrade_database(self):
row = self.Version.query.all()
version = row[0].version if row else 1
if version == LATEST_VERSION:
return
self.Version.query.delete()
# Implement table schema updates here and increase version
self.db.add(self.Version(version=version))
self.db.commit()
def new_session(self, session_id):
return AlchemySession(self, session_id)
def list_sessions(self):
return
def save(self):
self.db.commit()
class AlchemySession(MemorySession):
def __init__(self, container, session_id):
super().__init__()
self.container = container
self.db = container.db
self.Version, self.Session, self.Entity, self.SentFile = (
container.Version, container.Session, container.Entity,
container.SentFile)
self.session_id = session_id
self._load_session()
def _load_session(self):
sessions = self._db_query(self.Session).all()
session = sessions[0] if sessions else None
if session:
self._dc_id = session.dc_id
self._server_address = session.server_address
self._port = session.port
self._auth_key = AuthKey(data=session.auth_key)
def clone(self, to_instance=None):
return super().clone(MemorySession())
def set_dc(self, dc_id, server_address, port):
super().set_dc(dc_id, server_address, port)
self._update_session_table()
sessions = self._db_query(self.Session).all()
session = sessions[0] if sessions else None
if session and session.auth_key:
self._auth_key = AuthKey(data=session.auth_key)
else:
self._auth_key = None
@MemorySession.auth_key.setter
def auth_key(self, value):
self._auth_key = value
self._update_session_table()
def _update_session_table(self):
self.Session.query.filter(
self.Session.session_id == self.session_id).delete()
new = self.Session(session_id=self.session_id, dc_id=self._dc_id,
server_address=self._server_address,
port=self._port,
auth_key=(self._auth_key.key
if self._auth_key else b''))
self.db.merge(new)
def _db_query(self, dbclass, *args):
return dbclass.query.filter(
dbclass.session_id == self.session_id, *args
)
def save(self):
self.container.save()
def close(self):
# Nothing to do here, connection is managed by AlchemySessionContainer.
pass
def delete(self):
self._db_query(self.Session).delete()
self._db_query(self.Entity).delete()
self._db_query(self.SentFile).delete()
def _entity_values_to_row(self, id, hash, username, phone, name):
return self.Entity(session_id=self.session_id, id=id, hash=hash,
username=username, phone=phone, name=name)
def process_entities(self, tlo):
rows = self._entities_to_rows(tlo)
if not rows:
return
for row in rows:
self.db.merge(row)
self.save()
def get_entity_rows_by_phone(self, key):
row = self._db_query(self.Entity,
self.Entity.phone == key).one_or_none()
return (row.id, row.hash) if row else None
def get_entity_rows_by_username(self, key):
row = self._db_query(self.Entity,
self.Entity.username == key).one_or_none()
return (row.id, row.hash) if row else None
def get_entity_rows_by_name(self, key):
row = self._db_query(self.Entity,
self.Entity.name == key).one_or_none()
return (row.id, row.hash) if row else None
def get_entity_rows_by_id(self, key, exact=True):
if exact:
query = self._db_query(self.Entity, self.Entity.id == key)
else:
ids = (
utils.get_peer_id(PeerUser(key)),
utils.get_peer_id(PeerChat(key)),
utils.get_peer_id(PeerChannel(key))
)
query = self._db_query(self.Entity, self.Entity.id in ids)
row = query.one_or_none()
return (row.id, row.hash) if row else None
def get_file(self, md5_digest, file_size, cls):
row = self._db_query(self.SentFile,
self.SentFile.md5_digest == md5_digest,
self.SentFile.file_size == file_size,
self.SentFile.type == _SentFileType.from_type(
cls).value).one_or_none()
return (row.id, row.hash) if row else None
def cache_file(self, md5_digest, file_size, instance):
if not isinstance(instance, (InputDocument, InputPhoto)):
raise TypeError('Cannot cache %s instance' % type(instance))
self.db.merge(
self.SentFile(session_id=self.session_id, md5_digest=md5_digest,
type=_SentFileType.from_type(type(instance)).value,
id=instance.id, hash=instance.access_hash))
self.save()

View File

@ -70,6 +70,7 @@ class TelegramBareClient:
proxy=None,
timeout=timedelta(seconds=5),
loop=None,
report_errors=True,
device_model=None,
system_version=None,
app_version=None,
@ -102,6 +103,7 @@ class TelegramBareClient:
DEFAULT_PORT
)
session.report_errors = report_errors
self.session = session
self.api_id = int(api_id)
self.api_hash = api_hash

View File

@ -146,9 +146,13 @@ class TelegramClient(TelegramBareClient):
instantly, as soon as they arrive. Can still be disabled
if you want to run the library without any additional thread.
report_errors (:obj:`bool`, optional):
Whether to report RPC errors or not. Defaults to ``True``,
see :ref:`api-status` for more information.
Kwargs:
Extra parameters will be forwarded to the ``Session`` file.
Most relevant parameters are:
Some extra parameters are required when establishing the first
connection. These are are (along with their default values):
.. code-block:: python
@ -157,7 +161,6 @@ class TelegramClient(TelegramBareClient):
app_version = TelegramClient.__version__
lang_code = 'en'
system_lang_code = lang_code
report_errors = True
"""
# region Initialization
@ -168,6 +171,7 @@ class TelegramClient(TelegramBareClient):
proxy=None,
timeout=timedelta(seconds=5),
loop=None,
report_errors=True,
**kwargs):
super().__init__(
session, api_id, api_hash,
@ -176,6 +180,7 @@ class TelegramClient(TelegramBareClient):
proxy=proxy,
timeout=timeout,
loop=loop,
report_errors=report_errors,
**kwargs
)
@ -190,6 +195,9 @@ class TelegramClient(TelegramBareClient):
# Sometimes we need to know who we are, cache the self peer
self._self_input_peer = None
# Don't call .get_dialogs() every time a .get_entity() fails
self._called_get_dialogs = False
# endregion
# region Telegram requests functions
@ -1156,9 +1164,10 @@ class TelegramClient(TelegramBareClient):
raise TypeError('Invalid message type: {}'.format(type(message)))
async def iter_participants(self, entity, limit=None, search='',
aggressive=False, _total_box=None):
filter=None, aggressive=False,
_total_box=None):
"""
Gets the list of participants from the specified entity.
Iterator over the participants belonging to the specified chat.
Args:
entity (:obj:`entity`):
@ -1170,6 +1179,12 @@ class TelegramClient(TelegramBareClient):
search (:obj:`str`, optional):
Look for participants with this string in name/username.
filter (:obj:`ChannelParticipantsFilter`, optional):
The filter to be used, if you want e.g. only admins. See
https://lonamiwebs.github.io/Telethon/types/channel_participants_filter.html.
Note that you might not have permissions for some filter.
This has no effect for normal chats or users.
aggressive (:obj:`bool`, optional):
Aggressively looks for all participants in the chat in
order to get more than 10,000 members (a hard limit
@ -1178,16 +1193,32 @@ class TelegramClient(TelegramBareClient):
participants on groups with 100,000 members.
This has no effect for groups or channels with less than
10,000 members.
10,000 members, or if a ``filter`` is given.
_total_box (:obj:`_Box`, optional):
A _Box instance to pass the total parameter by reference.
Returns:
A list of participants with an additional .total variable on the
list indicating the total amount of members in this group/channel.
Yields:
The ``User`` objects returned by ``GetParticipantsRequest``
with an additional ``.participant`` attribute which is the
matched ``ChannelParticipant`` type for channels/megagroups
or ``ChatParticipants`` for normal chats.
"""
if isinstance(filter, type):
filter = filter()
entity = await self.get_input_entity(entity)
if search and (filter or not isinstance(entity, InputPeerChannel)):
# We need to 'search' ourselves unless we have a PeerChannel
search = search.lower()
def filter_entity(ent):
return search in utils.get_display_name(ent).lower() or\
search in (getattr(ent, 'username', '') or None).lower()
else:
def filter_entity(ent):
return True
limit = float('inf') if limit is None else int(limit)
if isinstance(entity, InputPeerChannel):
total = (await self(GetFullChannelRequest(
@ -1200,7 +1231,7 @@ class TelegramClient(TelegramBareClient):
return
seen = set()
if total > 10000 and aggressive:
if total > 10000 and aggressive and not filter:
requests = [GetParticipantsRequest(
channel=entity,
filter=ChannelParticipantsSearch(search + chr(x)),
@ -1211,7 +1242,7 @@ class TelegramClient(TelegramBareClient):
else:
requests = [GetParticipantsRequest(
channel=entity,
filter=ChannelParticipantsSearch(search),
filter=filter or ChannelParticipantsSearch(search),
offset=0,
limit=200,
hash=0
@ -1237,31 +1268,47 @@ class TelegramClient(TelegramBareClient):
if not participants.users:
requests.pop(i)
else:
requests[i].offset += len(participants.users)
for user in participants.users:
if user.id not in seen:
seen.add(user.id)
yield user
if len(seen) >= limit:
return
requests[i].offset += len(participants.participants)
users = {user.id: user for user in participants.users}
for participant in participants.participants:
user = users[participant.user_id]
if not filter_entity(user) or user.id in seen:
continue
seen.add(participant.user_id)
user = users[participant.user_id]
user.participant = participant
yield user
if len(seen) >= limit:
return
elif isinstance(entity, InputPeerChat):
users = (await self(GetFullChatRequest(entity.chat_id))).users
# TODO We *could* apply the `filter` here ourselves
full = await self(GetFullChatRequest(entity.chat_id))
if _total_box:
_total_box.x = len(users)
_total_box.x = len(full.full_chat.participants.participants)
have = 0
for user in users:
users = {user.id: user for user in full.users}
for participant in full.full_chat.participants.participants:
user = users[participant.user_id]
if not filter_entity(user):
continue
have += 1
if have > limit:
break
else:
user = users[participant.user_id]
user.participant = participant
yield user
else:
if _total_box:
_total_box.x = 1
if limit != 0:
yield await self.get_entity(entity)
user = await self.get_entity(entity)
if filter_entity(user):
user.participant = None
yield user
async def get_participants(self, *args, **kwargs):
"""
@ -1308,6 +1355,10 @@ class TelegramClient(TelegramBareClient):
photo or similar) so that it can be resent without the need
to download and re-upload it again.
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
in chunks of 10 if more than 10 are given.
caption (:obj:`str`, optional):
Optional caption for the sent media message.
@ -1353,23 +1404,33 @@ class TelegramClient(TelegramBareClient):
# First check if the user passed an iterable, in which case
# we may want to send as an album if all are photo files.
if utils.is_list_like(file):
# Convert to tuple so we can iterate several times
file = tuple(x for x in file)
if all(utils.is_image(x) for x in file):
return await self._send_album(
entity, file, caption=caption,
# TODO Fix progress_callback
images = []
documents = []
for x in file:
if utils.is_image(x):
images.append(x)
else:
documents.append(x)
result = []
while images:
result += await self._send_album(
entity, images[:10], caption=caption,
progress_callback=progress_callback, reply_to=reply_to,
parse_mode=parse_mode
)
# Not all are images, so send all the files one by one
return [
images = images[10:]
result.extend(
await self.send_file(
entity, x, allow_cache=False,
caption=caption, force_document=force_document,
progress_callback=progress_callback, reply_to=reply_to,
attributes=attributes, thumb=thumb, **kwargs
) for x in file
]
) for x in documents
)
return result
entity = await self.get_input_entity(entity)
reply_to = self._get_message_id(reply_to)
@ -1509,6 +1570,10 @@ class TelegramClient(TelegramBareClient):
# we need to produce right now to send albums (uploadMedia), and
# cache only makes a difference for documents where the user may
# want the attributes used on them to change.
#
# In theory documents can be sent inside the albums but they appear
# as different messages (not inside the album), and the logic to set
# the attributes/avoid cache is already written in .send_file().
entity = await self.get_input_entity(entity)
if not utils.is_list_like(caption):
caption = (caption,)
@ -2001,7 +2066,7 @@ class TelegramClient(TelegramBareClient):
input_location (:obj:`InputFileLocation`):
The file location from which the file will be downloaded.
file (:obj:`str` | :obj:`file`, optional):
file (:obj:`str` | :obj:`file`):
The output file path, directory, or stream-like object.
If the path exists and is a file, it will be overwritten.
@ -2168,22 +2233,46 @@ class TelegramClient(TelegramBareClient):
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"""
def remove_event_handler(self, callback, event=None):
"""
Inverse operation of :meth:`add_event_handler`.
If no event is given, all events for this callback are removed.
Returns how many callbacks were removed.
"""
found = 0
if event and not isinstance(event, type):
event = type(event)
for i, ec in enumerate(self._event_builders):
ev, cb = ec
if cb == callback and (not event or isinstance(ev, event)):
del self._event_builders[i]
found += 1
return found
def list_event_handlers(self):
"""
Lists all added event handlers, returning a list of pairs
consisting of (callback, event).
"""
return [(callback, event) for event, callback in self._event_builders]
async def add_update_handler(self, handler):
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)
return await self.add_event_handler(handler, events.Raw)
def remove_update_handler(self, handler):
pass
return self.remove_event_handler(handler)
def list_update_handlers(self):
return []
return [callback for callback, _ in self.list_event_handlers()]
# endregion
@ -2293,7 +2382,8 @@ class TelegramClient(TelegramBareClient):
return await self.get_me()
result = await self(ResolveUsernameRequest(username))
for entity in itertools.chain(result.users, result.chats):
if entity.username.lower() == username:
if getattr(entity, 'username', None) or ''\
.lower() == username:
return entity
try:
# Nobody with this username, maybe it's an exact name/title
@ -2350,16 +2440,18 @@ class TelegramClient(TelegramBareClient):
# Add the mark to the peers if the user passed a Peer (not an int),
# or said ID is negative. If it's negative it's been marked already.
# Look in the dialogs with the hope to find it.
mark = not isinstance(peer, int) or peer < 0
target_id = utils.get_peer_id(peer)
if mark:
async for dialog in self.iter_dialogs():
if utils.get_peer_id(dialog.entity) == target_id:
return utils.get_input_peer(dialog.entity)
else:
async for dialog in self.iter_dialogs():
if dialog.entity.id == target_id:
return utils.get_input_peer(dialog.entity)
if not self._called_get_dialogs:
self._called_get_dialogs = True
mark = not isinstance(peer, int) or peer < 0
target_id = utils.get_peer_id(peer)
if mark:
async for dialog in self.get_dialogs(100):
if utils.get_peer_id(dialog.entity) == target_id:
return utils.get_input_peer(dialog.entity)
else:
async for dialog in self.get_dialogs(100):
if dialog.entity.id == target_id:
return utils.get_input_peer(dialog.entity)
raise TypeError(
'Could not find the input entity corresponding to "{}". '

View File

@ -4,10 +4,11 @@ to convert between an entity like an User, Chat, etc. into its Input version)
"""
import math
import mimetypes
import os
import re
import types
from collections import UserList
from mimetypes import add_type, guess_extension
from mimetypes import guess_extension
from .tl.types import (
Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull,
@ -315,11 +316,13 @@ def get_input_media(media, is_photo=False):
def is_image(file):
"""Returns True if the file extension looks like an image file"""
"""
Returns True if the file extension looks like an image file to Telegram.
"""
if not isinstance(file, str):
return False
mime = mimetypes.guess_type(file)[0] or ''
return mime.startswith('image/') and not mime.endswith('/webp')
_, ext = os.path.splitext(file)
return re.match(r'\.(png|jpe?g)', ext, re.IGNORECASE)
def is_audio(file):

View File

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

View File

@ -254,7 +254,7 @@ class TLArg:
self.generic_definition = generic_definition
def type_hint(self):
def doc_type_hint(self):
result = {
'int': 'int',
'long': 'int',
@ -272,6 +272,27 @@ class TLArg:
return result
def python_type_hint(self):
type = self.type
if '.' in type:
type = type.split('.')[1]
result = {
'int': 'int',
'long': 'int',
'int128': 'int',
'int256': 'int',
'string': 'str',
'date': 'Optional[datetime]', # None date = 0 timestamp
'bytes': 'bytes',
'true': 'bool',
}.get(type, "Type{}".format(type))
if self.is_vector:
result = 'List[{}]'.format(result)
if self.is_flag and type != 'date':
result = 'Optional[{}]'.format(result)
return result
def __str__(self):
# Find the real type representation by updating it as required
real_type = self.type

View File

@ -138,6 +138,7 @@ class TLGenerator:
builder.writeln(
'from {}.tl.tlobject import TLObject'.format('.' * depth)
)
builder.writeln('from typing import Optional, List, Union, TYPE_CHECKING')
# Add the relative imports to the namespaces,
# unless we already are in a namespace.
@ -154,13 +155,81 @@ class TLGenerator:
# Import struct for the .__bytes__(self) serialization
builder.writeln('import struct')
tlobjects.sort(key=lambda x: x.name)
type_names = set()
type_defs = []
# Find all the types in this file and generate type definitions
# based on the types. The type definitions are written to the
# file at the end.
for t in tlobjects:
if not t.is_function:
type_name = t.result
if '.' in type_name:
type_name = type_name[type_name.rindex('.'):]
if type_name in type_names:
continue
type_names.add(type_name)
constructors = type_constructors[type_name]
if not constructors:
pass
elif len(constructors) == 1:
type_defs.append('Type{} = {}'.format(
type_name, constructors[0].class_name()))
else:
type_defs.append('Type{} = Union[{}]'.format(
type_name, ','.join(c.class_name()
for c in constructors)))
imports = {}
primitives = ('int', 'long', 'int128', 'int256', 'string',
'date', 'bytes', 'true')
# Find all the types in other files that are used in this file
# and generate the information required to import those types.
for t in tlobjects:
for arg in t.args:
name = arg.type
if not name or name in primitives:
continue
import_space = '{}.tl.types'.format('.' * depth)
if '.' in name:
namespace = name.split('.')[0]
name = name.split('.')[1]
import_space += '.{}'.format(namespace)
if name not in type_names:
type_names.add(name)
if name == 'date':
imports['datetime'] = ['datetime']
continue
elif not import_space in imports:
imports[import_space] = set()
imports[import_space].add('Type{}'.format(name))
# Add imports required for type checking.
builder.writeln('if TYPE_CHECKING:')
for namespace, names in imports.items():
builder.writeln('from {} import {}'.format(
namespace, ', '.join(names)))
else:
builder.writeln('pass')
builder.end_block()
# Generate the class for every TLObject
for t in sorted(tlobjects, key=lambda x: x.name):
for t in tlobjects:
TLGenerator._write_source_code(
t, builder, depth, type_constructors
)
builder.current_indent = 0
# Write the type definitions generated earlier.
builder.writeln('')
for line in type_defs:
builder.writeln(line)
@staticmethod
def _write_source_code(tlobject, builder, depth, type_constructors):
"""Writes the source code corresponding to the given TLObject
@ -218,7 +287,7 @@ class TLGenerator:
for arg in args:
if not arg.flag_indicator:
builder.writeln(':param {} {}:'.format(
arg.type_hint(), arg.name
arg.doc_type_hint(), arg.name
))
builder.current_indent -= 1 # It will auto-indent (':')
@ -258,7 +327,8 @@ class TLGenerator:
for arg in args:
if not arg.can_be_inferred:
builder.writeln('self.{0} = {0}'.format(arg.name))
builder.writeln('self.{0} = {0} # type: {1}'.format(
arg.name, arg.python_type_hint()))
continue
# Currently the only argument that can be