Compare commits

..

394 Commits

Author SHA1 Message Date
iLuisTheDev
859a83a1a5
Add BotCommandsTooMuchError class (#4700) 2025-09-13 17:50:23 +02:00
Nick80835
2b56fa91bb Bump to v1.41.2 2025-09-04 21:07:44 +02:00
Nick80835
2d5fad6565 Ensure document spoiler is preserved in utils.get_input_media 2025-09-04 21:07:44 +02:00
Lonami Exo
ba201b9189 Bump to v1.41.1 2025-09-04 19:52:24 +02:00
Lonami Exo
bc5c5d54b1 Fix incorrect async session warning 2025-09-04 19:52:00 +02:00
Darskiy
ee778ba763 Revert "Fix spoilers when sending InputPhoto and InputDocument"
This reverts commit 45a546a675.
2025-09-04 19:45:43 +02:00
Lonami Exo
5d07348faf Bump to v1.41 2025-09-01 17:33:19 +02:00
WildBeeJS
293f8d5bd9
Update to layer 214 (#4695) 2025-09-01 17:08:24 +02:00
Darskiy
a28e757dc3
Update to layer 211 (again) (#4683) 2025-08-15 14:04:22 +02:00
Lonami Exo
e77307d0ed Update to layer 211 2025-08-01 23:41:45 +02:00
Humberto Gontijo
d80898ecc5
Add experimental support for async sessions (#4667)
There no plans for this to ever be non-experimental in v1.
2025-07-28 17:03:31 +02:00
Deer-Spangle
45a546a675 Fix spoilers when sending InputPhoto and InputDocument
After this was reported as a problem for MessageMediaPhoto objects in #4584, it was fixed in commit 37e29e8, but the problem exists for InputPhoto and InputDocument, also. This commit fixes that
2025-07-25 21:04:31 +02:00
WildBeeJS
e168602511
Update to layer 210 2025-07-25 16:45:16 +02:00
Humberto Gontijo
01af2fcca3
Reduce data manipulation operations 2025-07-24 21:46:28 +02:00
humbertogontijo
b59c005903 Add isal gzip decompressor 2025-07-24 08:17:43 +02:00
wkpn
5e150ddf1e Update to layer 209 2025-07-15 17:20:11 +02:00
Darskiy
7d0fadea29 Update to layer 207 2025-07-08 19:51:55 +02:00
Egor
aa17aa65ec
Update to layer 206 (#4654) 2025-07-02 20:38:24 +02:00
UZQueen
31e8ceeecc
Handle cases when Message.reply_to is MessageReplyStoryHeader 2025-06-07 12:27:29 +02:00
Devesh Pal
7b00d2f510 Update to layer 204 2025-06-04 17:08:25 +02:00
zeticsce
17a014906e
Support tg-emoji tag when using html parse_mode 2025-06-03 22:47:39 +02:00
Darskiy
f61518274e
Cleanup usage of removed inline_only parameter (#4630) 2025-05-30 23:18:58 +02:00
Darskiy
8bb2ec30fe
Add persistent and placeholder options to buttons (#4629) 2025-05-28 18:21:50 +02:00
Aayco (Coder)
69e4493c04
Add custom error types for new FROZEN codes 2025-05-26 21:04:26 +02:00
yzx23333
1db71f6d7d Add customizable 'mime_type' for send_file 2025-05-25 17:32:41 +02:00
Darskiy
663a1808a1
Update FileLike type hint to better reflect valid types (#4620) 2025-05-20 22:13:43 +02:00
Darskiy
59da66e105 Update to layer 203 2025-05-13 23:21:34 +02:00
wkpn
6625327b4f Add ChannelForbidden check to utils.get_display_name 2025-05-13 23:05:07 +02:00
Maxsim Smirnov
20434e5a9d Update to layer 202 2025-05-05 20:03:56 +02:00
AEIMS
6a7331b7dc
Add open_url param to control browser opening on click (#4607) 2025-05-04 22:01:55 +02:00
Lonami Exo
77b7edcd6e Also process updates entities with session 2025-04-24 17:36:21 +02:00
Lonami Exo
04922fee3c Stop re-saving input peers on disconnect
This was overwriting actual information on the cache.
2025-04-23 19:21:36 +02:00
Lonami Exo
b2809e0b57 Apply seq if all updates were applied
Instead of only checking if *any* update *with pts* was applied.
Should fix #4602.
2025-04-23 18:25:22 +02:00
Lindsay Zhou
ae9c798e2c Add send_as and message_effect_id for client send_file test
new parameters introduced by 859f7423f2
2025-04-22 17:46:50 +02:00
Lonami Exo
3708fd9605 Update to v1.40 2025-04-21 11:05:32 +02:00
Lonami Exo
5f0695d21b Fix time offset check was in opposite branch 2025-04-18 18:40:53 +02:00
Lonami Exo
9545011de6 Update time offset if first message is badMsgNotification
This should prevent the library from getting stuck during connect.
2025-04-17 17:08:08 +02:00
Lonami Exo
11658d3bbf Combine tdlib and tdesktop schemas
It is safer to keep additions than unnecessarily removing.
Additional flags should also be safe to keep, but they may
not be usable in practice.
2025-04-17 16:57:30 +02:00
Lewis
a73e5a8c71
Fix layer 201 (#4596) 2025-04-12 22:13:52 +02:00
orShadxw
3921914a96
Fix issues raising errors, media dl and reply markup (#4592) 2025-04-01 18:00:09 +02:00
Ankit Chaubey
c409d8c605 Update to Layer 201 2025-03-26 18:06:16 +01:00
Lonami Exo
19a27d602c Delete dated documentation section
While it might have persuaded some people to avoid ending here,
there's also the possibility this had no effect, or even made
the situation worse. For that, I apologize for any hurt feelings.
2025-03-22 10:42:08 +01:00
Lonami Exo
37e29e8f13 Add missing param on photo-to-input conversion
Closes #4584.
2025-03-20 16:48:17 +01:00
abcdenis
890bf485c9 skip ChannelParticipantLeft in _ParticipantsIter 2025-03-14 17:27:45 +01:00
Lonami Exo
67765f84a5 Update to layer 200 2025-03-09 13:05:29 +01:00
Lonami Exo
4bfe7849f6 Add missing input conversion for MessageMediaGeoLive
Fixes #4576.
2025-03-09 13:00:15 +01:00
Shrimadhav U K
859f7423f2 Add send_as and effect parameters in high level 2025-03-08 17:21:49 +01:00
Nick80835
6f5556373f Call getDifference on login to ensure qts is up to date 2025-03-07 18:03:01 +01:00
Nick80835
0fc9b14674 Make URL regex match everything except ]
Hopefully this is the last time I touch this.
2025-03-07 08:03:31 +01:00
Nick80835
0c2a3c144b Make URL regex match anything again
Including spaces.
2025-03-06 08:03:44 +01:00
martín
a03a8673e1
Clarify warning when reusing unintended session (#4568) 2025-03-02 19:40:34 +01:00
Lonami Exo
045df418df Update to v1.39 2025-02-20 18:26:00 +01:00
Nick80835
592a899aab Update to layer 199 2025-02-13 17:20:59 +01:00
Nick80835
1cb5ff1dd5 Consider range list-like
This allows you to pass range() to things such as get_messages as ids= without first explicitly converting it to a list.
2025-02-09 08:42:44 +01:00
s1xu
9762a83541 fix: support batch sending of image URLs and video URLs in albums 2025-02-08 15:40:56 +01:00
Nick80835
141b620437 Make markdown URL regex less greedy
Fixes multiple URLs in a single message.
2025-02-05 16:56:04 +01:00
Nick80835
551c24f3e4 Fix overlapping URLs and improve overlapping in md
Also remove the unused overlap function.
2025-02-02 04:11:46 +01:00
Nick80835
38d024312e Unconditionally match text and link text in markdown
Fixes cases where there's a nested [] in the text by matching until "](" is reached. This doesn't match newlines in URLs because that makes no sense.
2025-02-01 21:02:27 +01:00
Nick80835
a2926b548f Update to layer 198 2025-01-28 18:23:18 +01:00
Nick80835
455acc43f6 Improve edit_message message type hint
This also allows utils.get_message_id to get the ID of InputMessageID.
2025-01-19 10:40:11 +01:00
Nick80835
792adb78b3 Respect receive_updates=False 2025-01-18 19:03:57 +01:00
Nick80835
5a0e69693b Document drop_author and add drop_media_captions
drop_author is already supported but is undocumented. drop_media_captions for consistency with drop_author being implemented.
2025-01-17 17:02:42 +01:00
Nick80835
b9aafa3441 Fix IOError with some image modes in photo resize
This fixes image compression with mode "P" (potentially others) which is necessary as the server has erroneous alpha color with some types of images (mode "P" for example). This also properly applies the background argument that may be passed to _resize_photo_if_needed by always compressing images with alpha regardless of whether the server will compress the image for us.
2025-01-13 20:37:36 +01:00
Nick80835
494b20db2d
Add missing parameters to Message constructor (#4529) 2025-01-04 19:29:19 +01:00
Danish
0a6b649ead
Updated to Layer 196 (#4525) 2025-01-04 15:15:50 +01:00
Lonami
cfce68e9ad
Avoid error when trying to reset deadline for no msgbox entries
Closes #4520.
2024-12-22 15:07:35 +01:00
Lonami Exo
b09c8c83f7 Update to layer 195 2024-12-15 12:31:27 +01:00
Danipulok
225ea9c3ab fix(session): persist session after session.set_dc 2024-11-20 17:25:56 +01:00
Lonami Exo
9ca3b599fc Fix Python 3.6 compatibility
Introduced on accident by #4475.
2024-11-18 17:04:07 +01:00
Lonami Exo
63d55bbe3d Bump to v1.38 2024-11-17 22:32:16 +01:00
Lonami Exo
c9cce8aa81 Update to layer 193 2024-11-17 22:23:37 +01:00
Colin Watson
70098c58a5 Fix test broken by removal of imghdr
This has been broken since bd11564579.
2024-11-06 16:23:33 +01:00
Colin Watson
769b65efb1 Exclude sign_up from test_all_methods_present
The sign_up method was deprecated in
07a7a8b404 and removed from the
documentation, but since the method technically still exists (even
though it immediately raises ValueError), that broke a test.
2024-11-06 16:23:33 +01:00
Devesh Pal
f03e4b1137
Update to Layer 192 (#4503) 2024-10-31 22:10:02 +01:00
Greg Eremeev
a77835a7d9
Fix reuse of captions during send msg with file (#4500) 2024-10-25 23:27:58 +02:00
Mohsen
85c4a91317
Fix typos in FAQ section (#4498) 2024-10-24 18:56:15 +02:00
Arkadii Halchenko
3f589b287d
Fix parse_mode with albums (#4494)
Closes #4492.
2024-10-20 19:50:08 +02:00
Lonami Exo
8138be2503 Update to layer 190 2024-10-15 23:20:57 +02:00
Devesh Pal
a0e42c1eb7
Update to layer 189 (#4476) 2024-10-05 21:43:51 +02:00
Arkadii Halchenko
4553f04e49
Fix formatting_entities not working for albums (#4475) 2024-10-05 11:13:20 +02:00
Lonami Exo
f652f3f01a Bump to v1.37 2024-09-23 19:01:20 +02:00
Lonami Exo
693c73ec1d Update to layer 188 2024-09-23 19:01:10 +02:00
Mohsen
a9442ef1be
Remove right-adjust from logging example docs (#4461)
Only works with %-style formatting, but not in the logging module.
2024-09-11 20:54:59 +02:00
Devesh Pal
d37b0f812f
Update to layer 187 (#4457) 2024-09-07 10:06:07 +02:00
kristal
b01d3d7a2f
Fix edge case on get_messages when reverse=True (#4455)
Closes #4453.
2024-09-05 17:00:22 +02:00
delobanov
aec957d62d
Add error message back to proxy errors (#4443) 2024-08-29 23:54:34 +02:00
delobanov
46854a7660
Fix ConnectionError() takes no keyword arguments with proxies (#4440) 2024-08-28 22:41:38 +02:00
Lonami Exo
90f1e5b073 Update to layer 186 2024-08-25 21:14:51 +02:00
灰白草
75408483ad
Support CDN downloads (#4420)
Closes #4327.
2024-08-07 20:25:35 +02:00
Lonami Exo
946f803de7 Handle FloodPremiumWaitError
Closes #4417.
2024-07-24 16:38:34 +02:00
Shrimadhav U K
087191e9c5
Update API Scheme to Layer 184 (#4413) 2024-07-19 17:43:02 +02:00
Lonami Exo
a5c98aec50 Update to layer 183 2024-07-06 21:03:29 +02:00
2ei
cfebb9df05
Remove unused imports (#4397) 2024-06-21 00:06:40 +02:00
Lonami Exo
04aea46fe4 Update to v1.36 2024-06-11 16:42:47 +02:00
Lonami Exo
3def9433b8 qts_count is always assumed to be 1
Per the docs https://core.telegram.org/api/updates:
> events are never grouped,
> so it's assumed that qts_count is always equal to 1.
2024-06-04 23:12:02 +02:00
Lonami Exo
b3e210a1fb Update to layer 181 2024-06-01 11:00:14 +02:00
Lonami Exo
47673680f4 Update to layer 179 2024-05-01 17:43:24 +02:00
Lonami Exo
1974b663a2 Add missing type to _store_own_updates 2024-04-28 10:16:56 +02:00
Lonami Exo
881bfaac5c Update to layer 179 2024-04-27 16:56:38 +02:00
Lonami Exo
0f6dd5987e Add readthedocs dependency on sphinx-rtd-theme 2024-04-24 19:03:26 +02:00
Lonami Exo
d77ac18695 Bump to v1.35 2024-04-24 18:51:29 +02:00
Lonami Exo
8137b12bec Fix readthedocs requirements 2024-04-24 18:46:57 +02:00
Lonami Exo
10a6d16af6 Update to layer 178 2024-04-24 18:45:09 +02:00
Darskiy
3ac11e15ec
Fix get_messages type hint (#4357) 2024-04-23 16:54:41 +02:00
Darskiy
3625bf849d
Fix get_entity type hint (#4352) 2024-04-18 20:52:29 +02:00
Shubham Kumar
d3a201a277
Fix regression on supported Python version (#4347) 2024-04-12 21:06:15 +02:00
CoderX
49a8f111d3
Add missing attributes to Message (#4346) 2024-04-08 21:24:43 +02:00
Lonami Exo
723fbd570f Update to layer 177 2024-04-05 18:28:31 +02:00
Lonami Exo
26aa178cf6 Handle FileReferenceExpiredError during download
May fix #4341.
2024-04-02 11:02:32 +02:00
Lindsay Zhou
9f3e7e4aa8
Fix TelegramClient init with None session (#4339) 2024-03-30 15:10:12 +01:00
Jordan Gillard
75d609ab2a
Fix type hint in start (#4332) 2024-03-26 20:30:01 +01:00
John Bezustally
4d34243b98
Add drop_author param to forward_messages (#4329) 2024-03-18 08:30:38 +01:00
Lonami Exo
7ceb2e0b25 Add missing quick_reply_shortcut_id 2024-03-10 10:53:32 +01:00
Lonami Exo
47178dfaef Update to layer 176 2024-03-08 23:15:54 +01:00
Lonami Exo
d90d0dc00f message parameter must be optional 2024-02-23 20:53:54 +01:00
Lonami Exo
d1518f002a Fix custom Message lacking parameter 2024-02-21 17:01:03 +01:00
Lonami Exo
319db57ccb Update to layer 174 2024-02-16 23:09:44 +01:00
Confused Character
22bf0b4310
Add custom secret support to TcpMTProxy (#4309) 2024-02-16 22:45:38 +01:00
Lonami Exo
2b99ff65c5 Support pathlib.Path as session again
See #3737.
2024-02-13 18:18:52 +01:00
Lonami Exo
39fc5c5fef Update changelog 2024-02-02 18:23:52 +01:00
Lonami Exo
65c27c5ced Bump to v1.34 2024-02-02 18:17:21 +01:00
Lonami Exo
d76f3b7556 Update to layer 173 2024-02-02 18:16:56 +01:00
Just-a-xD
41eb665c9d
Fix custom parse_mode instances (#4304) 2024-02-02 18:16:01 +01:00
Lonami Exo
70201a9ff1 Fix Message finish init for reply_to stories 2024-01-31 21:57:41 +01:00
Prashant Sengar
63d9b267f4
Fix unparsing of message.text (#4301)
Co-authored-by: Disk6969 <disk6969@users.noreply.github.com>
2024-01-20 10:42:13 +01:00
Lonami Exo
6187ff7dcb Update to layer 172 2024-01-18 18:48:36 +01:00
exovoq
6ee2fffce8
Add reply_to_chat and reply_to_sender in Message (#4300) 2024-01-18 18:48:22 +01:00
Lonami Exo
32a4cb82ce Update to layer 171 2024-01-17 17:09:12 +01:00
Lonami Exo
a97a7a5400 Add new config file for readthedocs 2024-01-14 11:49:49 +01:00
Lonami Exo
9dbe9a7669 Add missing saved_peer_id parameter to Message 2024-01-04 13:01:28 +01:00
Lonami Exo
c445684be8 Update to layer 170 2024-01-01 22:07:20 +01:00
Lonami Exo
1241671e72 Update to layer 169 2023-12-26 10:55:15 +01:00
Jahongir Qurbonov
b882348a2b
Fix restriction_reason type hint (#4282) 2023-12-25 10:13:03 +01:00
Allen Calderwood
2082a0e4de
Fix typo in documentation example (#4277) 2023-12-18 17:32:17 +01:00
Lonami Exo
6cf1be93ae Bump to v1.33.1 2023-12-08 17:31:43 +01:00
Lonami
3d58dc355e
Fix ordering of closing tags of sequential entities (#4268) 2023-12-08 08:12:02 +01:00
udf
3b428f97a9
Fix ordering of nested entities 2023-12-07 18:25:11 +02:00
udf
abeb8c4d8d
Prioritise closing tags when sorting tags 2023-12-07 18:09:02 +02:00
Lonami Exo
985d12e169 Bump to v1.33 2023-12-01 20:30:19 +01:00
Lonami Exo
1ef66896bd Update to layer 167 2023-12-01 20:16:56 +01:00
Balázs Triszka
584735afe1
Conditional webbrowser import (#4261) 2023-11-28 00:04:36 +01:00
mario-ttide
cf3bc71e1d
Retry on TimedOutError (#4255) 2023-11-19 18:14:34 +01:00
Lonami Exo
ddc9bef503 Force filename with JPG extension after resizing
Old name does not matter, since we just encoded it as JPEG
2023-11-12 21:13:24 +01:00
Lonami
308f8e8bf8
Add PR template mentioning v1 is feature-frozen
Should prevent efforts like #4244 going to waste in the future.
2023-11-09 17:04:05 +01:00
Lonami Exo
6ccd6b0a41 Bump to v1.32 2023-10-31 19:12:41 +01:00
Lonami Exo
b17e10af1d Fix init of custom Draft after layer update 2023-10-29 11:41:54 +01:00
Lonami Exo
046dbb58b8 Update to layer 166 2023-10-29 11:00:13 +01:00
Lonami Exo
fda6840449 Fix file name could be lost when uploading files
Leading to invalid extension when sending photos.
2023-10-17 20:31:58 +02:00
Lonami Exo
eb67ef1b15 Update to v1.31 2023-10-12 18:39:29 +02:00
Lonami Exo
7d7dbdf47f Update to layer 165 2023-09-29 22:12:41 +02:00
Lonami Exo
6a36066d19 Update to layer 164 2023-09-22 20:49:39 +02:00
Lonami Exo
bd11564579 Remove uses of imghdr
It's deprecated. Closes #4207.
2023-09-20 18:30:57 +02:00
Alexander Goryushkin
ad19987cd6
Fixed sorting of markup entities with the same offsets (#4201) 2023-09-14 18:52:04 +02:00
Lonami Exo
7325718f0e Fix date empty when getting difference 2023-09-13 17:35:15 +02:00
Lonami Exo
7ce0b2f940 Fix invalid date type in UpdateShort 2023-09-12 17:16:55 +02:00
Dingyuan Wang
5ba312555a
Fix generator for pypy (#4198) 2023-09-12 08:28:14 +02:00
Lonami Exo
2cef715921 Bump to v1.30 2023-09-10 12:28:10 +02:00
Lonami Exo
ba99b8b466 Update to layer 162 2023-09-10 12:15:14 +02:00
Non
72faa89361
Remove client-side check in message.edit (#4195)
Fixes #4193.
2023-09-08 18:35:25 +02:00
Kacnep89
e928fbdac0
Fix date empty (#4191) 2023-09-06 16:47:46 +02:00
Shubham Kumar
9b1d9aa672
Fix incorrect param type in apply_channel_difference (#4185) 2023-08-29 18:40:07 +02:00
Lonami Exo
72f16ef73e Update to layer 161
Closes #4184.
2023-08-29 15:53:25 +02:00
Lonami Exo
33f3e27e7d Change apply_deadlines_reset micro-optimization
No need for buffer reuse in Python. It simply complicates the code.
And even then it was not as optimal as it could.
2023-08-29 15:04:04 +02:00
Lonami Exo
ac483e6812 Only update seq if pts changed
This solves UpdateChatParticipant being missed after UpdateChat,
which seems to reliable occur when a bot is in a Chat that gets
deleted.
2023-08-29 15:04:04 +02:00
Lonami Exo
d40aae75f3 Further improve MessageBox trace logging 2023-08-29 15:04:04 +02:00
Lonami Exo
574e8876ec Fix getting_diff_for with empty set was being spammed
Because the above check used >= but the inner check >.
2023-08-29 15:04:04 +02:00
Lonami Exo
2011a329b0 Make MessageBox trace logs more useful 2023-08-29 15:04:04 +02:00
misuzu
0cc9ca9bd9
Fix is_inline check for KeyboardButtonWebView (#4183) 2023-08-28 17:40:23 +02:00
Kacnep89
e617b59d48
Return marked ID from MemorySession.get_entity_rows_by_id (#4177)
Otherwise the unpacking done later won't work.
2023-08-23 16:07:32 +02:00
Lonami Exo
b0f9fd1f25 Except all types of timeout error
Closes #4172.
2023-08-18 18:36:30 +02:00
Lonami Exo
128b707488 Don't treat asyncio.IncompleteReadError as unhandled
The library will behave the same, but the log severity is lowered.
2023-08-03 19:01:10 +02:00
Bernhard M. Wiedemann
6ded164b85
Sort tlobjects before generating their listing (#4163) 2023-08-01 20:23:24 +02:00
Lonami Exo
211238fcd2 Fix reply_to when sending albums 2023-07-24 17:25:03 +02:00
Nick80835
694c78c8e9
Improve image compression heuristics and algorithm used (#4158) 2023-07-23 21:58:10 +02:00
Lonami Exo
ce010e9bfb Fix handling of UpdateShortSentMessage 2023-07-23 17:12:16 +02:00
Lonami Exo
413a2bb9f3 Bump to v1.29.0 2023-07-23 10:48:04 +02:00
novenary
9cf4cd70d1
Disable blank issues in GitHub (#4157) 2023-07-23 10:32:57 +02:00
Lonami Exo
131f021d51 Don't attempt thumb download if there is no thumb 2023-07-22 10:52:03 +02:00
Lonami Exo
438aff3545 Handle FloodWaitError in update loop
Likely temporary server issues, since getDifference should
realistically not fail with flood waits. In any case,
stopping early until the problem is resolved is the correct
approach.
2023-07-21 23:01:12 +02:00
Lonami Exo
4eef9b52c9 Handle sqlite3.OperationalError in update loop 2023-07-21 22:56:16 +02:00
Lonami Exo
a0cda0c37c Remove client-side checks when editing permissions
The server should instead fail with proper RPC errors,
as the rules could change any time (and the local checks
get out of date).
2023-07-21 22:53:38 +02:00
Lonami Exo
816b0bdf9f Fix _get_thumb failed when document had no thumbs 2023-07-21 22:48:12 +02:00
Lonami Exo
164d35681e Fix reply_to can be optional 2023-07-21 22:44:35 +02:00
Lonami Exo
75ed58ad89 Update to layer 160 2023-07-21 21:24:10 +02:00
Lonami
16ed9614f9 Change html.unparse logic to mimic markdown's
It was overcomplicated and had some subtle bugs.
Closes #4133.
2023-06-17 13:11:14 +02:00
Lonami Exo
9267917031 Improve error message when trying to delete inline messages
Closes #4129.
2023-06-09 17:48:08 +02:00
rozha
1e63de9b68
Fix lack of support for anon channel restrictions (#4130) 2023-06-09 17:42:34 +02:00
Lonami Exo
2826c942c0 Support most usernames in VALID_USERNAME_RE
See #4128.
2023-06-09 17:41:45 +02:00
Devesh Pal
65407fc899
Document more RPC errors (#4127) 2023-06-06 22:51:27 +02:00
Lonami Exo
c3bddf9440 Add missing formatting arg in logging call
Noticed in #4123.
2023-06-02 23:04:14 +02:00
Lonami Exo
4ff7ac6b75 Handle CancelledError inside mtprotosender recv loop 2023-06-02 19:04:51 +02:00
novenary
c3ec775607 Clarify OS field in bug report template 2023-06-02 12:20:27 +03:00
iamilya
aab8009a5a
Fix comment_to for a group of messages (#4120) 2023-05-31 17:04:33 +02:00
Lonami
0f0ca6b0d9
Upgrade issue templates to issue forms (#4118) 2023-05-31 00:02:20 +02:00
Lonami
c89644eec4
Update some fields in the GH issue template 2023-05-31 00:01:16 +02:00
novenary
ed825a2c7d Add dedicated form for documentation issues 2023-05-29 21:58:28 +03:00
novenary
9751b356fe Change feature request template to an issue form 2023-05-29 21:53:41 +03:00
novenary
6acc39ac04 Change bug report template to an issue form
Lifted from Tachiyomi and adapted for Telethon.

See: https://github.com/tachiyomiorg/tachiyomi/blob/master/.github/ISSUE_TEMPLATE/report_issue.yml
2023-05-29 21:29:26 +03:00
Lonami Exo
9fe5937ae1 Update FAQ with more common questions 2023-05-24 19:23:12 +02:00
Lonami Exo
16122545ec Add check for asyncio event loop to remain the same 2023-05-24 19:15:46 +02:00
Lonami Exo
6a7a981b7a Fix asyncio.CancelledError was being swallowed by inner except
Closes #4104.
2023-05-08 22:34:12 +02:00
Lonami Exo
980f8b32fc Fix KeyError when ID is in cache but queried without mark
Closes #4084.
2023-05-05 00:04:30 +02:00
Lonami Exo
c4a41adae5 Better document breaking ToS will lead to bans
Closes #4102.
2023-05-04 19:05:07 +02:00
Lonami Exo
2889bd5bf3 except and propagate TypeNotFoundError during update handling 2023-05-03 17:56:13 +02:00
R.T
9c7ac3b210
Fix absolute import should be relative (#4101) 2023-04-30 18:27:09 +02:00
Lonami Exo
ce29f13606 Fix UserUpdate.last_seen check 2023-04-30 10:32:08 +02:00
Lonami Exo
d7bd554ba0 Fix ValueError during connect with catchup on bad cache
Closes #4080.
2023-04-29 13:10:00 +02:00
Lonami Exo
ccf67d0f4f Exit receive loop on IOError or unhandled exceptions
May help with #4088.
2023-04-29 12:53:25 +02:00
Lonami Exo
03ff996ace Improve unhelpful 'readexactly size can not be less than zero'
Technically closes #4092, as the error is now properly handled.
2023-04-29 12:33:32 +02:00
Lonami Exo
9aad453e1a Update to layer 158 2023-04-21 17:11:59 +02:00
Deer-Spangle
6e7423e894
Allowing nosound_video to be specified (#4090) 2023-04-14 22:03:03 +02:00
Nick80835
7b1b33f805
Save photos as progressive when uploading (#4089) 2023-04-13 20:11:52 +02:00
David
d419979406
Declare missing exception variable (#4087) 2023-04-12 17:36:43 +02:00
SsNiPeR1
acec8a776f
Fix documentation typo (#4086) 2023-04-11 17:43:44 +02:00
Nitan Alexandru Marcel
ced36adb03
Fix editing inline messages of type InputBotInlineMessageID64 (#4082) 2023-04-07 17:04:08 +02:00
Lonami Exo
5b1135734b Properly handle PhoneCodeExpiredError in sign_in
Should actually fix #3185 now.
2023-04-06 18:53:12 +02:00
Lonami Exo
10c74f8bab Bump to v1.28.0 2023-04-06 15:05:44 +02:00
Lonami Exo
af18538722 Handle PhoneCodeExpiredError during sign_in
Closes #3185.
2023-04-06 14:36:24 +02:00
Lonami Exo
fd09284598 Update FAQ
Closes #3759.
2023-04-06 14:32:45 +02:00
Lonami Exo
a657ae0134 Save self user ID in session file
Should result in one less request after connecting,
as there is no longer a need to fetch the self user.
2023-04-06 14:18:42 +02:00
Lonami Exo
88bc6a46a6 Store self user in entity cache 2023-04-06 13:58:26 +02:00
Lonami Exo
97b0ba6707 Flush in-memory cache to session after a limit is reached
Should fully close #3989.
Should help with #3235.
2023-04-06 13:45:12 +02:00
Lonami Exo
cb04e269c0 Fix _get_entity_pair could receive None as input 2023-04-06 13:39:56 +02:00
Lonami Exo
d1e3237c41 Remove now-unused EntityCache class 2023-04-06 13:37:40 +02:00
Lonami Exo
f7e38ee6f0 Remove redundant entity cache
Progress towards #3989.
May also help with #3235.
2023-04-06 13:25:48 +02:00
Lonami Exo
3e64ea35ff Update FAQ 2023-04-06 13:25:47 +02:00
Lonami Exo
f9001bc8e0 Include Telethon version on fatal errors during updates
Since a lot of people don't mention the version when reporting
issues, it makes it hard to determine whether it's already been
fixed or not.
2023-04-06 13:25:47 +02:00
Kacnep89
68ea208b43
Periodically save update state (#4071) 2023-03-28 19:00:36 +02:00
Lonami Exo
0f7756ac68 Remove dead code from send_file 2023-03-28 18:17:07 +02:00
Lonami Exo
33c5ee9be4 Implement progress_callback for sending albums
Closes #3190.
2023-03-28 18:15:57 +02:00
Kacnep89
a942b021bc
Fix conversion and time zone issues (#4072) 2023-03-28 17:38:46 +02:00
Lonami Exo
516a2e7435 Handle timeout error during getDifference
Closes #4043.
2023-03-12 17:46:25 +01:00
Lonami Exo
be59c36ed3 Handle errors at connection level
Closes #4042.
2023-03-12 17:43:36 +01:00
Lonami Exo
acd3407418 Propagate errors at the connection level 2023-03-12 17:43:19 +01:00
Lonami Exo
f3414d134a Handle invalid buffers at protocol level
See #4042.
2023-03-12 17:27:22 +01:00
Lonami Exo
177386e755 Update to layer 155 2023-03-12 17:15:28 +01:00
Lonami Exo
1f79f063a2 Expand documentation on using the raw API 2023-03-11 12:45:06 +01:00
Lonami Exo
b87a8d0c1f Remove mentions to methods in generated TL ref
Instead, consistently use the term request, to avoid confusion.
2023-03-11 12:26:17 +01:00
Lonami Exo
b68c1f4f03 Add docs warning to file.id about it not being maintained 2023-03-11 11:56:43 +01:00
Lonami Exo
6bc7245106 Add file IDs section to the FAQ 2023-03-11 11:56:39 +01:00
Lonami Exo
373601500f Update to layer 154 2023-03-09 21:34:48 +01:00
Lonami Exo
f334d5b8fe Move working with messages to the wiki 2023-02-28 21:58:07 +01:00
Lonami Exo
4de1609d4e Fix ChatAction for groups with hidden members 2023-02-26 11:17:12 +01:00
Lonami Exo
07a7a8b404 Deprecate force_sms and sign_up
On the 10th of February, Telegram sent the following message to
those with an application registered on https://my.telegram.org.

--

Telegram API Update. Hello [REDACTED]. Thank you for contributing to the
open Telegram ecosystem by developing your app, [REDACTED].

Please note that due to recent updates to Telegram's handling of SMS and
the integration of new SMS providers like Firebase, we are changing the
way login codes are handled in third-party apps based on the Telegram API.

Starting on 18.02.2023, users logging into third-party apps will only be
able to receive login codes via Telegram. It will no longer be possible
to request an SMS to log into your app - just like when logging into
Telegram's own desktop and web clients.

Exactly like with the Telegram Desktop and Web apps, if a user doesn't
have a Telegram account yet, they will need to create one first using
an official mobile Telegram app.

We kindly ask you to update your app's login and signup interfaces to
reflect these changes before they go live on 18.02.2023 at 13:00 UTC.

This change will not significantly affect users since, according to our
research, the vast majority of third-party app users also use official
Telegram apps. In the coming months, we expect to offer new tools for
third-party developers that will help streamline the login process.
2023-02-26 10:40:53 +01:00
Lonami Exo
0563430314 Slightly improve documentation 2023-02-26 10:10:19 +01:00
Lonami Exo
acfde7132b Support Message without client as inputs to forward_messages
Raw API does not monkey-patch the client on the returned messages,
so the get_input_chat call would return None, causing the forward
to fail. Instead, manually resolve the chat using the message's
peer_id.

The resolve call can also be avoided if from_peer is passed (which
may mismatch with where the messages actually belong, but that's not
really a concern).
2023-02-08 17:35:49 +01:00
Lonami Exo
610b8c34dd Prevent publishing to PyPi if tl.telethon.dev is out-of-date 2023-02-06 17:40:51 +01:00
Lonami Exo
daf21f12d9 Bump to v1.27 2023-02-05 15:19:41 +01:00
Lonami Exo
6dece6e8a1 Update to layer 152 2023-02-05 15:05:05 +01:00
Lonami Exo
9f077e356b Fix downloading vcard and webdoc with Path
Closes #4027.
2023-01-23 08:48:39 +01:00
Nick80835
1f8b59043b
Change maximum photo size to 2560px (#4024) 2023-01-18 17:37:56 +01:00
Lonami Exo
cc3d25eeb8 Wrap init request in invokeWithoutUpdates if requested
This may fix #3743.
2023-01-14 13:31:32 +01:00
Lonami Exo
d81eb0b2e8 Apply pts returned by some additional requests
When a bot account sends a message, deletes it, and sends a new one,
very reliably it would detect a gap, and as a result recover the
second message it sent, processing it itself (because the hack with
`_self_outgoing` cannot possibly work when catching up).

Now certain `rpc_result` are also processed as-if they were updates
(including the ones from deleting messages), which solves this gap
issue. Not entirely sure if it's a hack or the intended way to do it
(since Telegram *does* return proper `updates` for other RPCs), but
it seems to solve this particular problem.

Other requests such as reading history, mentions or reactions also
return an instance of this type, but the `pts_count` should be 0,
and at worst it should simply trigger a gap, which shouldn't be a
big deal.
2023-01-14 12:31:01 +01:00
Lonami Exo
83bafa25e3 Stop using asyncio.get_event_loop()
It is deprecated in newer Python versions.
Closes #4013.
2023-01-11 21:02:29 +01:00
Lonami Exo
fb97a8aa87 Propagate account being logged-out errors
Should close #4016.
2023-01-11 20:30:33 +01:00
Lonami Exo
c932d79ab3 Ignore improperly formatted errors from Telegram's JSON 2023-01-11 20:28:29 +01:00
Lonami Exo
94cc897019 Sync list of known errors with Telegram's JSON 2023-01-11 19:53:35 +01:00
Lonami Exo
7a74dedc48 Add script to sync errors with Telegram's JSON 2023-01-11 19:53:20 +01:00
Lonami Exo
6332690a51 Sort RPC error data 2023-01-11 19:36:30 +01:00
Lonami Exo
2007c83c9e Update to layer 151 2022-12-31 18:38:53 +01:00
Lonami Exo
7288c9933c Update to layer 150 2022-12-22 20:30:38 +01:00
Lonami Exo
979e38152d Bump to v1.26.1 2022-12-22 20:30:38 +01:00
Lonami Exo
6d2a5dada5 Fix PERSISTENT_TIMESTAMP_EMPTY for new entries with pts 1, count 0
Because Telegram can't actually use 0 for the pts, it uses 1, even
if the count is 0. This forces the next update to use 2, or else it
could not be fetched when using an offset of 1 (despite the count
being 0 on the first update, which should not have bumped the second
update to use 2).

This caused Telethon to create an initial state of 0 for the new entry
(and also "incorrectly" detected following updates as gaps, which
would quickly trigger the call to get difference with a bad pts).

Now Telethon is aware of this special-case and will not initialize
state as 0, even if that's not the "correct" thing to do.
2022-12-22 20:30:38 +01:00
yumupdate
061a84bef2
Fix payment.py example (#4005) 2022-12-22 13:42:30 +01:00
Lonami Exo
e750eb7ab5 Add more debug traces to the messagebox 2022-12-18 12:57:51 +01:00
Lonami Exo
59ffad0090 Promote messagebox warnings to errors
No point in making a warning if it immediately is raised.
2022-12-18 12:42:14 +01:00
Lonami Exo
a8ce308b7a Fix messagebox state trace was not logging the object
This state was a TL State, not the MessageBox State with repr.
2022-12-18 12:41:08 +01:00
Lonami Exo
c72c7b160a Introduce trace-level logs to MessageBox
These will log sensitive information.
They are disabled when running with PYTHONOPTIMIZED.
They can only be enabled by setting a level lower than DEBUG,
which is difficult to do on accident.
2022-12-17 23:13:06 +01:00
Lonami Exo
5080715565 Change updates add_done_callback to discard tasks more reliably
See #3235. This should help tone down memory usage a little.
2022-12-16 08:34:01 +01:00
Lonami Exo
b2925f8279 Update documentation and list of known errors 2022-12-14 20:00:55 +01:00
Lonami Exo
4a6ef97910 Fix calls to disconnect after logout
Introduced by 83f13da420.
2022-12-13 08:33:13 +01:00
Lonami Exo
83f13da420 Don't error when calling disconnect after logout 2022-11-27 11:22:30 +01:00
Lonami Exo
4f51604def Fix sending copies of a file message should ignore parse mode
Closes #3983.
2022-11-26 09:12:06 +01:00
Lonami Exo
ba7fc245ab Set documentation language to en 2022-11-26 09:11:49 +01:00
Lonami Exo
bd1ba3bf1e Bump to v1.26 and update layer to 149 2022-11-25 18:23:28 +01:00
Lonami Exo
8ae75db862 Sort updates preemptively
Closes #3936.

There are two changes made to ensure the first update in a channel
cannot be lost, first by always sorting updates before applying pts,
and second by cautiously initializing the local pts if the client
had no pts known beforehand.

It might be possible to cleanup the handling of possible gaps now
that updates are always sorted, but that requires more thought.
2022-11-20 22:51:59 +01:00
Lonami Exo
2c85ffea12 Fix get_dialogs could fail when count % chunk_size = 0
Closes #3971.
2022-11-09 16:14:12 +01:00
Shrimadhav U K
fb43f638ff
Update to layer 148 and document new more errors (#3970) 2022-11-05 20:09:08 +01:00
Lonami Exo
073b87ba1f Fix some typos and add note about BotFather in migration guide 2022-10-26 14:13:30 +02:00
Lonami Exo
0c868065c7 Handle ConnectionError during update handling 2022-10-25 12:28:40 +02:00
Lonami Exo
2ffac2dcdb Handle DestroyAuthKey result more gracefully 2022-10-21 15:52:00 +02:00
Lonami Exo
f902c9293a Fix log message formatting when obj was not updates 2022-10-21 15:50:37 +02:00
Lonami Exo
a7db08d020 Fix sender destroy session handling was not running 2022-10-21 15:49:18 +02:00
Lonami Exo
0980d55c34 Document how to send spoilers and custom emoji 2022-10-21 11:12:55 +02:00
Lonami Exo
b3266fabd8 Fix iter_messages could get stuck on global search
Found thanks to #920. Issue probably introduced in b6d8311.
2022-10-18 16:00:32 +02:00
th3c00lw0lf
ef4f9a962c
Fix MediaEmptyError error when sending some videos (#3951) 2022-10-15 20:01:29 +02:00
Devesh Pal
f819593cbf
Update to layer 146 (#3933) 2022-10-14 18:50:27 +02:00
Lonami Exo
2d237c41fe Revert accidental NO_UPDATES_TIMEOUT change and bump to v1.25.4 2022-10-14 18:43:44 +02:00
Lonami Exo
7f5a1ec5e1 Bump to v1.25.3 2022-10-14 18:41:39 +02:00
Lonami Exo
949b54fdb0 Fix edit_admin failing on small Chat 2022-10-13 17:56:47 +02:00
Lonami Exo
b6d8311a55 Fix iter_messages was stopping too early in some channels
Closes #3949.
2022-10-13 13:40:25 +02:00
Lonami Exo
db29e9b7ef Don't unnecessarily refetch the sender twice 2022-10-03 13:11:48 +02:00
Lonami Exo
299b090cde Let download_profile_photo work with min-User 2022-10-03 13:06:27 +02:00
Lonami Exo
04cf2953f6 Document that disconnect cancels event handlers
Closes #3942.
2022-10-03 10:25:15 +02:00
Lonami Exo
ad2238e788 Shield disconnect from cancellation
Relevant issue: #3942.
2022-10-03 10:25:15 +02:00
Alfian Pangetsu
908375ac42
Fix get_running_loop usage in Python3.6 (#3941)
Closes #3939.
2022-10-02 19:05:11 +02:00
Lonami Exo
7f472ee72c Add CHAT_FORWARDS_RESTRICTED to known errors 2022-10-02 16:25:52 +02:00
Lonami Exo
d2b1c3ec5f Lower severity of some log messages during update handling
Some people were complaining that their logs were being spammed by it.
2022-10-02 16:07:14 +02:00
Lonami Exo
1cf6cf46bd Bump to v1.25.2 2022-09-27 11:32:38 +02:00
Lonami Exo
bb98f4e68c Fix get_dialogs was not filling channels pts 2022-09-27 11:31:41 +02:00
Lonami Exo
105a7a7c56 Log channel ID when getting their difference 2022-09-27 11:31:26 +02:00
Lonami Exo
fd70b5a428 Update list of known errors 2022-09-27 10:49:12 +02:00
Lonami Exo
6fcd7dff38 Bump to v1.25.1 2022-09-24 11:03:09 +02:00
Lonami Exo
346a3f0ef5 Add note on 2FA for qr_login 2022-09-21 12:28:51 +02:00
Lonami Exo
c975b566a1 Handle ServerError while getting difference
Closes #3870.
2022-09-21 12:17:24 +02:00
Lonami Exo
49bdb762c9 Re-raise unhandled errors that occur during update handling
This should help the situation in #3870.
2022-09-21 12:13:21 +02:00
Lonami Exo
a83fe46baf Document the client instance cannot be used after logout
Closes #3780.
2022-09-21 12:00:55 +02:00
Lonami Exo
17516318e6 Add a hard timeout on disconnect
Closes #3917.
2022-09-21 10:58:35 +02:00
Lonami Exo
6d02a1c6ff Update some raw API examples 2022-09-21 10:50:07 +02:00
Lonami Exo
1f42e6e32f del_surrogate for HTML inline URLs
Closes #3693.
2022-09-20 18:18:26 +02:00
Lonami Exo
ff0f9b0e8f Ignore ChannelParticipantLeft during iter_participants
Closes #3231.
2022-09-20 18:12:29 +02:00
Lonami Exo
2d4305db76 Wrap buttons typehint in Optional
Closes #3762.
2022-09-20 18:03:16 +02:00
Lonami Exo
5a17397fc7 Fix events.Album did not have chat in PM
Closes #3773.
2022-09-20 17:54:05 +02:00
Lonami Exo
d7424ccb90 Ignore aggressive parameter in iter_participants
It's broken (it causes flood wait immediately).
Closes #3787.
2022-09-20 17:35:25 +02:00
Lonami Exo
e6ebe6b334 Replace mentions of master branch with v1 2022-09-20 17:08:53 +02:00
Lonami Exo
18da855dd4 Fix get_permissions for small group chats
Closes #3811.
2022-09-20 16:17:45 +02:00
Lonami Exo
75fe90005f Manually construct reply_to for send_message result
Closes #3803.
2022-09-20 16:05:48 +02:00
Lonami Exo
363c2604df Strip 0-length message entities
Closes #3884.
2022-09-20 13:12:01 +02:00
Lonami Exo
7cac3668d6 Make custom, functions and types proper modules
This allows "from telethon.types import Message" to work.
Closes #3929.

Not entirely sure how it used to work before, perhaps
it got changed at some point but this should revert previous
behaviour.
2022-09-20 12:59:36 +02:00
Lonami Exo
2f2a9901e2 Trust pts values during apply_difference
See #3873.
2022-09-20 12:52:24 +02:00
Lonami Exo
64bc73c41e Do not remove ENTRY_SECRET during apply difference
This probably occurs with bot accounts only.
It is strange that the qts is used by bots but is missing from the
results of getDifference. This would need more investigation, but
it might just be the way this thing works.

Closes #3873.
2022-09-20 12:40:48 +02:00
Lonami Exo
243f58c331 Handle auth errors during get difference 2022-09-20 11:35:59 +02:00
Lonami Exo
06536cfb91 Recognize invite links with plus sign prefix 2022-09-20 10:57:52 +02:00
Lonami Exo
299eceb6eb Document new known RPC errors 2022-09-20 10:56:57 +02:00
Lonami Exo
50aa92ebde Handle CancelledError inside update loop
This error is not really unexpected, since the library uses it to
cancel the task during disconnect.

Closes #3921.
2022-09-14 17:11:13 +02:00
Lonami Exo
7d4424ac2b Make use of AlbumHack for all albums
Closes #3916.

The new MessageBox system is not designed with "albums come in the
same updates container" in mind (in fact, there was a note about this).

This version was also not intended to be published to PyPi, but it is,
so a workaround must be made for events.Album to remain working.

In essence, AlbumHack will always be used even if it technically did not
need to be used previously. This will cause a small delay for those
updates, but it should not be a major issue.
2022-09-14 16:53:56 +02:00
Lonami Exo
a66df977f7 Fix UpdateShort from socket was not unboxed
This was causing UpdateShorts to be sent to Raw handlers,
which in turn broke things like QR login.

Should fix #3922.
2022-09-14 15:53:11 +02:00
Shubham Kumar
935be9dd6e
Fix some MD parsing of inline URLs (#3920) 2022-09-09 21:46:06 +02:00
Lonami Exo
6e8bc0d5b9 Fix raw API docs generation 2022-08-30 13:21:17 +02:00
Lonami Exo
8b1bfcdf9c Bump to v1.25 2022-08-30 12:57:34 +02:00
Lonami Exo
48d7dbe90b Remove missed async keywords from the revert
This should've been in 7d21b40401.
This completes the revert of async sessions.
2022-08-30 12:40:05 +02:00
Lonami Exo
e87e6738b5 Revert "Add missing async keywords in SQLiteSession"
This reverts commit 0f5eeb29e7.
2022-08-30 12:32:23 +02:00
Lonami Exo
7d21b40401 Revert "Make sessions async"
This reverts commit d2de0f3aca.
2022-08-30 12:32:21 +02:00
Lonami Exo
88b2b9372d Revert "Mark certain SQLiteSession methods as async"
This reverts commit f913ea6b75.
2022-08-30 12:22:05 +02:00
Lonami Exo
44e3651adf Revert "Add workaround for SQLiteSession needing save after init"
This reverts commit 8190a92aae.
2022-08-30 12:20:20 +02:00
Tulir Asokan
d5c864597c Update to layer 144 2022-08-30 12:11:38 +02:00
Lonami Exo
df96ead0ab Also except ChannelInvalidError during get_diff
This change comes from here:
2166d913e6
2022-08-30 12:07:14 +02:00
Tulir Asokan
809a07edac Fix missing variable and assignment in reset_deadline 2022-08-30 12:05:33 +02:00
Lonami Exo
4b151fbce9 Handle ValueError during get_channel_difference 2022-08-16 12:09:21 +02:00
Lonami Exo
396594060b Fix reset_deadline was not doing its job
This was leading to a soft deadlock, always trying to get difference
but always receiving empty one and not exiting.
2022-08-11 11:04:37 +02:00
Lonami Exo
dd55e7c748 Prevent double-logging of 'timeout for updates' 2022-08-11 10:53:21 +02:00
Devesh Pal
362d06654f
Support sending 4GB files (#3891) 2022-07-28 12:30:46 +02:00
Rongrong
db3faedbfc
Add ENTITY_BOUNDS_INVALID and POSTPONED_TIMEOUT to known errors (#3887) 2022-07-25 11:52:48 +02:00
Rongrong
046e2cb605
Fix HTML/MD parser producing empty MessageEntity (#3885)
Closes #3884. The implementation is also simplified.
2022-07-25 11:11:26 +02:00
Eugene Lam
066820900d
Update to layer 143 (#3862) 2022-07-02 09:27:35 +02:00
Lonami Exo
f90cdf2ffb Fix apply_difference should not end get diff for secret if not active 2022-05-31 11:58:38 +02:00
Lonami Exo
1af6d9a873 Properly log RpcError with no parent request
This should get rid of the unexpected BufferError traceback.
2022-05-31 11:02:34 +02:00
Lonami Exo
0f5eeb29e7 Add missing async keywords in SQLiteSession 2022-05-30 13:39:08 +02:00
Lonami Exo
441fe9d076 Remove TODOs which are no longer relevant in MessageBox 2022-05-30 13:33:56 +02:00
Lonami Exo
7e0639ac57 Add getting_diff_for assertion in get_channel_difference too 2022-05-30 13:31:29 +02:00
Lonami Exo
898e279218 Assert getting_diff_for is not filled when not possible in get_difference 2022-05-30 13:29:58 +02:00
Lonami Exo
a38170d26a Assert reset_deadline is not unnecessarily called 2022-05-30 13:28:22 +02:00
Lonami Exo
6f6b207866 Better fix for reset_deadline
Follow-up to 876af8f27c.
The issue was caused because we called end_get_diff to cleanup a diff
that could not actually be started.

This also enables further cleanup later on.
2022-05-30 13:27:04 +02:00
Lonami Exo
876af8f27c Fix reset_deadline failing when the state map is empty 2022-05-30 13:04:02 +02:00
Lonami Exo
8190a92aae Add workaround for SQLiteSession needing save after init 2022-05-30 12:59:04 +02:00
Lonami Exo
378ccd17bf Call catch_up before processing updates if the user wants so 2022-05-25 15:31:39 +02:00
Lonami Exo
aa7a083444 Add missing begin_get_diff call on updatesTooLong 2022-05-25 15:28:15 +02:00
Lonami
b180b53619
Fix typo in ChannelTooLong code path 2022-05-23 16:53:34 +02:00
Lonami Exo
6005585764 Don't crash if periodic session access fails
If saving every minute or new entities fails, it's not fatal.
Other places are not checked because it is more critical for
information to be saved, such as disconnect, where we want to
crash if the session cannot be accessed.
2022-05-23 14:02:56 +02:00
Lonami Exo
06b0ae56d4 Treat invalid pts as outdated pts 2022-05-23 13:56:10 +02:00
Lonami Exo
c5bf83eb86 Remove unnecessary workaround when updating local pts 2022-05-23 13:52:32 +02:00
Lonami Exo
5a1b9daf4c Add back UpdateChannelTooLong check
Removed in the previous commit 2bcedb98.
2022-05-23 13:17:12 +02:00
Lonami Exo
2bcedb9820 Process diff.other_updates as if they are socket updates
This prevents duplicates since it contains the control flow to check for pts.
2022-05-23 13:13:39 +02:00
Lonami Exo
9dbf3443d0 Better initialization pts for new channels 2022-05-23 12:20:02 +02:00
Lonami Exo
f50b2f5d61 Handle bans when getting difference 2022-05-23 11:41:59 +02:00
Lonami Exo
dfce1f53a8 Handle PersistentTimestampOutdatedError 2022-05-23 11:10:46 +02:00
Lonami Exo
5e46b6365c Use the correct type in apply_channel_difference
This one should not change the behaviour, but it's done for consistency.
2022-05-23 10:27:57 +02:00
Lonami Exo
d5bfb71e10 Handle get_difference case for secret chats 2022-05-20 20:44:36 +02:00
Lonami Exo
af56429e78 Add repr to MessageBox types 2022-05-20 18:15:43 +02:00
Lonami Exo
dfc6d448ed Expose catch_up in client constructor and default it to False 2022-05-20 14:55:47 +02:00
Lonami Exo
3a44f56f64 Also process own updates in MessageBox 2022-05-19 16:40:32 +02:00
Lonami Exo
80685191ab Add a hack to enable StringSession.save be (a)sync 2022-05-18 14:53:04 +02:00
Lonami Exo
184984ac51 Protect against potential replay attacks
See #3753.
2022-05-18 12:24:28 +02:00
Lonami Exo
09b9cd8193 Fix initial session state load 2022-05-16 19:01:05 +02:00
Lonami Exo
c16fb0dae6 Add missing await in qr_login 2022-05-16 18:56:24 +02:00
Lonami Exo
898eb5b82f Call GetState on login to init MessageBox 2022-05-16 10:05:07 +02:00
Lonami Exo
3c7f53802f Fix saving of update state 2022-05-16 09:36:57 +02:00
Lonami Exo
0dff21a80f Add missing async in sqlite 2022-05-16 09:36:41 +02:00
Lonami Exo
7963af1d17 Add repr to updates session types for easier debugging 2022-05-16 09:36:13 +02:00
Lonami Exo
001df933a5 Disable GHA workflows temporarily 2022-05-13 17:53:20 +02:00
Lonami Exo
db7b7fde3f Actually fix references to TL in MessageBox 2022-05-13 17:46:51 +02:00
Lonami Exo
a5c3df2743 Attempt to load and save MessageBox state 2022-05-13 17:40:03 +02:00
Lonami Exo
053a0052c8 Fix references to TL in MessageBox 2022-05-13 17:39:31 +02:00
Lonami Exo
db09a92bc5 Make use of the new MessageBox 2022-05-13 13:17:16 +02:00
Lonami Exo
b5bfe5d9a1 Remove StateCache 2022-05-13 12:43:50 +02:00
Lonami Exo
f4b2fe9540 Backport v2 MessageBox 2022-05-13 12:29:58 +02:00
Lonami Exo
fdb0720fe9 Don't reset the auth_key upon receiving -404 2022-05-12 12:03:48 +02:00
Lonami Exo
f913ea6b75 Mark certain SQLiteSession methods as async
Follow-up to d2de0f3aca.
2022-05-12 11:08:18 +02:00
Tulir Asokan
ecc036c7f4 Add option to clear unread reactions in send_read_acknowledge 2022-05-12 10:40:31 +02:00
Tulir Asokan
dda696cce4 Add INVITE_REQUEST_SENT to known errors 2022-05-12 10:40:04 +02:00
Tulir Asokan
f351d5dcfd Handle expired phone codes. Fixes mautrix/telegram#326 2022-05-12 10:39:27 +02:00
Tulir Asokan
d2de0f3aca Make sessions async
SQLiteSession is not updated, don't try to use it
2022-05-12 10:36:10 +02:00
Lonami Exo
43f629f665 Update to layer 140 2022-05-12 10:21:03 +02:00
Lonami Exo
5feb210442 Add support for flags2 in the TL 2022-05-12 10:20:42 +02:00
229 changed files with 21100 additions and 17134 deletions

View File

@ -1,27 +0,0 @@
---
name: Bug Report
about: Create a report about a bug inside the library or issues with the documentation
title: ''
labels: ''
assignees: ''
---
**Checklist**
* [ ] The error is in the library's code, and not in my own.
* [ ] I have searched for this issue before posting it and there isn't a duplicate.
* [ ] I ran `pip install -U https://github.com/LonamiWebs/Telethon/archive/master.zip` and triggered the bug in the latest version.
**Code that causes the issue**
```python
from telethon import TelegramClient
...
```
**Traceback**
```
Traceback (most recent call last):
File "code.py", line 1, in <code>
```

96
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@ -0,0 +1,96 @@
name: Bug Report
description: Create a report about a bug inside the library.
body:
- type: textarea
id: reproducing-example
attributes:
label: Code that causes the issue
description: Provide a code example that reproduces the problem. Try to keep it short without other dependencies.
placeholder: |
```python
from telethon.sync import TelegramClient
...
```
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: Explain what you should expect to happen. Include reproduction steps.
placeholder: |
"I was doing... I was expecting the following to happen..."
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
description: Explain what actually happens.
placeholder: |
"This happened instead..."
validations:
required: true
- type: textarea
id: traceback
attributes:
label: Traceback
description: |
The traceback, if the problem is a crash.
placeholder: |
```
Traceback (most recent call last):
File "code.py", line 1, in <code>
```
- type: input
id: telethon-version
attributes:
label: Telethon version
description: The output of `python -c "import telethon; print(telethon.__version__)"`.
placeholder: "1.x"
validations:
required: true
- type: input
id: python-version
attributes:
label: Python version
description: The output of `python --version`.
placeholder: "3.x"
validations:
required: true
- type: input
id: os
attributes:
label: Operating system (including distribution name and version)
placeholder: Windows 11, macOS 13.4, Ubuntu 23.04...
validations:
required: true
- type: textarea
id: other-details
attributes:
label: Other details
placeholder: |
Additional details and attachments. Is it a server? Network condition?
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: The error is in the library's code, and not in my own.
required: true
- label: I have searched for this issue before posting it and there isn't an open duplicate.
required: true
- label: I ran `pip install -U https://github.com/LonamiWebs/Telethon/archive/v1.zip` and triggered the bug in the latest version.
required: true

View File

@ -1,3 +1,4 @@
blank_issues_enabled: false
contact_links:
- name: Ask questions in StackOverflow
url: https://stackoverflow.com/questions/ask?tags=telethon

View File

@ -0,0 +1,22 @@
name: Documentation Issue
description: Report a problem with the documentation.
labels: [documentation]
body:
- type: textarea
id: description
attributes:
label: Description
description: Describe the problem in detail.
placeholder: This part is unclear...
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: This is a documentation problem, not a question or a bug report.
required: true
- label: I have searched for this issue before posting it and there isn't a duplicate.
required: true

View File

@ -1,10 +0,0 @@
---
name: Feature Request
about: Suggest ideas, changes or other enhancements for the library
title: ''
labels: enhancement
assignees: ''
---
Please describe your idea. Would you like another friendly method? Renaming them to something more appropriated? Changing the way something works?

View File

@ -0,0 +1,22 @@
name: Feature Request
description: Suggest ideas, changes or other enhancements for the library.
labels: [enhancement]
body:
- type: textarea
id: feature-description
attributes:
label: Describe your suggested feature
description: Please describe your idea. Would you like another friendly method? Renaming them to something more appropriate? Changing the way something works?
placeholder: "It should work like this..."
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: I have searched for this issue before posting it and there isn't a duplicate.
required: true

5
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,5 @@
<!--
Thanks for the PR! Please keep in mind that v1 is *feature frozen*.
New features very likely won't be merged, although fixes can be sent.
All new development should happen in v2. Thanks!
-->

28
.github/workflows.disabled/python.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Python Library
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.5", "3.6", "3.7", "3.8"]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Set up env
run: |
python -m pip install --upgrade pip
pip install tox
- name: Lint with flake8
run: |
tox -e flake
- name: Test with pytest
run: |
# use "py", which is the default python version
tox -e py

11
.gitignore vendored
View File

@ -1,12 +1,11 @@
# Generated code
/telethon/_tl/fn/
/telethon/_tl/*.py
/telethon/_tl/alltlobjects.py
/telethon/errors/_generated.py
/telethon/tl/functions/
/telethon/tl/types/
/telethon/tl/alltlobjects.py
/telethon/errors/rpcerrorlist.py
# User session
*.session
sessions/
/usermedia/
# Builds and testing
@ -21,4 +20,4 @@ __pycache__/
/docs/
# File used to manually test new changes, contains sensitive data
/example*.py
/example.py

18
.readthedocs.yaml Normal file
View File

@ -0,0 +1,18 @@
# https://docs.readthedocs.io/en/stable/config-file/v2.html
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.11"
sphinx:
configuration: readthedocs/conf.py
formats:
- pdf
- epub
python:
install:
- requirements: readthedocs/requirements.txt

View File

@ -12,6 +12,8 @@ as a user or through a bot account (bot API alternative).
If you have code using Telethon before its 1.0 version, you must
read `Compatibility and Convenience`_ to learn how to migrate.
As with any third-party library for Telegram, be careful not to
break `Telegram's ToS`_ or `Telegram can ban the account`_.
What is this?
-------------
@ -35,19 +37,15 @@ Creating a client
.. code-block:: python
import asyncio
from telethon import TelegramClient, events
from telethon import TelegramClient, events, sync
# These example values won't work. You must get your own api_id and
# api_hash from https://my.telegram.org, under API Development.
api_id = 12345
api_hash = '0123456789abcdef0123456789abcdef'
async def main():
client = TelegramClient('session_name', api_id, api_hash)
await client.start()
asyncio.run(main())
client = TelegramClient('session_name', api_id, api_hash)
client.start()
Doing stuff
@ -55,14 +53,14 @@ Doing stuff
.. code-block:: python
print((await client.get_me()).stringify())
print(client.get_me().stringify())
await client.send_message('username', 'Hello! Talking to you from Telethon')
await client.send_file('username', '/home/myself/Pictures/holidays.jpg')
client.send_message('username', 'Hello! Talking to you from Telethon')
client.send_file('username', '/home/myself/Pictures/holidays.jpg')
await client.download_profile_photo('me')
messages = await client.get_messages('username')
await messages[0].download_media()
client.download_profile_photo('me')
messages = client.get_messages('username')
messages[0].download_media()
@client.on(events.NewMessage(pattern='(?i)hi|hello'))
async def handler(event):
@ -80,6 +78,8 @@ useful information.
.. _MTProto: https://core.telegram.org/mtproto
.. _Telegram: https://telegram.org
.. _Compatibility and Convenience: https://docs.telethon.dev/en/stable/misc/compatibility-and-convenience.html
.. _Telegram's ToS: https://core.telegram.org/api/terms
.. _Telegram can ban the account: https://docs.telethon.dev/en/stable/quick-references/faq.html#my-account-was-deleted-limited-when-using-the-library
.. _Read The Docs: https://docs.telethon.dev
.. |logo| image:: logo.svg

View File

@ -3,3 +3,4 @@ pysocks
python-socks[asyncio]
hachoir
pillow
isal

View File

@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py37,py38
envlist = py35,py36,py37,py38
# run with tox -e py
[testenv]

View File

@ -25,7 +25,7 @@ you can run the following command instead:
.. code-block:: sh
python3 -m pip install --upgrade https://github.com/LonamiWebs/Telethon/archive/master.zip
python3 -m pip install --upgrade https://github.com/LonamiWebs/Telethon/archive/v1.zip
.. note::

View File

@ -8,70 +8,70 @@ use these if possible.
.. code-block:: python
import asyncio
from telethon import TelegramClient
# Remember to use your own values from my.telegram.org!
api_id = 12345
api_hash = '0123456789abcdef0123456789abcdef'
client = TelegramClient('anon', api_id, api_hash)
async def main():
async with TelegramClient('anon', api_id, api_hash).start() as client:
# Getting information about yourself
me = await client.get_me()
# Getting information about yourself
me = await client.get_me()
# "me" is a user object. You can pretty-print
# any Telegram object with the "stringify" method:
print(me.stringify())
# "me" is a user object. You can pretty-print
# any Telegram object with the "stringify" method:
print(me.stringify())
# When you print something, you see a representation of it.
# You can access all attributes of Telegram objects with
# the dot operator. For example, to get the username:
username = me.username
print(username)
print(me.phone)
# When you print something, you see a representation of it.
# You can access all attributes of Telegram objects with
# the dot operator. For example, to get the username:
username = me.username
print(username)
print(me.phone)
# You can print all the dialogs/conversations that you are part of:
async for dialog in client.get_dialogs():
print(dialog.name, 'has ID', dialog.id)
# You can print all the dialogs/conversations that you are part of:
async for dialog in client.iter_dialogs():
print(dialog.name, 'has ID', dialog.id)
# You can send messages to yourself...
await client.send_message('me', 'Hello, myself!')
# ...to some chat ID
await client.send_message(-100123456, 'Hello, group!')
# ...to your contacts
await client.send_message('+34600123123', 'Hello, friend!')
# ...or even to any username
await client.send_message('username', 'Testing Telethon!')
# You can send messages to yourself...
await client.send_message('me', 'Hello, myself!')
# ...to some chat ID
await client.send_message(-100123456, 'Hello, group!')
# ...to your contacts
await client.send_message('+34600123123', 'Hello, friend!')
# ...or even to any username
await client.send_message('username', 'Testing Telethon!')
# You can, of course, use markdown in your messages:
message = await client.send_message(
'me',
'This message has **bold**, `code`, __italics__ and '
'a [nice website](https://example.com)!',
link_preview=False
)
# You can, of course, use markdown in your messages:
message = await client.send_message(
'me',
'This message has **bold**, `code`, __italics__ and '
'a [nice website](https://example.com)!',
link_preview=False
)
# Sending a message returns the sent message object, which you can use
print(message.raw_text)
# Sending a message returns the sent message object, which you can use
print(message.raw_text)
# You can reply to messages directly if you have a message object
await message.reply('Cool!')
# You can reply to messages directly if you have a message object
await message.reply('Cool!')
# Or send files, songs, documents, albums...
await client.send_file('me', '/home/me/Pictures/holidays.jpg')
# Or send files, songs, documents, albums...
await client.send_file('me', '/home/me/Pictures/holidays.jpg')
# You can print the message history of any chat:
async for message in client.get_messages('me'):
print(message.id, message.text)
# You can print the message history of any chat:
async for message in client.iter_messages('me'):
print(message.id, message.text)
# You can download media from messages, too!
# The method will return the path where the file was saved.
if message.photo:
path = await message.download_media()
print('File saved to', path) # printed after download is done
# You can download media from messages, too!
# The method will return the path where the file was saved.
if message.photo:
path = await message.download_media()
print('File saved to', path) # printed after download is done
asyncio.run(main())
with client:
client.loop.run_until_complete(main())
Here, we show how to sign in, get information about yourself, send
@ -100,8 +100,12 @@ proceeding. We will see all the available methods later on.
# Most of your code should go here.
# You can of course make and use your own async def (do_something).
# They only need to be async if they need to await things.
async with client.start():
me = await client.get_me()
await do_something(me)
me = await client.get_me()
await do_something(me)
asyncio.run(main())
with client:
client.loop.run_until_complete(main())
After you understand this, you may use the ``telethon.sync`` hack if you
want do so (see :ref:`compatibility-and-convenience`), but note you may
run into other issues (iPython, Anaconda, etc. have some issues with it).

View File

@ -49,19 +49,15 @@ We can finally write some code to log into our account!
.. code-block:: python
import asyncio
from telethon import TelegramClient
# Use your own values from my.telegram.org
api_id = 12345
api_hash = '0123456789abcdef0123456789abcdef'
async def main():
# The first parameter is the .session file name (absolute paths allowed)
async with TelegramClient('anon', api_id, api_hash).start() as client:
await client.send_message('me', 'Hello, myself!')
asyncio.run(main())
# The first parameter is the .session file name (absolute paths allowed)
with TelegramClient('anon', api_id, api_hash) as client:
client.loop.run_until_complete(client.send_message('me', 'Hello, myself!'))
In the first line, we import the class name so we can create an instance
@ -99,19 +95,18 @@ You will still need an API ID and hash, but the process is very similar:
.. code-block:: python
import asyncio
from telethon import TelegramClient
from telethon.sync import TelegramClient
api_id = 12345
api_hash = '0123456789abcdef0123456789abcdef'
bot_token = '12345:0123456789abcdef0123456789abcdef'
async def main():
# But then we can use the client instance as usual
async with TelegramClient('bot', api_id, api_hash).start(bot_token=bot_token) as bot:
... # bot is your client
# We have to manually call "start" if we want an explicit bot token
bot = TelegramClient('bot', api_id, api_hash).start(bot_token=bot_token)
asyncio.run(main())
# But then we can use the client instance as usual
with bot:
...
To get a bot account, you need to talk
@ -121,9 +116,11 @@ with `@BotFather <https://t.me/BotFather>`_.
Signing In behind a Proxy
=========================
If you need to use a proxy to access Telegram, you will need to:
If you need to use a proxy to access Telegram,
you will need to either:
`install python-socks[asyncio]`__
* For Python >= 3.6 : `install python-socks[asyncio]`__
* For Python <= 3.5 : `install PySocks`__
and then change
@ -144,9 +141,16 @@ consisting of parameters described `in PySocks usage`__.
The allowed values for the argument ``proxy_type`` are:
* ``python_socks.ProxyType.SOCKS5``
* ``python_socks.ProxyType.SOCKS4``
* ``python_socks.ProxyType.HTTP``
* For Python <= 3.5:
* ``socks.SOCKS5`` or ``'socks5'``
* ``socks.SOCKS4`` or ``'socks4'``
* ``socks.HTTP`` or ``'http'``
* For Python >= 3.6:
* All of the above
* ``python_socks.ProxyType.SOCKS5``
* ``python_socks.ProxyType.SOCKS4``
* ``python_socks.ProxyType.HTTP``
Example:

View File

@ -16,7 +16,7 @@ For that, you can use **events**.
.. code-block:: python
import logging
logging.basicConfig(format='[%(levelname) 5s/%(asctime)s] %(name)s: %(message)s',
logging.basicConfig(format='[%(levelname) %(asctime)s] %(name)s: %(message)s',
level=logging.WARNING)

View File

@ -40,21 +40,99 @@ because tasks are smaller than threads, which are smaller than processes.
What are asyncio basics?
========================
The code samples below assume that you have Python 3.7 or greater installed.
.. code-block:: python
# First we need the asyncio library
import asyncio
# Then we need a loop to work with
loop = asyncio.get_event_loop()
# We also need something to run
async def main():
for char in 'Hello, world!\n':
print(char, end='', flush=True)
await asyncio.sleep(0.2)
# Then, we need to run the loop with a task
# Then, we can create a new asyncio loop and use it to run our coroutine.
# The creation and tear-down of the loop is hidden away from us.
asyncio.run(main())
What does telethon.sync do?
===========================
The moment you import any of these:
.. code-block:: python
from telethon import sync, ...
# or
from telethon.sync import ...
# or
import telethon.sync
The ``sync`` module rewrites most ``async def``
methods in Telethon to something similar to this:
.. code-block:: python
def new_method():
result = original_method()
if loop.is_running():
# the loop is already running, return the await-able to the user
return result
else:
# the loop is not running yet, so we can run it for the user
return loop.run_until_complete(result)
That means you can do this:
.. code-block:: python
print(client.get_me().username)
Instead of this:
.. code-block:: python
me = client.loop.run_until_complete(client.get_me())
print(me.username)
# or, using asyncio's default loop (it's the same)
import asyncio
loop = asyncio.get_running_loop() # == client.loop
me = loop.run_until_complete(client.get_me())
print(me.username)
As you can see, it's a lot of boilerplate and noise having to type
``run_until_complete`` all the time, so you can let the magic module
to rewrite it for you. But notice the comment above: it won't run
the loop if it's already running, because it can't. That means this:
.. code-block:: python
async def main():
# 3. the loop is running here
print(
client.get_me() # 4. this will return a coroutine!
.username # 5. this fails, coroutines don't have usernames
)
loop.run_until_complete( # 2. run the loop and the ``main()`` coroutine
main() # 1. calling ``async def`` "returns" a coroutine
)
Will fail. So if you're inside an ``async def``, then the loop is
running, and if the loop is running, you must ``await`` things yourself:
.. code-block:: python
async def main():
print((await client.get_me()).username)
loop.run_until_complete(main())
@ -75,18 +153,15 @@ loops or use ``async with``:
async with client:
# ^ this is an asynchronous with block
async for message in client.get_messages(chat):
async for message in client.iter_messages(chat):
# ^ this is a for loop over an asynchronous generator
print(message.sender.username)
loop = asyncio.get_event_loop()
# ^ this assigns the default event loop from the main thread to a variable
loop.run_until_complete(main())
# ^ this runs the *entire* loop until the main() function finishes.
# While the main() function does not finish, the loop will be running.
# While the loop is running, you can't run it again.
asyncio.run(main())
# ^ this will create a new asyncio loop behind the scenes and tear it down
# once the function returns. It will run the loop untiil main finishes.
# You should only use this function if there is no other loop running.
The ``await`` keyword blocks the *current* task, and the loop can run
@ -106,14 +181,14 @@ concurrently:
await asyncio.sleep(delay) # await tells the loop this task is "busy"
print('world') # eventually the loop finishes all tasks
loop = asyncio.get_event_loop() # get the default loop for the main thread
loop.create_task(world(2)) # create the world task, passing 2 as delay
loop.create_task(hello(delay=1)) # another task, but with delay 1
async def main():
asyncio.create_task(world(2)) # create the world task, passing 2 as delay
asyncio.create_task(hello(delay=1)) # another task, but with delay 1
await asyncio.sleep(3) # wait for three seconds before exiting
try:
# run the event loop forever; ctrl+c to stop it
# we could also run the loop for three seconds:
# loop.run_until_complete(asyncio.sleep(3))
loop.run_forever()
# create a new temporary asyncio loop and use it to run main
asyncio.run(main())
except KeyboardInterrupt:
pass
@ -131,10 +206,15 @@ The same example, but without the comment noise:
await asyncio.sleep(delay)
print('world')
loop = asyncio.get_event_loop()
loop.create_task(world(2))
loop.create_task(hello(1))
loop.run_until_complete(asyncio.sleep(3))
async def main():
asyncio.create_task(world(2))
asyncio.create_task(hello(delay=1))
await asyncio.sleep(3)
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
Can I use threads?
@ -172,9 +252,9 @@ You may have seen this error:
RuntimeError: There is no current event loop in thread 'Thread-1'.
It just means you didn't create a loop for that thread, and if you don't
pass a loop when creating the client, it uses ``asyncio.get_event_loop()``,
which only works in the main thread.
It just means you didn't create a loop for that thread. Please refer to
the ``asyncio`` documentation to correctly learn how to set the event loop
for non-main threads.
client.run_until_disconnected() blocks!
@ -197,7 +277,7 @@ in it. So if you want to run *other* code, create tasks for it:
loop.create_task(clock())
...
await client.run_until_disconnected()
client.run_until_disconnected()
This creates a task for a clock that prints the time every second.
You don't need to use `client.run_until_disconnected()
@ -266,6 +346,19 @@ When you use a library, you're not limited to use only its methods. You can
combine all the libraries you want. People seem to forget this simple fact!
Why does client.start() work outside async?
===========================================
Because it's so common that it's really convenient to offer said
functionality by default. This means you can set up all your event
handlers and start the client without worrying about loops at all.
Using the client in a ``with`` block, `start
<telethon.client.auth.AuthMethods.start>`, `run_until_disconnected
<telethon.client.updates.UpdateMethods.run_until_disconnected>`, and
`disconnect <telethon.client.telegrambaseclient.TelegramBaseClient.disconnect>`
all support this.
Where can I read more?
======================

View File

@ -28,6 +28,9 @@ their own Telegram bots. Quoting their main page:
Bot API is simply an HTTP endpoint which translates your requests to it into
MTProto calls through tdlib_, their bot backend.
Configuration of your bot, such as its available commands and auto-completion,
is configured through `@BotFather <https://t.me/BotFather>`_.
What is MTProto?
================
@ -296,7 +299,7 @@ After rewriting:
class Subbot(TelegramClient):
def __init__(self, *a, **kw):
await super().__init__(*a, **kw)
super().__init__(*a, **kw)
self.add_event_handler(self.on_update, events.NewMessage)
async def connect():

View File

@ -100,12 +100,12 @@ Note that this function can also work with other types, like :tl:`Chat` or
:tl:`Channel` instances.
If you need to convert other types like usernames which might need to perform
API calls to find out the identifier, you can use ``client.get_profile``:
API calls to find out the identifier, you can use ``client.get_peer_id``:
.. code-block:: python
print((await client.get_profile('me')).id) # your id
print(await client.get_peer_id('me')) # your id
If there is no "mark" (no minus sign), Telethon will assume your identifier

View File

@ -1,27 +1,20 @@
.. _entities:
===============
Users and Chats
===============
========
Entities
========
The library widely uses the concept of "users" to refer to both real accounts
and bot accounts, as well as the concept of "chats" to refer to groups and
broadcast channels.
The most general term you can use to think about these is "an entity", but
recent versions of the library often prefer to opt for names which better
reflect the intention, such as "dialog" when a previously-existing
conversation is expected, or "profile" when referring to the information about
the user or chat.
The library widely uses the concept of "entities". An entity will refer
to any :tl:`User`, :tl:`Chat` or :tl:`Channel` object that the API may return
in response to certain methods, such as :tl:`GetUsersRequest`.
.. note::
When something "dialog-like" is required, it means that you need to
provide something that can be used to refer to an open conversation.
These things include, but are not limited to, packed chats, usernames,
integer IDs (identifiers), :tl:`Peer` objects, or even entire :tl:`User`,
:tl:`Chat` and :tl:`Channel` objects and even phone numbers **from people
you have in your contact list**.
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, :tl:`Peer` objects,
or even entire :tl:`User`, :tl:`Chat` and :tl:`Channel` objects and even
phone numbers **from people you have in your contact list**.
To "encounter" an ID, you would have to "find it" like you would in the
normal app. If the peer is in your dialogs, you would need to
@ -30,123 +23,82 @@ the user or chat.
`client.get_participants(group) <telethon.client.chats.ChatMethods.get_participants>`.
Once you have encountered an ID, the library will (by default) have saved
its packed version for you, which is needed to invoke most methods.
their ``access_hash`` for you, which is needed to invoke most methods.
This is why sometimes you might encounter this error when working with
the library. You should ``except ValueError`` and run code that you know
should work to find the user or chat. You **cannot** use an ID of someone
you haven't interacted with. Because this is more unreliable, packed chats
are recommended instead.
should work to find the entity.
.. contents::
What is a User?
===============
What is an Entity?
==================
A `User <telethon.types._custom.user.User>` can be either a real user account
(some person who has signed up for an account) or a bot account which is
programmed to perform certain actions (created by a developer via
`@BotFather <https://t.me/BotFather>`_).
A lot of methods and requests require *entities* to work. For example,
you send a message to an *entity*, get the username of an *entity*, and
so on.
A lot of methods and requests require user or chats to work. For example,
you can send a message to a *user*, ban a *user* from a group, and so on.
These methods accept more than just `User <telethon.types._custom.user.User>`
as the input parameter. You can also use packed users, usernames, string phone
numbers, or integer IDs, although some have higher cost than others.
When using the username, the library must fetch it first, which can be
expensive. When using the phone number, the library must fetch it first, which
can be expensive. If you plan to use these, it's recommended you manually use
`client.get_profile() <telethon.client.users.UserMethods.get_profile>` to cache
the username or phone number, and then use the value returned instead.
There are a lot of things that work as entities: usernames, phone numbers,
chat links, invite links, IDs, and the types themselves. That is, you can
use any of those when you see an "entity" is needed.
.. note::
Remember that the phone number must be in your contact list before you
can use it.
The recommended type to use as input parameters to the methods is either a
`User <telethon.types._custom.user.User>` instance or its packed type.
You should use, **from better to worse**:
In the raw API, users are instances of :tl:`User` (or :tl:`UserEmpty`), which
are returned in response to some requests, such as :tl:`GetUsersRequest`.
There are also variants for use as "input parameters", such as :tl:`InputUser`
and :tl:`InputPeerUser`. You generally **do not need** to worry about these
types unless you're using raw API.
1. Input entities. For example, `event.input_chat
<telethon.tl.custom.chatgetter.ChatGetter.input_chat>`,
`message.input_sender
<telethon.tl.custom.sendergetter.SenderGetter.input_sender>`,
or caching an entity you will use a lot with
``entity = await client.get_input_entity(...)``.
2. Entities. For example, if you had to get someone's
username, you can just use ``user`` or ``channel``.
It will work. Only use this option if you already have the entity!
3. IDs. This will always look the entity up from the
cache (the ``*.session`` file caches seen entities).
4. Usernames, phone numbers and links. The cache will be
used too (unless you force a `client.get_entity()
<telethon.client.users.UserMethods.get_entity>`),
but may make a request if the username, phone or link
has not been found yet.
In recent versions of the library, the following two are equivalent:
.. code-block:: python
async def handler(event):
await client.send_message(event.sender_id, 'Hi')
await client.send_message(event.input_sender, 'Hi')
What is a Chat?
===============
If you need to be 99% sure that the code will work (sometimes it's
simply impossible for the library to find the input entity), or if
you will reuse the chat a lot, consider using the following instead:
A `Chat <telethon.types._custom.chat.Chat>` can be a small group chat (the
default group type created by users where many users can join and talk), a
megagroup (also known as "supergroup"), a broadcast channel or a broadcast
group.
.. code-block:: python
The term "chat" is really overloaded in Telegram. The library tries to be
explicit and always use "small group chat", "megagroup" and "broadcast" to
differentiate. However, Telegram's API uses "chat" to refer to both "chat"
(small group chat), and "channel" (megagroup, broadcast or "gigagroup" which
is a broadcast group of type channel).
A lot of methods and requests require a chat to work. For example,
you can get the participants from a *chat*, kick users from a *chat*, and so on.
These methods accept more than just `Chat <telethon.types._custom.chat.Chat>`
as the input parameter. You can also use packed chats, the public link, or
integer IDs, although some have higher cost than others.
When using the public link, the library must fetch it first, which can be
expensive. If you plan to use these, it's recommended you manually use
`client.get_profile() <telethon.client.users.UserMethods.get_profile>` to cache
the link, and then use the value returned instead.
.. note::
The link of a public chat has the form "t.me/username", where the username
can belong to either an actual user or a public chat.
The recommended type to use as input parameters to the methods is either a
`Chat <telethon.types._custom.chat.Chat>` instance or its packed type.
In the raw API, chats are instances of :tl:`Chat` and :tl:`Channel` (or
:tl:`ChatEmpty`, :tl:`ChatForbidden` and :tl:`ChannelForbidden`), which
are returned in response to some requests, such as :tl:`messages.GetChats`
and :tl:`channels.GetChannels`. There are also variants for use as "input
parameters", such as :tl:`InputChannel` and :tl:`InputPeerChannel`. You
generally **do not need** to worry about these types unless you're using raw API.
async def handler(event):
# This method may make a network request to find the input sender.
# Properties can't make network requests, so we need a method.
sender = await event.get_input_sender()
await client.send_message(sender, 'Hi')
await client.send_message(sender, 'Hi')
When to use each term?
======================
The term "dialog" is used when the library expects a reference to an open
conversation (from the list the user sees when they open the application).
The term "profile" is used instead of "dialog" when the conversation is not
expected to exist. Because "dialog" is more specific than "profile", "dialog"
is used where possible instead.
In general, you should not use named arguments for neither "dialogs" or
"profiles", since they're the first argument. The parameter name only exists
for documentation purposes.
The term "chat" is used where a group or broadcast channel is expected. This
includes small groups, megagroups, broadcast channels and broadcast groups.
Telegram's API has, in the past, made a difference between which methods can
be used for "small group chats" and everything else. For example, small group
chats cannot have a public link (they automatically convert to megagroups).
Group permissions also used to be different, but because Telegram may unify
these eventually, the library attempts to hide this distinction. In general,
this is not something you should worry about.
Fetching profile information
============================
Getting Entities
================
Through the use of the :ref:`sessions`, the library will automatically
remember the packed users and chats, along with some extra information,
so you're able to just do this:
remember the ID and hash pair, along with some extra information, so
you're able to just do this:
.. code-block:: python
@ -154,37 +106,146 @@ so you're able to just do this:
#
# Dialogs are the "conversations you have open".
# This method returns a list of Dialog, which
# has the .user and .chat attributes (among others).
# has the .entity attribute and other information.
#
# This part is IMPORTANT, because it fills the cache.
# This part is IMPORTANT, because it fills the entity cache.
dialogs = await client.get_dialogs()
# All of these work and do the same, but are more expensive to use.
channel = await client.get_profile('username')
channel = await client.get_profile('t.me/username')
channel = await client.get_profile('https://telegram.dog/username')
contact = await client.get_profile('+34xxxxxxxxx')
# All of these work and do the same.
username = await client.get_entity('username')
username = await client.get_entity('t.me/username')
username = await client.get_entity('https://telegram.dog/username')
# This will work, but only if the ID is in cache.
friend = await client.get_profile(friend_id)
# Other kind of entities.
channel = await client.get_entity('telegram.me/joinchat/AAAAAEkk2WdoDrB4-Q8-gg')
contact = await client.get_entity('+34xxxxxxxxx')
friend = await client.get_entity(friend_id)
# This is the most reliable way to fetch a profile.
user = await client.get_profile('U.123.456789')
group = await client.get_profile('G.456.0')
broadcast = await client.get_profile('C.789.123456')
# Getting entities through their ID (User, Chat or Channel)
entity = await client.get_entity(some_id)
# You can be more explicit about the type for said ID by wrapping
# it inside a Peer instance. This is recommended but not necessary.
from telethon.tl.types import PeerUser, PeerChat, PeerChannel
my_user = await client.get_entity(PeerUser(some_id))
my_chat = await client.get_entity(PeerChat(some_id))
my_channel = await client.get_entity(PeerChannel(some_id))
All methods in the :ref:`telethon-client` accept any of the above
prior to sending the request to save you from the hassle of doing so manually.
.. note::
You **don't** need to get the entity before using it! Just let the
library do its job. Use a phone from your contacts, username, ID or
input entity (preferred but not necessary), whatever you already have.
All methods in the :ref:`telethon-client` call `.get_input_entity()
<telethon.client.users.UserMethods.get_input_entity>` prior
to sending the request to save you from the hassle of doing so manually.
That way, convenience calls such as `client.send_message('username', 'hi!')
<telethon.client.messages.MessageMethods.send_message>` become possible.
However, it can be expensive to fetch the username every time, so this is
better left for things which are not executed often.
<telethon.client.messages.MessageMethods.send_message>`
become possible.
Every entity the library encounters (in any response to any call) will by
default be cached in the ``.session`` file (an SQLite database), to avoid
performing unnecessary API calls. If the entity cannot be found, additonal
calls like :tl:`ResolveUsernameRequest` or :tl:`GetContactsRequest` may be
made to obtain the required information.
Entities vs. Input Entities
===========================
.. note::
This section is informative, but worth reading. The library
will transparently handle all of these details for you.
On top of the normal types, the API also make use of what they call their
``Input*`` versions of objects. The input version of an entity (e.g.
:tl:`InputPeerUser`, :tl:`InputChat`, etc.) only contains the minimum
information that's required from Telegram to be able to identify
who you're referring to: a :tl:`Peer`'s **ID** and **hash**. They
are named like this because they are input parameters in the requests.
Entities' ID are the same for all user and bot accounts, however, the access
hash is **different for each account**, so trying to reuse the access hash
from one account in another will **not** work.
Sometimes, Telegram only needs to indicate the type of the entity along
with their ID. For this purpose, :tl:`Peer` versions of the entities also
exist, which just have the ID. You cannot get the hash out of them since
you should not be needing it. The library probably has cached it before.
Peers are enough to identify an entity, but they are not enough to make
a request with them. You need to know their hash before you can
"use them", and to know the hash you need to "encounter" them, let it
be in your dialogs, participants, message forwards, etc.
.. note::
You *can* use peers with the library. Behind the scenes, they are
replaced with the input variant. Peers "aren't enough" on their own
but the library will do some more work to use the right type.
As we just mentioned, API calls don't need to know the whole information
about the entities, only their ID and hash. For this reason, another method,
`client.get_input_entity() <telethon.client.users.UserMethods.get_input_entity>`
is available. This will always use the cache while possible, making zero API
calls most of the time. When a request is made, if you provided the full
entity, e.g. an :tl:`User`, the library will convert it to the required
:tl:`InputPeer` automatically for you.
**You should always favour**
`client.get_input_entity() <telethon.client.users.UserMethods.get_input_entity>`
**over**
`client.get_entity() <telethon.client.users.UserMethods.get_entity>`
for this reason! Calling the latter will always make an API call to get
the most recent information about said entity, but invoking requests don't
need this information, just the :tl:`InputPeer`. Only use
`client.get_entity() <telethon.client.users.UserMethods.get_entity>`
if you need to get actual information, like the username, name, title, etc.
of the entity.
To further simplify the workflow, since the version ``0.16.2`` of the
library, the raw requests you make to the API are also able to call
`client.get_input_entity() <telethon.client.users.UserMethods.get_input_entity>`
wherever needed, so you can even do things like:
.. code-block:: python
await client(SendMessageRequest('username', 'hello'))
The library will call the ``.resolve()`` method of the request, which will
resolve ``'username'`` with the appropriated :tl:`InputPeer`. Don't worry if
you don't get this yet, but remember some of the details here are important.
Full Entities
=============
In addition to :tl:`PeerUser`, :tl:`InputPeerUser`, :tl:`User` (and its
variants for chats and channels), there is also the concept of :tl:`UserFull`.
This full variant has additional information such as whether the user is
blocked, its notification settings, the bio or about of the user, etc.
There is also :tl:`messages.ChatFull` which is the equivalent of full entities
for chats and channels, with also the about section of the channel. Note that
the ``users`` field only contains bots for the channel (so that clients can
suggest commands to use).
You can get both of these by invoking :tl:`GetFullUser`, :tl:`GetFullChat`
and :tl:`GetFullChannel` respectively.
Accessing Entities
==================
Although it's explicitly noted in the documentation that messages
*subclass* `ChatGetter <telethon.tl.custom.chatgetter.ChatGetter>`
and `SenderGetter <telethon.tl.custom.sendergetter.SenderGetter>`,
this section will explain what this means.
some people still don't get inheritance.
When the documentation says "Bases: `telethon.tl.custom.chatgetter.ChatGetter`"
it means that the class you're looking at, *also* can act as the class it
@ -197,9 +258,9 @@ That means you can do this:
.. code-block:: python
message.is_private
message.chat_id
message.chat
await event.get_chat()
await message.get_chat()
# ...etc
`SenderGetter <telethon.tl.custom.sendergetter.SenderGetter>` is similar:
@ -207,8 +268,8 @@ That means you can do this:
.. code-block:: python
message.user_id
await message.get_input_sender()
message.user
await event.get_input_user()
# ...etc
Quite a few things implement them, so it makes sense to reuse the code.
@ -217,51 +278,11 @@ For example, all events (except raw updates) implement `ChatGetter
in some chat.
Packed User and packed Chat
===========================
A packed `User <telethon.types._custom.user.User>` or a packed
`Chat <telethon.types._custom.chat.Chat>` can be thought of as
"a small string reference to the actual user or chat".
It can easily be saved or embedded in the code for later use,
without having to worry if the user is in the session file cache.
This "packed representation" is a compact way to store the type of the User
or Chat (is it a user account, a bot, a broadcast channel…), the identifier,
and the access hash. This "access hash" is something Telegram uses to ensure
that you can actually use this "User" or "Chat" in requests (so you can't just
create some random user identifier and expect it to work).
In the raw API, this is pretty much "input peers", but the library uses the
term "packed user or chat" to refer to its custom type and string
representation.
The User and Chat IDs are the same for all user and bot accounts. However, the
access hash is **different for each account**, so trying to reuse the access
hash from one account in another will **not** work. This also means the packed
representation will only work for the account that created it.
The library needs to have this access hash in some way for it to work.
If it only has an ID and this ID is not in cache, it will not work.
If using the packed representation, the hash is embedded, and will always work.
Every method, including raw API, will automatically convert your types to the
expected input type the API uses, meaning the following will work:
.. code-block:: python
await client(_tl.fn.messages.SendMessage('username', 'hello'))
(This is only a raw API example, there are better ways to send messages.)
Summary
=======
TL;DR; If you're here because of *"Could not find the input peer for"*,
you must ask yourself, "how did I find this user or chat through official
TL;DR; If you're here because of *"Could not find the input entity for"*,
you must ask yourself "how did I find this entity through official
applications"? Now do the same with the library. Use what applies:
.. code-block:: python
@ -269,28 +290,24 @@ applications"? Now do the same with the library. Use what applies:
# (These examples assume you are inside an "async def")
async with client:
# Does it have a username? Use it!
user = await client.get_profile(username)
entity = await client.get_entity(username)
# Do you have a conversation open with them? Get dialogs.
await client.get_dialogs()
# Are they participants of some group? Get them.
# Are they participant of some group? Get them.
await client.get_participants('username')
# Is the user the original sender of a forwarded message? Fetch the message.
# Is the entity the original sender of a forwarded message? Get it.
await client.get_messages('username', 100)
# NOW you can use the ID anywhere!
# NOW you can use the ID, anywhere!
await client.send_message(123456, 'Hi!')
user = await client.get_profile(123456)
print(user)
entity = await client.get_entity(123456)
print(entity)
Once the library has "seen" the user or chat, you can use their **integer** ID.
You can't use users or chats from IDs the library hasn't seen. You must make
the library see them *at least once* and disconnect properly. You know where
the user or chat are, and you must tell the library. It won't guess for you.
This is why it's recommended to use the packed versions instead. They will
always work (unless Telegram, for some very unlikely reason, changes the way
using users and chats works, of course).
Once the library has "seen" the entity, you can use their **integer** ID.
You can't use entities from IDs the library hasn't seen. You must make the
library see them *at least once* and disconnect properly. You know where
the entities are and you must tell the library. It won't guess for you.

View File

@ -150,6 +150,6 @@ You can also except it and act as you prefer:
VoIP numbers are very limited, and some countries are more limited too.
.. _list of known errors: https://github.com/LonamiWebs/Telethon/blob/master/telethon_generator/data/errors.csv
.. _list of known errors: https://github.com/LonamiWebs/Telethon/blob/v1/telethon_generator/data/errors.csv
.. _raw API page: https://tl.telethon.dev/
.. _messages.sendMessage: https://tl.telethon.dev/methods/messages/send_message.html

View File

@ -10,13 +10,20 @@ The Full API
methods listed on :ref:`client-ref` unless you have a better reason
not to, like a method not existing or you wanting more control.
.. contents::
Introduction
============
The :ref:`telethon-client` doesn't offer a method for every single request
the Telegram API supports. However, it's very simple to *call* or *invoke*
any request. Whenever you need something, don't forget to `check the documentation`_
and look for the `method you need`_. There you can go through a sorted list
of everything you can do.
any request defined in Telegram's API.
This section will teach you how to use what Telethon calls the `TL reference`_.
The linked page contains a list and a way to search through *all* types
generated from the definition of Telegram's API (in ``.tl`` file format,
hence the name). These types include requests and constructors.
.. note::
@ -25,10 +32,193 @@ of everything you can do.
as you type, and a "Copy import" button. If you like namespaces, you
can also do ``from telethon.tl import types, functions``. Both work.
Telegram makes these ``.tl`` files public, which other implementations, such
as Telethon, can also use to generate code. These files are versioned under
what's called "layers". ``.tl`` files consist of thousands of definitions,
and newer layers often add, change, or remove them. Each definition refers
to either a Remote Procedure Call (RPC) function, or a type (which the
`TL reference`_ calls "constructors", as they construct particular type
instances).
You should also refer to the documentation to see what the objects
(constructors) Telegram returns look like. Every constructor inherits
from a common type, and that's the reason for this distinction.
As such, the `TL reference`_ is a good place to go to learn about all possible
requests, types, and what they look like. If you're curious about what's been
changed between layers, you can refer to the `TL diff`_ site.
Navigating the TL reference
===========================
Functions
---------
"Functions" is the term used for the Remote Procedure Calls (RPC) that can be
sent to Telegram to ask it to perform something (e.g. "send message"). These
requests have an associated return type. These can be invoked ("called"):
.. code-block:: python
client = TelegramClient(...)
function_instance = SomeRequest(...)
# Invoke the request
returned_type = await client(function_instance)
Whenever you find the type for a function in the `TL reference`_, the page
will contain the following information:
* What type of account can use the method. This information is regenerated
from time to time (by attempting to invoke the function under both account
types and finding out where it fails). Some requests can only be used by
bot accounts, others by user accounts, and others by both.
* The TL definition. This helps you get a feel for the what the function
looks like. This is not Python code. It just contains the definition in
a concise manner.
* "Copy import" button. Does what it says: it will copy the necessary Python
code to import the function to your system's clipboard for easy access.
* Returns. The returned type. When you invoke the function, this is what the
result will be. It also includes which of the constructors can be returned
inline, to save you a click.
* Parameters. The parameters accepted by the function, including their type,
whether they expect a list, and whether they're optional.
* Known RPC errors. A best-effort list of known errors the request may cause.
This list is not complete and may be out of date, but should provide an
overview of what could go wrong.
* Example. Autogenerated example, showcasing how you may want to call it.
Bear in mind that this is *autogenerated*. It may be spitting out non-sense.
The goal of this example is not to show you everything you can do with the
request, only to give you a feel for what it looks like to use it.
It is very important to click through the links and navigate to get the full
picture. A specific page will show you what the specific function returns and
needs as input parameters. But it may reference other types, so you need to
navigate to those to learn what those contain or need.
Types
-----
"Types" as understood by TL are not actually generated in Telethon.
They would be the "abstract base class" of the constructors, but since Python
is duck-typed, there is hardly any need to generate mostly unnecessary code.
The page for a type contains:
* Constructors. Every type will have one or more constructors. These
constructors *are* generated and can be immported and used.
* Requests returning this type. A helpful way to find out "what requests can
return this?". This is how you may learn what request you need to use to
obtain a particular instance of a type.
* Requests accepting this type as input. A helpful way to find out "what
requests can use this type as one of their input parameters?". This is how
you may learn where a type is used.
* Other types containing this type. A helpful way to find out "where else
does this type appear?". This is how you can walk back through nested
objects.
Constructors
------------
Constructors are used to create instances of a particular type, and are also
returned when invoking requests. You will have to create instances yourself
when invoking requests that need a particular type as input.
The page for a constructor contains:
* Belongs to. The parent type. This is a link back to the types page for the
specific constructor. It also contains the sibling constructors inline, to
save you a click.
* Members. Both the input parameters *and* fields the constructor contains.
Using the TL reference
======================
After you've found a request you want to send, a good start would be to simply
copy and paste the autogenerated example into your script. Then you can simply
tweak it to your needs.
If you want to do it from scratch, first, make sure to import the request into
your code (either using the "Copy import" button near the top, or by manually
spelling out the package under ``telethon.tl.functions.*``).
Then, start reading the parameters one by one. If the parameter cannot be
omitted, you **will** need to specify it, so make sure to spell it out as
an input parameter when constructing the request instance. Let's look at
`PingRequest`_ for example. First, we copy the import:
.. code-block:: python
from telethon.tl.functions import PingRequest
Then, we look at the parameters:
ping_id - long
A single parameter, and it's a long (a integer number with a large range of
values). It doesn't say it can be omitted, so we must provide it, like so:
.. code-block:: python
PingRequest(
ping_id=48641868471
)
(In this case, the ping ID is a random number. You often have to guess what
the parameter needs just by looking at the name.)
Now that we have our request, we can invoke it:
.. code-block:: python
response = await client(PingRequest(
ping_id=48641868471
))
To find out what ``response`` looks like, we can do as the autogenerated
example suggests and "stringify" the result as a pretty-printed string:
.. code-block:: python
print(result.stringify())
This will print out the following:
.. code-block:: python
Pong(
msg_id=781875678118,
ping_id=48641868471
)
Which is a very easy way to get a feel for a response. You should nearly
always print the stringified result, at least once, when trying out requests,
to get a feel for what the response may look like.
But of course, you don't need to do that. Without writing any code, you could
have navigated through the "Returns" link to learn ``PingRequest`` returns a
``Pong``, which only has one constructor, and the constructor has two members,
``msg_id`` and ``ping_id``.
If you wanted to create your own ``Pong``, you would use both members as input
parameters:
.. code-block:: python
my_pong = Pong(
msg_id=781875678118,
ping_id=48641868471
)
(Yes, constructing object instances can use the same code that ``.stringify``
would return!)
And if you wanted to access the ``msg_id`` member, you would simply access it
like any other attribute access in Python:
.. code-block:: python
print(response.msg_id)
Example walkthrough
===================
Say `client.send_message()
<telethon.client.messages.MessageMethods.send_message>` didn't exist,
@ -74,7 +264,7 @@ Or we call `client.get_input_entity()
async def main():
peer = await client.get_input_entity('someone')
asyncio.run(main())
client.loop.run_until_complete(main())
.. note::
@ -129,7 +319,7 @@ as you wish. Remember to use the right types! To sum up:
.. code-block:: python
result = await client(SendMessageRequest(
await client.get_profile('username'), 'Hello there!'
await client.get_input_entity('username'), 'Hello there!'
))
@ -224,6 +414,7 @@ and still access the successful results:
# The second request failed.
second = e.exceptions[1]
.. _check the documentation: https://tl.telethon.dev
.. _method you need: https://tl.telethon.dev/methods/index.html
.. _TL reference: https://tl.telethon.dev
.. _TL diff: https://diff.telethon.dev
.. _PingRequest: https://tl.telethon.dev/methods/ping.html
.. _use the search: https://tl.telethon.dev/?q=message&redirect=no

View File

@ -73,10 +73,10 @@ You can import these ``from telethon.sessions``. For example, using the
.. code-block:: python
from telethon import TelegramClient
from telethon.sync import TelegramClient
from telethon.sessions import StringSession
async with TelegramClient(StringSession(string), api_id, api_hash) as client:
with TelegramClient(StringSession(string), api_id, api_hash) as client:
... # use the client
# Save the string session as a string; you should decide how
@ -129,10 +129,10 @@ The easiest way to generate a string session is as follows:
.. code-block:: python
from telethon import TelegramClient
from telethon.sync import TelegramClient
from telethon.sessions import StringSession
async with TelegramClient(StringSession(), api_id, api_hash) as client:
with TelegramClient(StringSession(), api_id, api_hash) as client:
print(client.session.save())
@ -143,7 +143,7 @@ output (likely your terminal).
.. warning::
**Keep this string safe!** Anyone with this string can use it
to login into your account and do anything they want to to do.
to login into your account and do anything they want to.
This is similar to leaking your ``*.session`` files online,
but it is easier to leak a string than it is to leak a file.
@ -156,8 +156,8 @@ you can save it in a variable directly:
.. code-block:: python
string = '1aaNk8EX-YRfwoRsebUkugFvht6DUPi_Q25UOCzOAqzc...'
async with TelegramClient(StringSession(string), api_id, api_hash).start() as client:
await client.send_message('me', 'Hi')
with TelegramClient(StringSession(string), api_id, api_hash) as client:
client.loop.run_until_complete(client.send_message('me', 'Hi'))
These strings are really convenient for using in places like Heroku since

View File

@ -8,7 +8,7 @@ does a result have? Well, the easiest thing to do is printing it:
.. code-block:: python
entity = await client.get_profile('username')
entity = await client.get_entity('username')
print(entity)
That will show a huge **string** similar to the following:

View File

@ -28,7 +28,7 @@ In short, you should do this:
buttons = await event.get_buttons()
async def main():
async for message in client.get_messages('me', 10):
async for message in client.iter_messages('me', 10):
# Methods from the client always have these properties ready
chat = message.input_chat
sender = message.sender
@ -191,8 +191,7 @@ so the code above and the following are equivalent:
async def main():
await client.disconnected
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
asyncio.run(main())
You could also run `client.disconnected
@ -207,7 +206,7 @@ Notice that unlike `client.disconnected
<telethon.client.telegrambaseclient.TelegramBaseClient.disconnected>`,
`client.run_until_disconnected
<telethon.client.updates.UpdateMethods.run_until_disconnected>` will
handle ``KeyboardInterrupt`` with you. This method is special and can
handle ``KeyboardInterrupt`` for you. This method is special and can
also be ran while the loop is running, so you can do this:
.. code-block:: python

View File

@ -85,7 +85,7 @@ release = version
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
language = 'en'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.

View File

@ -71,7 +71,7 @@ version incompatabilities.
Tox environments are declared in the ``tox.ini`` file. The default
environments, declared at the top, can be simply run with ``tox``. The option
``tox -e py37,flake`` can be used to request specific environments to be run.
``tox -e py36,flake`` can be used to request specific environments to be run.
Brief Introduction to Pytest-cov
================================

View File

@ -107,7 +107,7 @@ use :tl:`GetMessagesViewsRequest`, setting ``increment=True``:
.. code-block:: python
# Obtain `channel' through dialogs or through client.get_profile() or anyhow.
# Obtain `channel' through dialogs or through client.get_entity() or anyhow.
# Obtain `msg_ids' through `.get_messages()` or anyhow. Must be a list.
await client(GetMessagesViewsRequest(

View File

@ -2,44 +2,12 @@
Working with messages
=====================
.. note::
These examples assume you have read :ref:`full-api`.
.. contents::
This section has been `moved to the wiki`_, where it can be easily edited as new
features arrive and the API changes. Please refer to the linked page to learn how
to send spoilers, custom emoji, stickers, react to messages, and more things.
Sending stickers
================
Stickers are nothing else than ``files``, and when you successfully retrieve
the stickers for a certain sticker set, all you will have are ``handles`` to
these files. Remember, the files Telegram holds on their servers can be
referenced through this pair of ID/hash (unique per user), and you need to
use this handle when sending a "document" message. This working example will
send yourself the very first sticker you have:
.. code-block:: python
# Get all the sticker sets this user has
from telethon.tl.functions.messages import GetAllStickersRequest
sticker_sets = await client(GetAllStickersRequest(0))
# Choose a sticker set
from telethon.tl.functions.messages import GetStickerSetRequest
from telethon.tl.types import InputStickerSetID
sticker_set = sticker_sets.sets[0]
# Get the stickers for this sticker set
stickers = await client(GetStickerSetRequest(
stickerset=InputStickerSetID(
id=sticker_set.id, access_hash=sticker_set.access_hash
)
))
# Stickers are nothing more than files, so send that
await client.send_file('me', stickers.documents[0])
.. _issues: https://github.com/LonamiWebs/Telethon/issues/215
.. _moved to the wiki: https://github.com/LonamiWebs/Telethon/wiki/Sending-more-than-just-messages

View File

@ -4,21 +4,17 @@ Telethon's Documentation
.. code-block:: python
import asyncio
from telethon import TelegramClient, events
from telethon.sync import TelegramClient, events
async def main():
async with TelegramClient('name', api_id, api_hash) as client:
await client.send_message('me', 'Hello, myself!')
print(await client.download_profile_photo('me'))
with TelegramClient('name', api_id, api_hash) as client:
client.send_message('me', 'Hello, myself!')
print(client.download_profile_photo('me'))
@client.on(events.NewMessage(pattern='(?i).*Hello'))
async def handler(event):
await event.reply('Hey!')
@client.on(events.NewMessage(pattern='(?i).*Hello'))
async def handler(event):
await event.reply('Hey!')
await client.run_until_disconnected()
asyncio.run(main())
client.run_until_disconnected()
* Are you new here? Jump straight into :ref:`installation`!
@ -107,7 +103,6 @@ You can also use the menu on the left to quickly skip over sections.
:caption: Miscellaneous
misc/changelog
misc/v2-migration-guide.rst
misc/compatibility-and-convenience
.. toctree::

View File

@ -13,13 +13,530 @@ it can take advantage of new goodies!
.. contents:: List of All Versions
New layer (v1.41)
=================
Complete overhaul of the library (v2.0)
=======================================
+------------------------+
| Scheme layer used: 214 |
+------------------------+
(inc and link all of migration guide)
properly-typed enums for filters and actions
`View new and changed raw API methods <https://diff.telethon.dev/?from=201&to=214>`__.
Additions
~~~~~~~~~
* ``send_as`` and ``effect`` added to ``send_file``.
* ``mime_type`` added to ``send_file``.
* ``tg-emoji`` now works with HTML parse mode.
* Clicking a button now lets you choose whether to open the browser.
* Persistent and placeholder buttons.
* More separate RPC error classes.
Enhancements
~~~~~~~~~~~~
* Update entities should now be cached to session more reliably.
* ``utils.get_display_name`` now handles more types.
* Improved some type hints.
* Reply properties for stories now behave as expected.
* ``isal`` can now be used as an optional dependency for faster compression.
* Potential slight speed improvements to deserialization.
Bug fixes
~~~~~~~~~
* Library was not saving update sequence from certain updates.
* Input peer cache should no longer overwrite valid data with min peers.
* Spoiler for input photos and documents was not being respected.
New layer (v1.40)
=================
+------------------------+
| Scheme layer used: 201 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=199&to=201>`__.
Additions
~~~~~~~~~
* ``send_as`` and ``effect`` added to ``send_message`` and related methods.
* :tl:`MessageMediaGeoLive` is now recognized for auto-input conversion.
Enhancements
~~~~~~~~~~~~
* Improved wording when using a likely unintended session file.
* Improved behaviour for matching Markdown links.
* A truly clean update-state is now fetched upon login. This was most notably important for bots.
* Time offset is now updated more reliably after connecting. This should fix legitimate "message too old/new" issues.
Bug fixes
~~~~~~~~~
* :tl:`ChannelParticipantLeft` is now skipped in ``iter_participants``.
* ``spoiler`` flag was lost on :tl:`MessageMediaPhoto` auto-input conversion.
* :tl:`KeyboardButtonCopy` is now recognized as an inline button.
* Downloading web-documents should now work again. Note that this still fetches the file from the original server.
New layer (v1.39)
=================
+------------------------+
| Scheme layer used: 199 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=193&to=199>`__.
Additions
~~~~~~~~~
* ``drop_media_captions`` added to ``forward_messages``, and documented together with ``drop_author``.
* :tl:`InputMediaDocumentExternal` is now recognized when sending albums.
Enhancements
~~~~~~~~~~~~
* ``receive_updates=False`` now covers more cases, however, Telegram is still free to ignore it.
* Better type-hints in several methods.
* Markdown parsing of inline links should cover more cases.
* ``range`` is now considered "list-like" and can be used on e.g. ``ids`` parameters.
Bug fixes
~~~~~~~~~
* Session is now saved after setting the DC.
* Fixed rare crash in entity cache handling when iterating through dialogs.
* Fixed IOError that could occur during automatic resizing of some photos.
New layer (v1.38)
=================
+------------------------+
| Scheme layer used: 193 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=188&to=193>`__.
Bug fixes
~~~~~~~~~
* Formatting entities misbehaved with albums.
* Sending a Message object with a file did not use the new file.
New layer (v1.37)
=================
+------------------------+
| Scheme layer used: 188 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=181&to=188>`__.
Additions
~~~~~~~~~
* Support for CDN downloads should be back. Telethon still prefers no CDN by default.
Enhancements
~~~~~~~~~~~~
* ``FloodWaitPremium`` should now be handled like any other floodwaits.
Bug fixes
~~~~~~~~~
* Fixed edge-case when using ``get_messages(..., reverse=True)``.
* ``ConnectionError`` when using proxies should be raised properly.
New layer (v1.36)
=================
+------------------------+
| Scheme layer used: 181 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=178&to=181>`__.
Bug fixes
~~~~~~~~~
* Certain updates, such as :tl:`UpdateBotStopped`, should now be processed reliably.
New layer (v1.35)
=================
+------------------------+
| Scheme layer used: 178 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=173&to=178>`__.
Additions
~~~~~~~~~
* ``drop_author`` parameter now exposed in ``forward_messages``.
Enhancements
~~~~~~~~~~~~
* "Custom secret support" should work with ``TcpMTProxy``.
* Some type hints should now be more accurate.
Bug fixes
~~~~~~~~~
* Session path couldn't be a ``pathlib.Path`` or ``None``.
* Python versions older than 3.9 should now be supported again.
* Readthedocs should hopefully build the v1 documentation again.
New layer (v1.34)
=================
+------------------------+
| Scheme layer used: 173 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=167&to=173>`__.
Additions
~~~~~~~~~
* ``reply_to_chat`` and ``reply_to_sender`` are now in ``Message``.
This is useful when you lack access to the chat, but Telegram still included some basic information.
Bug fixes
~~~~~~~~~
* ``parse_mode`` with a custom instance containing both ``parse`` and ``unparse`` should now work.
* Parsing and unparsing message entities should now behave better in certain corner-cases.
New layer (v1.33)
=================
+------------------------+
| Scheme layer used: 167 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=166&to=167>`__.
Enhancements
~~~~~~~~~~~~
* ``webbrowser`` is now imported conditionally, to support niche environments.
* Library should now retry on the suddenly-common ``TimedOutError``.
Bug fixes
~~~~~~~~~
* Sending photos which were automatically resized should work again (included in the v1.32 series).
New layer (v1.32)
=================
+------------------------+
| Scheme layer used: 166 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=165&to=166>`__.
This enables you to use custom languages in preformatted blocks using HTML:
.. code-block:: html
<pre>
<code class='language-python'>from telethon import TelegramClient</code>
</pre>
Note that Telethon v1's markdown is a custom format and won't support language tags.
If you want to set a custom language, you have to use HTML or a custom formatter.
Dropped imghdr support (v1.31)
==============================
+------------------------+
| Scheme layer used: 165 |
+------------------------+
This release contains a breaking change in preparation for Python 3.12.
If you were sending photos from in-memory ``bytes`` or ``BytesIO`` containing images,
you should now use ``BytesIO`` and set the ``.name`` property to a dummy name.
This will allow Telethon to detect the correct extension (and file type).
.. code-block:: python
# before
image_data = b'...'
client.send_file(chat, image_data)
# after
from io import BytesIO
image_data = BytesIO(b'...')
image_data.name = 'a.jpg' # any name, only the extension matters
client.send_file(chat, image_data)
Bug fixes
~~~~~~~~~
* Code generation wasn't working under PyPy.
* Obtaining markdown or HTML from message text could produce unexpected results sometimes.
* Other fixes for bugs from the previous version, which were already fixed in patch versions.
Breaking Changes
~~~~~~~~~~~~~~~~
* ``imghdr`` is deprecated in newer Python versions, so Telethon no longer uses it.
This means there might be some cases where Telethon fails to infer the file extension for buffers containing images.
If you were relying on this, add ``.name = 'a.jpg'`` (or other extension) to the ``BytesIO`` buffers you upload.
Layer bump and small changes (v1.30)
====================================
+------------------------+
| Scheme layer used: 162 |
+------------------------+
Some of the bug fixes were already present in patch versions of ``v1.29``, but
the new layer necessitated a minor bump.
Enhancements
~~~~~~~~~~~~
* Removed client-side checks for editing messages.
This only affects ``Message.edit``, as ``client.edit_message`` already had
no checks.
* Library should not understand more server-side errors during update handling
which should reduce crashes.
* Client-side image compression should behave better now.
Bug fixes
~~~~~~~~~
* Some updates such as ``UpdateChatParticipant`` were being missed due to the
order in which Telegram sent them. The library now more carefully checks for
the sequence and pts contained in them to avoid dropping them.
* Fixed ``is_inline`` check for :tl:`KeyboardButtonWebView`.
* Fixed some issues getting entity from cache by ID.
* ``reply_to`` should now work when sending albums.
More bug fixing (v1.29)
=======================
+------------------------+
| Scheme layer used: 160 |
+------------------------+
This layer introduces the necessary raw API methods to work with stories.
The library is aiming to be "feature-frozen" for as long as v1 is active,
so friendly client methods are not implemented, but example code to use
stories can be found in the GitHub wiki of the project.
Enhancements
~~~~~~~~~~~~
* Removed client-side checks for methods dealing with chat permissions.
In particular, this means you can now ban channels.
* Improved some error messages and added new classes for more RPC errors.
* The client-side check for valid usernames has been loosened, so that
very short premium usernames are no longer considered invalid.
Bug fixes
~~~~~~~~~
* Attempting to download a thumbnail from documnets without one would fail,
rather than do nothing (since nothing can be downloaded if there is no thumb).
* More errors are caught in the update handling loop.
* HTML ``.text`` should now "unparse" any message contents correctly.
* Fixed some problems related to logging.
* ``comment_to`` should now work as expected with albums.
* ``asyncio.CancelledError`` should now correctly propagate from the update loop.
* Removed some absolute imports in favour of relative imports.
* ``UserUpdate.last_seen`` should now behave correctly.
* Fixed a rare ``ValueError`` during ``connect`` if the session cache was bad.
New Layer and housekeeping (v1.28)
==================================
+------------------------+
| Scheme layer used: 155 |
+------------------------+
Plenty of stale issues closed, as well as improvements for some others.
Additions
~~~~~~~~~
* New ``entity_cache_limit`` parameter in the ``TelegramClient`` constructor.
This should help a bit in keeping memory usage in check.
Enhancements
~~~~~~~~~~~~
* ``progress_callback`` is now called when dealing with albums. See the
documentation on `client.send_file() <telethon.client.uploads.UploadMethods.send_file>`
for details.
* Update state and entities are now periodically saved, so that the information
isn't lost in the case of crash or unexpected script terminations. You should
still be calling ``disconnect`` or using the context-manager, though.
* The client should no longer unnecessarily call ``get_me`` every time it's started.
Bug fixes
~~~~~~~~~
* Messages obtained via raw API could not be used in ``forward_messages``.
* ``force_sms`` and ``sign_up`` have been deprecated. See `issue 4050`_ for details.
It is no longer possible for third-party applications, such as those made with
Telethon, to use those features.
* ``events.ChatAction`` should now work in more cases in groups with hidden members.
* Errors that occur at the connection level should now be properly propagated, so that
you can actually have a chance to handle them.
* Update handling should be more resilient.
* ``PhoneCodeExpiredError`` will correctly clear the stored hash if it occurs in ``sign_in``.
* In patch ``v1.28.2``, :tl:`InputBotInlineMessageID64` can now be used
to edit inline messages.
.. _issue 4050: https://github.com/LonamiWebs/Telethon/issues/4050
New Layer and some Bug fixes (v1.27)
====================================
+------------------------+
| Scheme layer used: 152 |
+------------------------+
Bug fixes
~~~~~~~~~
* When the account is logged-out, the library should now correctly propagate
an error through ``run_until_disconnected`` to let you handle it.
* The library no longer uses ``asyncio.get_event_loop()`` in newer Python
versions, which should get rid of some deprecation warnings.
* It could happen that bots would receive messages sent by themselves,
very often right after they deleted a message. This should happen far
less often now (but might still happen with unlucky timings).
* Maximum photo size for automatic image resizing is now larger.
* The initial request is now correctly wrapped in ``invokeWithoutUpdates``
when updates are disabled after constructing the client instance.
* Using a ``pathlib.Path`` to download contacts and web documents should
now work correctly.
New Layer and some Bug fixes (v1.26)
====================================
+------------------------+
| Scheme layer used: 149 |
+------------------------+
This new layer includes things such as emoji status, more admin log events,
forum topics and message reactions, among other things. You can access these
using raw API. It also contains a few bug fixes.
These were fixed in the v1.25 series:
* ``client.edit_admin`` did not work on small group chats.
* ``client.get_messages`` could stop early in some channels.
* ``client.download_profile_photo`` now should work even if ``User.min``.
* ``client.disconnect`` should no longer hang when being called from within
an event handlers.
* ``client.get_dialogs`` now initializes the update state for channels.
* The message sender should not need to be fetched in more cases.
* Lowered the severity of some log messages to be less spammy.
These are new to v1.26.0:
* Layer update.
* New documented RPC errors.
* Sometimes the first message update to a channel could be missed if said
message was read immediately.
* ``client.get_dialogs`` would fail when the total count evenly divided
the chunk size of 100.
* ``client.get_messages`` could get stuck during a global search.
* Potentially fixed some issues when sending certain videos.
* Update handling should be more resilient.
* The client should handle having its auth key destroyed more gracefully.
* Fixed some issues when logging certain messages.
Bug fixes (v1.25.1)
===================
This version should fix some of the problems that came with the revamped
update handling.
* Some inline URLs were not parsing correctly with markdown.
* ``events.Raw`` was handling :tl:`UpdateShort` which it shouldn't do.
* ``events.Album`` should now work again.
* ``CancelledError`` was being incorrectly logged as a fatal error.
* Some fixes to update handling primarly aimed for bot accounts.
* Update handling now can deal with more errors without crashing.
* Unhandled errors from update handling will now be propagated through
``client.run_until_disconnected``.
* Invite links with ``+`` are now recognized.
* Added new known RPC errors.
* ``telethon.types`` could not be used as a module.
* 0-length message entities are now stripped to avoid errors.
* ``client.send_message`` was not returning a message with ``reply_to``
in some cases.
* ``aggressive`` in ``client.iter_participants`` now does nothing (it did
not really work anymore anyway, and this should prevent other errors).
* ``client.iter_participants`` was failing in some groups.
* Text with HTML URLs could sometimes fail to parse.
* Added a hard timeout during disconnect in order to prevent the program
from freezing.
Please be sure to report issues with update handling if you still encounter
some errors!
Update handling overhaul (v1.25)
================================
+------------------------+
| Scheme layer used: 144 |
+------------------------+
I had plans to release v2 way earlier, but my motivation drained off, so that
didn't happen. The reason for another v1 release is that there was a clear
need to fix some things regarding update handling (which were present in v2).
I did not want to make this release. But with the release date for v2 still
being unclear, I find it necessary to release another v1 version. I apologize
for the delay (I should've done this a lot sooner but didn't because in my
head I would've pushed through and finished v2, but I underestimated how much
work that was and I probably experienced burn-out).
I still don't intend to make new additions to the v1 series (beyond updating
the Telegram layer being used). I still have plans to finish v2 some day.
But in the meantime, new features, such as reactions, will have to be used
through raw API.
This update also backports the update overhaul from v2. If you experience
issues with updates, please report them on the GitHub page for the project.
However, this new update handling should be more reliable, and ``catch_up``
should actually work properly.
Breaking Changes
~~~~~~~~~~~~~~~~
* In order for ``catch_up`` to work (new flag in the ``TelegramClient``
constructor), sessions need to impleemnt the new ``get_update_states``.
Third-party session storages won't have this implemented by the time
this version released, so ``catch_up`` may not work with those.
Rushed release to fix login (v1.24)
===================================
@ -1998,7 +2515,7 @@ the scenes! This means you're now able to do both of the following:
async def main():
await client.send_message('me', 'Hello!')
asyncio.get_event_loop().run_until_complete(main())
asyncio.run(main())
# ...can be rewritten as:

View File

@ -161,19 +161,17 @@ just get rid of ``telethon.sync`` and work inside an ``async def``:
await client.run_until_disconnected()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
asyncio.run(main())
The ``telethon.sync`` magic module simply wraps every method behind:
The ``telethon.sync`` magic module essentially wraps every method behind:
.. code-block:: python
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
asyncio.run(main())
So that you don't have to write it yourself every time. That's the
overhead you pay if you import it, and what you save if you don't.
With some other tricks, so that you don't have to write it yourself every time.
That's the overhead you pay if you import it, and what you save if you don't.
Learning
========

View File

@ -1,991 +0,0 @@
=========================
Version 2 Migration Guide
=========================
Version 2 represents the second major version change, breaking compatibility
with old code beyond the usual raw API changes in order to clean up a lot of
the technical debt that has grown on the project.
This document documents all the things you should be aware of when migrating from Telethon version
1.x to 2.0 onwards. It is sorted roughly from the "likely most impactful changes" to "there's a
good chance you were not relying on this to begin with".
**Please read this document in full before upgrading your code to Telethon 2.0.**
Python 3.5 is no longer supported
---------------------------------
The library will no longer attempt to support Python 3.5. The minimum version is now Python 3.7.
This also means workarounds for 3.6 and below have been dropped.
User, chat and channel identifiers are now 64-bit numbers
---------------------------------------------------------
`Layer 133 <https://diff.telethon.dev/?from=132&to=133>`__ changed *a lot* of identifiers from
``int`` to ``long``, meaning they will no longer fit in 32 bits, and instead require 64 bits.
If you were storing these identifiers somewhere size did matter (for example, a database), you
will need to migrate that to support the new size requirement of 8 bytes.
For the full list of types changed, please review the above link.
Peer IDs, including chat_id and sender_id, no longer follow bot API conventions
-------------------------------------------------------------------------------
Both the ``utils.get_peer_id`` and ``client.get_peer_id`` methods no longer have an ``add_mark``
parameter. Both will always return the original ID as given by Telegram. This should lead to less
confusion. However, it also means that an integer ID on its own no longer embeds the information
about the type (did it belong to a user, chat, or channel?), so ``utils.get_peer`` can no longer
guess the type from just a number.
Because it's not possible to know what other changes Telegram will do with identifiers, it's
probably best to get used to transparently storing whatever value they send along with the type
separatedly.
As far as I can tell, user, chat and channel identifiers are globally unique, meaning a channel
and a user cannot share the same identifier. The library currently makes this assumption. However,
this is merely an observation (I have never heard of such a collision exist), and Telegram could
change at any time. If you want to be on the safe side, you're encouraged to save a pair of type
and identifier, rather than just the number.
// TODO we DEFINITELY need to provide a way to "upgrade" old ids
// TODO and storing type+number by hand is a pain, provide better alternative
// TODO get_peer_id is gone now too!
Synchronous compatibility mode has been removed
-----------------------------------------------
The "sync hack" (which kicked in as soon as anything from ``telethon.sync`` was imported) has been
removed. This implies:
* The ``telethon.sync`` module is gone.
* Synchronous context-managers (``with`` as opposed to ``async with``) are no longer supported.
Most notably, you can no longer do ``with client``. It must be ``async with client`` now.
* The "smart" behaviour of the following methods has been removed and now they no longer work in
a synchronous context when the ``asyncio`` event loop was not running. This means they now need
to be used with ``await`` (or, alternatively, manually used with ``loop.run_until_complete``):
* ``start``
* ``disconnect``
* ``run_until_disconnected``
// TODO provide standalone alternative for this?
Overhaul of events and updates
------------------------------
Updates produced by the client are now also processed by your event handlers.
Before, if you had some code listening for new outgoing messages, only messages you sent with
another client, such as from Telegram Desktop, would be processed. Now, if your own code uses
``client.send_message``, you will also receive the new message event. Be careful, as this can
easily lead to "loops" (a new outgoing message can trigger ``client.send_message``, which
triggers a new outgoing message and the cycle repeats)!
There are no longer "event builders" and "event" types. Now there are only events, and you
register the type of events you want, not an instance. Because of this, the way filters are
specified have also changed:
.. code-block:: python
# OLD
@client.on(events.NewMessage(chats=...))
async def handler(event):
pass
# NEW
@client.on(events.NewMessage, chats=...)
async def handler(event): # ^^ ^
pass
This also means filters are unified, although not all filters have an effect on all events types.
Type hinting is now done through ``events.NewMessage`` and not ``events.NewMessage.Event``.
The filter rework also enables more features. For example, you can now mutate a ``chats`` filter
to add or remove a chat that needs to be received by a handler, rather than having to remove and
re-add the event handler.
The ``from_users`` filter has been renamed to ``senders``.
The ``inbox`` filter for ``events.MessageRead`` has been removed, in favour of ``outgoing`` and
``incoming``.
``events.register``, ``events.unregister`` and ``events.is_handler`` have been removed. There is
no longer anything special about methods which are handlers, and they are no longer monkey-patched.
Because pre-defining the event type to handle without a client was useful, you can now instead use
the following syntax:
.. code-block:: python
# OLD
@events.register(events.NewMessage)
async def handler(event):
pass
# NEW
async def handler(event: events.NewMessage):
pass # ^^^^^^^^^^^^^^^^^^^^^^^^
As a bonus, you only need to type-hint once, and both your IDE and Telethon will understand what
you meant. This is similar to Python's ``@dataclass`` which uses type hints.
// TODO document filter creation and usage, showcase how to mutate them
Complete overhaul of session files
----------------------------------
If you were using third-party libraries to deal with sessions, you will need to wait for those to
be updated. The library will automatically upgrade the SQLite session files to the new version,
and the ``StringSession`` remains backward-compatible. The sessions can now be async.
In case you were relying on the tables used by SQLite (even though these should have been, and
will still need to be, treated as an implementation detail), here are the changes:
* The ``sessions`` table is now correctly split into ``datacenter`` and ``session``.
``datacenter`` contains information about a Telegram datacenter, along with its corresponding
authorization key, and ``session`` contains information about the update state and user.
* The ``entities`` table is now called ``entity`` and stores the ``type`` separatedly.
* The ``update_state`` table is now split into ``session`` and ``channel``, which can contain
a per-channel ``pts``.
Because **the new version does not cache usernames, phone numbers and display names**, using these
in method calls is now quite expensive. You *should* migrate your code to do the Right Thing and
start using identifiers rather than usernames, phone numbers or invite links. This is both simpler
and more reliable, because while a user identifier won't change, their username could.
You can use the following snippet to make a JSON backup (alternatively, you could just copy the
``.session`` file and keep it around) in case you want to preserve the cached usernames:
.. code-block:: python
import sqlite, json
with sqlite3.connect('your.session') as conn, open('entities.json', 'w', encoding='utf-8') as fp:
json.dump([
{'id': id, 'hash': hash, 'username': username, 'phone': phone, 'name': name, 'date': date}
for (id, hash, username, phone, name, date)
in conn.execute('select id, hash, username, phone, name, date from entities')
], fp)
The following public methods or properties have also been removed from ``SQLiteSession`` because
they no longer make sense:
* ``list_sessions``. You can ``glob.glob('*.session')`` instead.
* ``clone``.
And the following, which were inherited from ``MemorySession``:
* ``delete``. You can ``os.remove`` the file instead (preferably after ``client.log_out()``).
``client.log_out()`` also no longer deletes the session file (it can't as there's no method).
* ``set_dc``.
* ``dc_id``.
* ``server_address``.
* ``port``.
* ``auth_key``.
* ``takeout_id``.
* ``get_update_state``.
* ``set_update_state``.
* ``process_entities``.
* ``get_entity_rows_by_phone``.
* ``get_entity_rows_by_username``.
* ``get_entity_rows_by_name``.
* ``get_entity_rows_by_id``.
* ``get_input_entity``.
* ``cache_file``.
* ``get_file``.
You also can no longer set ``client.session.save_entities = False``. The entities must be saved
for the library to work properly. If you still don't want it, you should subclass the session and
override the methods to do nothing.
Complete overhaul of errors
---------------------------
The following error name have changed to follow a better naming convention (clearer acronyms):
* ``RPCError`` is now ``RpcError``.
* ``InvalidDCError`` is now ``InvalidDcError`` (lowercase ``c``).
The base errors no longer have a ``.message`` field at the class-level. Instead, it is now an
attribute at the instance level (meaning you cannot do ``BadRequestError.message``, it must be
``bad_request_err.message`` where ``isinstance(bad_request_err, BadRequestError)``).
The ``.message`` will gain its value at the time the error is constructed, rather than being
known beforehand.
The parameter order for ``RpcError`` and all its subclasses are now ``(code, message, request)``,
as opposed to ``(message, request, code)``.
Because Telegram errors can be added at any time, the library no longer generate a fixed set of
them. This means you can no longer use ``dir`` to get a full list of them. Instead, the errors
are automatically generated depending on the name you use for the error, with the following rules:
* Numbers are removed from the name. The Telegram error ``FLOOD_WAIT_42`` is transformed into
``FLOOD_WAIT_``.
* Underscores are removed from the name. ``FLOOD_WAIT_`` becomes ``FLOODWAIT``.
* Everything is lowercased. ``FLOODWAIT`` turns into ``floodwait``.
* While the name ends with ``error``, this suffix is removed.
The only exception to this rule is ``2FA_CONFIRM_WAIT_0``, which is transformed as
``twofaconfirmwait`` (read as ``TwoFaConfirmWait``).
What all this means is that, if Telegram raises a ``FLOOD_WAIT_42``, you can write the following:
.. code-block:: python
from telethon.errors import FloodWaitError
try:
await client.send_message(chat, message)
except FloodWaitError as e:
print(f'Flood! wait for {e.seconds} seconds')
Essentially, old code will keep working, but now you have the freedom to define even yet-to-be
discovered errors. This makes use of `PEP 562 <https://www.python.org/dev/peps/pep-0562/>`__ on
Python 3.7 and above and a more-hacky approach below (which your IDE may not love).
Given the above rules, you could also write ``except errors.FLOOD_WAIT`` if you prefer to match
Telegram's naming conventions. We recommend Camel-Case naming with the "Error" suffix, but that's
up to you.
All errors will include a list of ``.values`` (the extracted number) and ``.value`` (the first
number extracted, or ``None`` if ``values`` is empty). In addition to that, certain errors have
a more-recognizable alias (such as ``FloodWait`` which has ``.seconds`` for its ``.value``).
The ``telethon.errors`` module continues to provide certain predefined ``RpcError`` to match on
the *code* of the error and not its message (for instance, match all errors with code 403 with
``ForbiddenError``). Note that a certain error message can appear with different codes too, this
is decided by Telegram.
The ``telethon.errors`` module continues to provide custom errors used by the library such as
``TypeNotFoundError``.
// TODO keep RPCError around? eh idk how much it's used
// TODO should RpcError subclass ValueError? technically the values used in the request somehow were wrong…
// TODO provide a way to see which errors are known in the docs or at tl.telethon.dev
Changes to the default parse mode
---------------------------------
The default markdown parse mode now conforms to the commonmark specification.
The old markdown parser (which was used as the default ``client.parse_mode``) used to emulate
Telegram Desktop's behaviour. Now `<markdown-it-py https://github.com/executablebooks/markdown-it-py>`__
is used instead, which fixes certain parsing bugs but also means the formatting will be different.
Most notably, ``__`` will now make text bold. If you want the old behaviour, use a single
underscore instead (such as ``_``). You can also use a single asterisk (``*``) for italics.
Because now there's proper parsing, you also gain:
* Headings (``# text``) will now be underlined.
* Certain HTML tags will now also be recognized in markdown (including ``<u>`` for underlining text).
* Line breaks behave properly now. For a single-line break, end your line with ``\\``.
* Inline links should no longer behave in a strange manner.
* Pre-blocks can now have a language. Official clients don't syntax highlight code yet, though.
Furthermore, the parse mode is no longer client-dependant. It is now configured through ``Message``.
// TODO provide a way to get back the old behaviour?
The "iter" variant of the client methods have been removed
----------------------------------------------------------
Instead, you can now use the result of the ``get_*`` variant. For instance, where before you had:
.. code-block:: python
async for message in client.iter_messages(...):
pass
You would now do:
.. code-block:: python
async for message in client.get_messages(...):
pass # ^^^ now it's get, not iter
You can still use ``await`` on the ``get_`` methods to retrieve the list.
The removed methods are:
* iter_messages
* iter_dialogs
* iter_participants
* iter_admin_log
* iter_profile_photos
* iter_drafts
The only exception to this rule is ``iter_download``.
Additionally, when using ``await``, if the method was called with a limit of 1 (either through
setting just one value to fetch, or setting the limit to one), either ``None`` or a single item
(outside of a ``list``) will be returned. This used to be the case only for ``get_messages``,
but now all methods behave in the same way for consistency.
When using ``async for``, the default limit will be ``None``, meaning all items will be fetched.
When using ``await``, the default limit will be ``1``, meaning the latest item will be fetched.
If you want to use ``await`` but still get a list, use the ``.collect()`` method to collect the
results into a list:
.. code-block:: python
chat = ...
# will iterate over all (default limit=None)
async for message in client.get_messages(chat):
...
# will return either a single Message or None if there is not any (limit=1)
message = await client.get_messages(chat)
# will collect all messages into a list (default limit=None). will also take long!
all_messages = await client.get_messages(chat).collect()
// TODO keep providing the old ``iter_`` versions? it doesn't really hurt, even if the recommended way changed
// TODO does the download really need to be special? get download is kind of weird though
Raw API has been renamed and is now immutable and considered private
--------------------------------------------------------------------
The subpackage holding the raw API methods has been renamed from ``tl`` to ``_tl`` in order to
signal that these are prone to change across minor version bumps (the ``y`` in version ``x.y.z``).
Because in Python "we're all adults", you *can* use this private module if you need to. However,
you *are* also acknowledging that this is a private module prone to change (and indeed, it will
change on layer upgrades across minor version bumps).
The ``Request`` suffix has been removed from the classes inside ``tl.functions``.
The ``tl.types`` is now simply ``_tl``, and the ``tl.functions`` is now ``_tl.fn``.
Both the raw API types and functions are now immutable. This can enable optimizations in the
future, such as greatly reducing the number of intermediate objects created (something worth
doing for deeply-nested objects).
Some examples:
.. code-block:: python
# Before
from telethon.tl import types, functions
await client(functions.messages.SendMessageRequest(...))
message: types.Message = ...
# After
from telethon import _tl
await client(_tl.fn.messages.SendMessage(...))
message: _tl.Message
This serves multiple goals:
* It removes redundant parts from the names. The "recommended" way of using the raw API is through
the subpackage namespace, which already contains a mention to "functions" in it. In addition,
some requests were awkward, such as ``SendCustomRequestRequest``.
* It makes it easier to search for code that is using the raw API, so that you can quickly
identify which parts are making use of it.
* The name is shorter, but remains recognizable.
Because *a lot* of these objects are created, they now define ``__slots__``. This means you can
no longer monkey-patch them to add new attributes at runtime. You have to create a subclass if you
want to define new attributes.
This also means that the updates from ``events.Raw`` **no longer have** ``update._entities``.
``tlobject.to_dict()`` has changed and is now generated dynamically based on the ``__slots__`.
This may incur a small performance hit (but you shouldn't really be using ``.to_dict()`` when
you can just use attribute access and ``getattr``). In general, this should handle ill-defined
objects more gracefully (for instance, those where you're using a ``tuple`` and not a ``list``
or using a list somewhere it shouldn't be), and have no other observable effects. As an extra
benefit, this slightly cuts down on the amount of bloat.
In ``tlobject.to_dict()``, the special ``_`` key is now also contains the module (so you can
actually distinguish between equally-named classes). If you want the old behaviour, use
``tlobject.__class__.__name__` instead (and add ``Request`` for functions).
Because the string representation of an object used ``tlobject.to_dict()``, it is now also
affected by these changes.
// TODO this definitely generated files mapping from the original name to this new one...
// TODO what's the alternative to update._entities? and update._client??
Many subpackages and modules are now private
--------------------------------------------
There were a lot of things which were public but should not have been. From now on, you should
only rely on things that are either publicly re-exported or defined. That is, as soon as anything
starts with an underscore (``_``) on its name, you're acknowledging that the functionality may
change even across minor version changes, and thus have your code break.
The following subpackages are now considered private:
* ``client`` is now ``_client``.
* ``crypto`` is now ``_crypto``.
* ``extensions`` is now ``_misc``.
* ``tl`` is now ``_tl``.
The following modules have been moved inside ``_misc``:
* ``entitycache.py``
* ``helpers.py``
* ``hints.py``
* ``password.py``
* ``requestiter.py``
* ``statecache.py``
* ``utils.py``
// TODO review telethon/__init__.py isn't exposing more than it should
Using the client in a context-manager no longer calls start automatically
-------------------------------------------------------------------------
The following code no longer automatically calls ``client.start()``:
.. code-block:: python
async with TelegramClient(...) as client:
...
# or
async with client:
...
This means the context-manager will only call ``client.connect()`` and ``client.disconnect()``.
The rationale for this change is that it could be strange for this to ask for the login code if
the session ever was invalid. If you want the old behaviour, you now need to be explicit:
.. code-block:: python
async with TelegramClient(...).start() as client:
... # ++++++++
Note that you do not need to ``await`` the call to ``.start()`` if you are going to use the result
in a context-manager (but it's okay if you put the ``await``).
Changes to sending messages and files
-------------------------------------
When sending messages or files, there is no longer a parse mode. Instead, the ``markdown`` or
``html`` parameters can be used instead of the (plaintext) ``message``.
.. code-block:: python
await client.send_message(chat, 'Default formatting (_markdown_)')
await client.send_message(chat, html='Force <em>HTML</em> formatting')
await client.send_message(chat, markdown='Force **Markdown** formatting')
These 3 parameters are exclusive with each other (you can only use one). The goal here is to make
it consistent with the custom ``Message`` class, which also offers ``.markdown`` and ``.html``
properties to obtain the correctly-formatted text, regardless of the default parse mode, and to
get rid of some implicit behaviour. It's also more convenient to set just one parameter than two
(the message and the parse mode separatedly).
Although the goal is to reduce raw API exposure, ``formatting_entities`` stays, because it's the
only feasible way to manually specify them.
When sending files, you can no longer pass a list of attributes. This was a common workaround to
set video size, audio duration, and so on. Now, proper parameters are available. The goal is to
hide raw API as much as possible (which lets the library hide future breaking changes as much as
possible). One can still use raw API if really needed.
Several methods have been removed from the client
-------------------------------------------------
``client.download_file`` has been removed. Instead, ``client.download_media`` should be used.
The now-removed ``client.download_file`` method was a lower level implementation which should
have not been exposed at all.
``client.build_reply_markup`` has been removed. Manually calling this method was purely an
optimization (the buttons won't need to be transformed into a reply markup every time they're
used). This means you can just remove any calls to this method and things will continue to work.
Support for bot-API style file_id has been removed
--------------------------------------------------
They have been half-broken for a while now, so this is just making an existing reality official.
See `issue #1613 <https://github.com/LonamiWebs/Telethon/issues/1613>`__ for details.
An alternative solution to re-use files may be provided in the future. For the time being, you
should either upload the file as needed, or keep a message with the media somewhere you can
later fetch it (by storing the chat and message identifier).
Additionally, the ``custom.File.id`` property is gone (which used to provide access to this
"bot-API style" file identifier.
// TODO could probably provide an in-memory cache for uploads to temporarily reuse old InputFile.
// this should lessen the impact of the removal of this feature
Removal of several utility methods
----------------------------------
The following ``utils`` methods no longer exist or have been made private:
* ``utils.resolve_bot_file_id``. It was half-broken.
* ``utils.pack_bot_file_id``. It was half-broken.
* ``utils.resolve_invite_link``. It has been broken for a while, so this just makes its removal
official (see `issue #1723 <https://github.com/LonamiWebs/Telethon/issues/1723>`__).
* ``utils.resolve_id``. Marked IDs are no longer used thorough the library. The removal of this
method also means ``utils.get_peer`` can no longer get a ``Peer`` from just a number, as the
type is no longer embedded inside the ID.
// TODO provide the new clean utils
Changes to many friendly methods in the client
----------------------------------------------
Some of the parameters used to initialize the ``TelegramClient`` have been renamed to be clearer:
* ``timeout`` is now ``connect_timeout``.
* ``connection_retries`` is now ``connect_retries``.
* ``retry_delay`` is now ``connect_retry_delay``.
* ``raise_last_call_error`` has been removed and is now the default. This means you won't get a
``ValueError`` if an API call fails multiple times, but rather the original error.
* ``connection`` to change the connection mode has been removed for the time being.
* ``sequential_updates`` has been removed for the time being.
// TODO document new parameters too
``client.send_code_request`` no longer has ``force_sms`` (it was broken and was never reliable).
``client.send_read_acknowledge`` is now ``client.mark_read``, consistent with the method of
``Message``, being shorter and less awkward to type. The method now only supports a single
message, not a list (the list was a lie, because all messages up to the one with the highest
ID were marked as read, meaning one could not leave unread gaps). ``max_id`` is now removed,
since it has the same meaning as the message to mark as read. The method no longer can clear
mentions without marking the chat as read, but this should not be an issue in practice.
Every ``client.action`` can now be directly ``await``-ed, not just ``'cancel'``.
``client.forward_messages`` now requires a list to be specified. The intention is to make it clear
that the method forwards message\ **s** and to reduce the number of strange allowed values, which
needlessly complicate the code. If you still need to forward a single message, manually construct
a list with ``[message]`` or use ``Message.forward_to``.
``client.delete_messages`` now requires a list to be specified, with the same rationale as forward.
``client.get_me`` no longer has an ``input_peer`` parameter. The goal is to hide raw API as much
as possible. Input peers are mostly an implementation detail the library needs to deal with
Telegram's API.
Before, ``client.iter_participants`` (and ``get_participants``) would expect a type or instance
of the raw Telegram definition as a ``filter``. Now, this ``filter`` expects a string.
The supported values are:
* ``'admin'``
* ``'bot'``
* ``'kicked'``
* ``'banned'``
* ``'contact'``
If you prefer to avoid hardcoding strings, you may use ``telethon.enums.Participant``.
The size selector for ``client.download_profile_photo`` and ``client.download_media`` is now using
an enumeration:
.. code-block:: python
from telethon import enums
await client.download_profile_photo(user, thumb=enums.Size.ORIGINAL)
This new selection mode is also smart enough to pick the "next best" size if the specified one
is not available. The parameter is known as ``thumb`` and not ``size`` because documents don't
have a "size", they have thumbnails of different size. For profile photos, the thumbnail size is
also used.
// TODO maintain support for the old way of doing it?
// TODO now that there's a custom filter, filter client-side for small chats?
The custom.Message class and the way it is used has changed
-----------------------------------------------------------
It no longer inherits ``TLObject``, and rather than trying to mimick Telegram's ``Message``
constructor, it now takes two parameters: a ``TelegramClient`` instance and a ``_tl.Message``.
As a benefit, you can now more easily reconstruct instances of this type from a previously-stored
``_tl.Message`` instance.
There are no public attributes. Instead, they are now properties which forward the values into and
from the private ``_message`` field. As a benefit, the documentation will now be easier to follow.
However, you can no longer use ``del`` on these.
The ``_tl.Message.media`` attribute will no longer be ``None`` when using raw API if the media was
``messageMediaEmpty``. As a benefit, you can now actually distinguish between no media and empty
media. The ``Message.media`` property as returned by friendly methods will still be ``None`` on
empty media.
The ``telethon.tl.patched`` hack has been removed.
The message sender no longer is the channel when no sender is provided by Telegram. Telethon used
to patch this value for channels to be the same as the chat, but now it will be faithful to
Telegram's value.
Overhaul of users and chats are no longer raw API types
-------------------------------------------------------
Users and chats are no longer raw API types. The goal is to reduce the amount of raw API exposed
to the user, and to provide less confusing naming. This also means that **the sender and chat of
messages and events is now a different type**. If you were using `isinstance` to check the types,
you will need to update that code. However, if you were accessing things like the ``first_name``
or ``username``, you will be fine.
Raw API is not affected by this change. When using it, the raw :tl:`User`, :tl:`Chat` and
:tl:`Channel` are still returned.
For friendly methods and events, There are now two main entity types, `User` and `Chat`.
`User`\ s are active entities which can send messages and interact with eachother. There is an
account controlling them. `Chat`\ s are passive entities where multiple users can join and
interact with each other. This includes small groups, supergroups, and broadcast channels.
``event.get_sender``, ``event.sender``, ``event.get_chat``, and ``event.chat`` (as well as
the same methods on ``message`` and elsewhere) now return this new type. The ``sender`` and
``chat`` is **now always returned** (where it makes sense, so no sender in channel messages),
even if Telegram did not include information about it in the update. This means you can use
send messages to ``event.chat`` without worrying if Telegram included this information or not,
or even access ``event.chat.id``. This was often a papercut. However, if you need other
information like the title, you might still need to use ``await event.get_chat()``, which is
used to signify an API call might be necessary.
``event.get_input_sender``, ``event.input_sender``, ``message.get_input_sender`` and
``message.input_sender`` (among other variations) have been removed. Instead, a new ``compact``
method has been added to the new `User` and `Chat` types, which can be used to obtain a compact
representation of the sender. The "input" terminology is confusing for end-users, as it's mostly
an implementation detail of friendly methods. Because the return type would've been different
had these methods been kept, one would have had to review code using them regardless.
What this means is that, if you now want a compact way to store a user or chat for later use,
you should use ``compact``:
.. code-block:: python
compacted_user = message.sender.compact()
# store compacted_user in a database or elsewhere for later use
Public methods accept this type as input parameters. This means you can send messages to a
compacted user or chat, for example.
``event.is_private``, ``event.is_group`` and ``event.is_channel`` have **been removed** (among
other variations, such as in ``message``). It didn't make much sense to ask "is this event a
group", and there is no such thing as "group messages" currently either. Instead, it's sensible
to ask if the sender of a message is a group, or the chat of an event is a channel. New properties
have been added to both the `User` and `Chat` classes:
* ``.is_user`` will always be `True` for `User` and `False` for `Chat`.
* ``.is_group`` will be `False` for `User` and be `True` for small group chats and supergroups.
* ``.is_broadcast`` will be `False` for `User` and `True` for broadcast channels and broadcast groups.
Because the properties exist both in `User` and `Chat`, you do not need use `isinstance` to check
if a sender is a channel or if a chat is a user.
Some fields of the new `User` type differ from the naming or value type of its raw API counterpart:
* ``user.restriction_reason`` has been renamed to ``restriction_reasons`` (with a trailing **s**)
and now always returns a list.
* ``user.bot_chat_history`` has been renamed to ``user.bot_info.chat_history_access``.
* ``user.bot_nochats`` has been renamed to ``user.bot_info.private_only``.
* ``user.bot_inline_geo`` has been renamed to ``user.bot_info.inline_geo``.
* ``user.bot_info_version`` has been renamed to ``user.bot_info.version``.
* ``user.bot_inline_placeholder`` has been renamed to ``user.bot_info.inline_placeholder``.
The new ``user.bot_info`` field will be `None` for non-bots. The goal is to unify where this
information is found and reduce clutter in the main ``user`` type.
Some fields of the new `Chat` type differ from the naming or value type of its raw API counterpart:
* ``chat.date`` is currently not available. It's either the chat creation or join date, but due
to this inconsistency, it's not included to allow for a better solution in the future.
* ``chat.has_link`` is currently not available, to allow for a better alternative in the future.
* ``chat.has_geo`` is currently not available, to allow for a better alternative in the future.
* ``chat.call_active`` is currently not available, until it's decided what to do about calls.
* ``chat.call_not_empty`` is currently not available, until it's decided what to do about calls.
* ``chat.version`` was removed. It's an implementation detail.
* ``chat.min`` was removed. It's an implementation detail.
* ``chat.deactivated`` was removed. It's redundant with ``chat.migrated_to``.
* ``chat.forbidden`` has been added as a replacement for ``isinstance(chat, (ChatForbidden, ChannelForbidden))``.
* ``chat.forbidden_until`` has been added as a replacement for ``until_date`` in forbidden chats.
* ``chat.restriction_reason`` has been renamed to ``restriction_reasons`` (with a trailing **s**)
and now always returns a list.
* ``chat.migrated_to`` no longer returns a raw type, and instead returns this new `Chat` type.
If you have a need for these, please step in, and explain your use case, so we can work together
to implement a proper design.
Both the new `User` and `Chat` types offer a ``fetch`` method, which can be used to refetch the
instance with fresh information, including the full information about the user (such as the user's
biography or a chat's about description).
Using a flat list to define buttons will now create rows and not columns
------------------------------------------------------------------------
When sending a message with buttons under a bot account, passing a flat list such as the following:
.. code-block:: python
bot.send_message(chat, message, buttons=[
Button.inline('top'),
Button.inline('middle'),
Button.inline('bottom'),
])
Will now send a message with 3 rows of buttons, instead of a message with 3 columns (old behaviour).
If you still want the old behaviour, wrap the list inside another list:
.. code-block:: python
bot.send_message(chat, message, buttons=[[
# +
Button.inline('left'),
Button.inline('center'),
Button.inline('right'),
]])
#+
Changes to the string and to_dict representation
------------------------------------------------
The string representation of raw API objects will now have its "printing depth" limited, meaning
very large and nested objects will be easier to read.
If you want to see the full object's representation, you should instead use Python's builtin
``repr`` method.
The ``.stringify`` method remains unchanged.
Here's a comparison table for a convenient overview:
+-------------------+---------------------------------------------+---------------------------------------------+
| | Telethon v1.x | Telethon v2.x |
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
| | ``__str__`` | ``__repr__`` | ``.stringify`` | ``__str__`` | ``__repr__`` | ``.stringify`` |
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
| Useful? | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
| Multiline? | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
| Shows everything? | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
Both of the string representations may still change in the future without warning, as Telegram
adds, changes or removes fields. It should only be used for debugging. If you need a persistent
string representation, it is your job to decide which fields you care about and their format.
The ``Message`` representation now contains different properties, which should be more useful and
less confusing.
Changes on how to configure a different connection mode
-------------------------------------------------------
The ``connection`` parameter of the ``TelegramClient`` now expects a string, and not a type.
The supported values are:
* ``'full'``
* ``'intermediate'``
* ``'abridged'``
* ``'obfuscated'``
* ``'http'``
The value chosen by the library is left as an implementation detail which may change. However,
you can force a certain mode by explicitly configuring it. If you don't want to hardcode the
string, you can import these values from the new ``telethon.enums`` module:
.. code-block:: python
client = TelegramClient(..., connection='tcp')
# or
from telethon.enums import ConnectionMode
client = TelegramClient(..., connection=ConnectionMode.TCP)
You may have noticed there's currently no alternative for ``TcpMTProxy``. This mode has been
broken for some time now (see `issue #1319 <https://github.com/LonamiWebs/Telethon/issues/1319>`__)
anyway, so until there's a working solution, the mode is not supported. Pull Requests are welcome!
The to_json method on objects has been removed
----------------------------------------------
This was not very useful, as most of the time, you'll probably be having other data along with the
object's JSON. It simply saved you an import (and not even always, in case you wanted another
encoder). Use ``json.dumps(obj.to_dict())`` instead.
The Conversation API has been removed
-------------------------------------
This API had certain shortcomings, such as lacking persistence, poor interaction with other event
handlers, and overcomplicated usage for anything beyond the simplest case.
It is not difficult to write your own code to deal with a conversation's state. A simple
`Finite State Machine <https://stackoverflow.com/a/62246569/>`__ inside your handlers will do
just fine This approach can also be easily persisted, and you can adjust it to your needs and
your handlers much more easily.
// TODO provide standalone alternative for this?
Certain client properties and methods are now private or no longer exist
------------------------------------------------------------------------
The ``client.loop`` property has been removed. ``asyncio`` has been moving towards implicit loops,
so this is the next step. Async methods can be launched with the much simpler ``asyncio.run`` (as
opposed to the old ``client.loop.run_until_complete``).
The ``client.upload_file`` method has been removed. It's a low-level method users should not need
to use. Its only purpose could have been to implement a cache of sorts, but this is something the
library needs to do, not the users.
The methods to deal with folders have been removed. The goal is to find and offer a better
interface to deal with both folders and archived chats in the future if there is demand for it.
This includes the removal of ``client.edit_folder``, ``Dialog.archive``, ``Dialog.archived``, and
the ``archived`` parameter of ``client.get_dialogs``. The ``folder`` parameter remains as it's
unlikely to change.
Deleting messages now returns a more useful value
-------------------------------------------------
It used to return a list of :tl:`messages.affectedMessages` which I expect very little people were
actually using. Now it returns an ``int`` value indicating the number of messages that did exist
and were deleted.
Changes to the methods to retrieve participants
-----------------------------------------------
The "aggressive" hack in ``get_participants`` (and ``iter_participants``) is now gone.
It was not reliable, and was a cause of flood wait errors.
The ``search`` parameter is no longer ignored when ``filter`` is specified.
The total value when getting participants has changed
-----------------------------------------------------
Before, it used to always be the total amount of people inside the chat. Now the filter is also
considered. If you were running ``client.get_participants`` with a ``filter`` other than the
default and accessing the ``list.total``, you will now get a different result. You will need to
perform a separate request with no filter to fetch the total without filter (this is what the
library used to do).
Changes to editing messages
---------------------------
Before, calling ``message.edit()`` would completely ignore your attempt to edit a message if the
message had a forward header or was not outgoing. This is no longer the case. It is now the user's
responsibility to check for this.
However, most likely, you were already doing the right thing (or else you would've experienced a
"why is this not being edited", which you would most likely consider a bug rather than a feature).
When using ``client.edit_message``, you now must always specify the chat and the message (or
message identifier). This should be less "magic". As an example, if you were doing this before:
.. code-block:: python
await client.edit_message(message, 'new text')
You now have to do the following:
.. code-block:: python
await client.edit_message(message.input_chat, message.id, 'new text')
# or
await message.edit('new text')
Signing in no longer sends the code
-----------------------------------
``client.sign_in()`` used to run ``client.send_code_request()`` if you only provided the phone and
not the code. It no longer does this. If you need that convenience, use ``client.start()`` instead.
The client.disconnected property has been removed
-------------------------------------------------
``client.run_until_disconnected()`` should be used instead.
The TelegramClient is no longer made out of mixins
--------------------------------------------------
If you were relying on any of the individual mixins that made up the client, such as
``UserMethods`` inside the ``telethon.client`` subpackage, those are now gone.
There is a single ``TelegramClient`` class now, containing everything you need.
The takeout context-manager has changed
---------------------------------------
It no longer has a finalize. All the requests made by the client in the same task will be wrapped,
not only those made through the proxy client returned by the context-manager.
This cleans up the (rather hacky) implementation, making use of Python's ``contextvar``. If you
still need the takeout session to persist, you should manually use the ``begin_takeout`` and
``end_takeout`` method.
If you want to ignore the currently-active takeout session in a task, toggle the following context
variable:
.. code-block:: python
telethon.ignore_takeout.set(True)
CdnDecrypter has been removed
-----------------------------
It was not really working and was more intended to be an implementation detail than anything else.
URL buttons no longer open the web-browser
------------------------------------------
Now the URL is returned. You can still use ``webbrowser.open`` to get the old behaviour.
---
todo update send_message and send_file docs (well review all functions)
album overhaul. use a list of Message instead.
is_connected is now a property (consistent with the rest of ``is_`` properties)
send_code_request now returns a custom type (reducing raw api).
sign_in no longer has phone or phone_hash (these are impl details, and now it's less error prone). also mandatory code=. also no longer is a no-op if already logged in. different error for sign up required.
send code / sign in now only expect a single phone. resend code with new phone is send code, not resend.
sign_up code is also now a kwarg. and no longer noop if already loggedin.
start also mandates phone= or password= as kwarg.
qrlogin expires has been replaced with timeout and expired for parity with tos and auth. the goal is to hide the error-prone system clock and instead use asyncio's clock. recreate was removed (just call qr_login again; parity with get_tos). class renamed to QrLogin. now must be used in a contextmgr to prevent misuse.
"entity" parameters have been renamed to "dialog" (user or chat expected) or "chat" (only chats expected), "profile" (if that makes sense). the goal is to move away from the entity terminology. this is intended to be a documentation change, but because the parameters were renamed, it's breaking. the expected usage of positional arguments is mostly unaffected. this includes the EntityLike hint.
download_media param renamed message to media. iter_download file to media too
less types are supported to get entity (exact names, private links are undocumented but may work). get_entity is get_profile. get_input_entity is gone. get_peer_id is gone (if the isntance needs to be fetched anyway just use get_profile).

View File

@ -20,10 +20,10 @@ Each mixin has its own methods, which you all can use.
async def main():
# Now you can use all client methods listed below, like for example...
async with client.start():
await client.send_message('me', 'Hello to myself!')
await client.send_message('me', 'Hello to myself!')
asyncio.run(main())
with client:
client.loop.run_until_complete(main())
You **don't** need to import these `AuthMethods`, `MessageMethods`, etc.

View File

@ -46,6 +46,15 @@ ChatGetter
:show-inheritance:
Conversation
============
.. automodule:: telethon.tl.custom.conversation
:members:
:undoc-members:
:show-inheritance:
Dialog
======
@ -136,7 +145,7 @@ ParticipantPermissions
:show-inheritance:
QrLogin
QRLogin
=======
.. automodule:: telethon.tl.custom.qrlogin

View File

@ -32,7 +32,6 @@ Auth
send_code_request
sign_in
qr_login
sign_up
log_out
edit_2fa
@ -103,9 +102,11 @@ Dialogs
iter_dialogs
get_dialogs
edit_folder
iter_drafts
get_drafts
delete_dialog
conversation
Users
-----
@ -118,7 +119,9 @@ Users
get_me
is_bot
is_user_authorized
get_profile
get_entity
get_input_entity
get_peer_id
Chats
-----

View File

@ -20,7 +20,7 @@ To enable logging, at the following code to the top of your main file:
.. code-block:: python
import logging
logging.basicConfig(format='[%(levelname) 5s/%(asctime)s] %(name)s: %(message)s',
logging.basicConfig(format='[%(levelname) %(asctime)s] %(name)s: %(message)s',
level=logging.WARNING)
You can change the logging level to be something different, from less to more information:
@ -60,6 +60,16 @@ And except them as such:
My account was deleted/limited when using the library
=====================================================
First and foremost, **this is not a problem exclusive to Telethon.
Any third-party library is prone to cause the accounts to appear banned.**
Even official applications can make Telegram ban an account under certain
circumstances. Third-party libraries such as Telethon are a lot easier to
use, and as such, they are misused to spam, which causes Telegram to learn
certain patterns and ban suspicious activity.
There is no point in Telethon trying to circumvent this. Even if it succeeded,
spammers would then abuse the library again, and the cycle would repeat.
The library will only do things that you tell it to do. If you use
the library with bad intentions, Telegram will hopefully ban you.
@ -67,8 +77,7 @@ However, you may also be part of a limited country, such as Iran or Russia.
In that case, we have bad news for you. Telegram is much more likely to ban
these numbers, as they are often used to spam other accounts, likely through
the use of libraries like this one. The best advice we can give you is to not
abuse the API, like calling many requests really quickly, and to sign up with
these phones through an official application.
abuse the API, like calling many requests really quickly.
We have also had reports from Kazakhstan and China, where connecting
would fail. To solve these connection problems, you should use a proxy.
@ -76,6 +85,16 @@ would fail. To solve these connection problems, you should use a proxy.
Telegram may also ban virtual (VoIP) phone numbers,
as again, they're likely to be used for spam.
More recently (year 2023 onwards), Telegram has started putting a lot more
measures to prevent spam (with even additions such as anonymous participants
in groups or the inability to fetch group members at all). This means some
of the anti-spam measures have gotten more aggressive.
The recommendation has usually been to use the library only on well-established
accounts (and not an account you just created), and to not perform actions that
could be seen as abuse. Telegram decides what those actions are, and they're
free to change how they operate at any time.
If you want to check if your account has been limited,
simply send a private message to `@SpamBot`_ through Telegram itself.
You should notice this by getting errors like ``PeerFloodError``,
@ -127,7 +146,14 @@ This is basic Python knowledge. You should use the dot operator:
AttributeError: 'coroutine' object has no attribute 'id'
========================================================
Telethon is an asynchronous library. This means you need to ``await`` most methods:
You either forgot to:
.. code-block:: python
import telethon.sync
# ^^^^^ import sync
Or:
.. code-block:: python
@ -172,6 +198,137 @@ won't do unnecessary work unless you need to:
sender = await event.get_sender()
File download is slow or sending files takes too long
=====================================================
The communication with Telegram is encrypted. Encryption requires a lot of
math, and doing it in pure Python is very slow. ``cryptg`` is a library which
containns the encryption functions used by Telethon. If it is installed (via
``pip install cryptg``), it will automatically be used and should provide
a considerable speed boost. You can know whether it's used by configuring
``logging`` (at ``INFO`` level or lower) *before* importing ``telethon``.
Note that the library does *not* download or upload files in parallel, which
can also help with the speed of downloading or uploading a single file. There
are snippets online implementing that. The reason why this is not built-in
is because the limiting factor in the long run are ``FloodWaitError``, and
using parallel download or uploads only makes them occur sooner.
What does "Server sent a very new message with ID" mean?
========================================================
You may also see this error as "Server sent a very old message with ID".
This is a security feature from Telethon that cannot be disabled and is
meant to protect you against replay attacks.
When this message is incorrectly reported as a "bug",
the most common patterns seem to be:
* Your system time is incorrect.
* The proxy you're using may be interfering somehow.
* The Telethon session is being used or has been used from somewhere else.
Make sure that you created the session from Telethon, and are not using the
same session anywhere else. If you need to use the same account from
multiple places, login and use a different session for each place you need.
What does "Server replied with a wrong session ID" mean?
========================================================
This is a security feature from Telethon that cannot be disabled and is
meant to protect you against unwanted session reuse.
When this message is reported as a "bug", the most common patterns seem to be:
* The proxy you're using may be interfering somehow.
* The Telethon session is being used or has been used from somewhere else.
Make sure that you created the session from Telethon, and are not using the
same session anywhere else. If you need to use the same account from
multiple places, login and use a different session for each place you need.
* You may be using multiple connections to the Telegram server, which seems
to confuse Telegram.
Most of the time it should be safe to ignore this warning. If the library
still doesn't behave correctly, make sure to check if any of the above bullet
points applies in your case and try to work around it.
If the issue persists and there is a way to reliably reproduce this error,
please add a comment with any additional details you can provide to
`issue 3759`_, and perhaps some additional investigation can be done
(but it's unlikely, as Telegram *is* sending unexpected data).
What does "Could not find a matching Constructor ID for the TLObject" mean?
===========================================================================
Telegram uses "layers", which you can think of as "versions" of the API they
offer. When Telethon reads responses that the Telegram servers send, these
need to be deserialized (into what Telethon calls "TLObjects").
Every Telethon version understands a single Telegram layer. When Telethon
connects to Telegram, both agree on the layer to use. If the layers don't
match, Telegram may send certain objects which Telethon no longer understands.
When this message is reported as a "bug", the most common patterns seem to be
that the Telethon session is being used or has been used from somewhere else.
Make sure that you created the session from Telethon, and are not using the
same session anywhere else. If you need to use the same account from
multiple places, login and use a different session for each place you need.
What does "Task was destroyed but it is pending" mean?
======================================================
Your script likely finished abruptly, the ``asyncio`` event loop got
destroyed, and the library did not get a chance to properly close the
connection and close the session.
Make sure you're either using the context manager for the client or always
call ``await client.disconnect()`` (by e.g. using a ``try/finally``).
What does "The asyncio event loop must not change after connection" mean?
=========================================================================
Telethon uses ``asyncio``, and makes use of things like tasks and queues
internally to manage the connection to the server and match responses to the
requests you make. Most of them are initialized after the client is connected.
For example, if the library expects a result to a request made in loop A, but
you attempt to get that result in loop B, you will very likely find a deadlock.
To avoid a deadlock, the library checks to make sure the loop in use is the
same as the one used to initialize everything, and if not, it throws an error.
The most common cause is ``asyncio.run``, since it creates a new event loop.
If you ``asyncio.run`` a function to create the client and set it up, and then
you ``asyncio.run`` another function to do work, things won't work, so the
library throws an error early to let you know something is wrong.
Instead, it's often a good idea to have a single ``async def main`` and simply
``asyncio.run()`` it and do all the work there. From it, you're also able to
call other ``async def`` without having to touch ``asyncio.run`` again:
.. code-block:: python
# It's fine to create the client outside as long as you don't connect
client = TelegramClient(...)
async def main():
# Now the client will connect, so the loop must not change from now on.
# But as long as you do all the work inside main, including calling
# other async functions, things will work.
async with client:
....
if __name__ == '__main__':
asyncio.run(main())
Be sure to read the ``asyncio`` documentation if you want a better
understanding of event loop, tasks, and what functions you can use.
What does "bases ChatGetter" mean?
==================================
@ -197,6 +354,36 @@ Telegram has a lot to offer, and inheritance helps the library reduce
boilerplate, so it's important to know this concept. For newcomers,
this may be a problem, so we explain what it means here in the FAQ.
Can I send files by ID?
=======================
When people talk about IDs, they often refer to one of two things:
the integer ID inside media, and a random-looking long string.
You cannot use the integer ID to send media. Generally speaking, sending media
requires a combination of ID, ``access_hash`` and ``file_reference``.
The first two are integers, while the last one is a random ``bytes`` sequence.
* The integer ``id`` will always be the same for every account, so every user
or bot looking at a particular media file, will see a consistent ID.
* The ``access_hash`` will always be the same for a given account, but
different accounts will each see their own, different ``access_hash``.
This makes it impossible to get media object from one account and use it in
another. The other account must fetch the media object itself.
* The ``file_reference`` is random for everyone and will only work for a few
hours before it expires. It must be refetched before the media can be used
(to either resend the media or download it).
The second type of "`file ID <https://core.telegram.org/bots/api#inputfile>`_"
people refer to is a concept from the HTTP Bot API. It's a custom format which
encodes enough information to use the media.
Telethon provides an old version of these HTTP Bot API-style file IDs via
``message.file.id``, however, this feature is no longer maintained, so it may
not work. It will be removed in future versions. Nonetheless, it is possible
to find a different Python package (or write your own) to parse these file IDs
and construct the necessary input file objects to send or download the media.
Can I use Flask with the library?
=================================
@ -211,7 +398,19 @@ Check out `quart_login.py`_ for an example web-application based on Quart.
Can I use Anaconda/Spyder/IPython with the library?
===================================================
Yes, but these interpreters run the asyncio event loop implicitly, so be wary of that.
Yes, but these interpreters run the asyncio event loop implicitly,
which interferes with the ``telethon.sync`` magic module.
If you use them, you should **not** import ``sync``:
.. code-block:: python
# Change any of these...:
from telethon import TelegramClient, sync, ...
from telethon.sync import TelegramClient, ...
# ...with this:
from telethon import TelegramClient, ...
You are also more likely to get "sqlite3.OperationalError: database is locked"
with them. If they cause too much trouble, just write your code in a ``.py``
@ -220,4 +419,5 @@ file and run that, or use the normal ``python`` interpreter.
.. _logging: https://docs.python.org/3/library/logging.html
.. _@SpamBot: https://t.me/SpamBot
.. _issue 297: https://github.com/LonamiWebs/Telethon/issues/297
.. _quart_login.py: https://github.com/LonamiWebs/Telethon/tree/master/telethon_examples#quart_loginpy
.. _issue 3759: https://github.com/LonamiWebs/Telethon/issues/3759
.. _quart_login.py: https://github.com/LonamiWebs/Telethon/tree/v1/telethon_examples#quart_loginpy

View File

@ -155,12 +155,39 @@ its name, bot-API style file ID, etc.
sticker_set
Conversation
============
The `Conversation <telethon.tl.custom.conversation.Conversation>` object
is returned by the `client.conversation()
<telethon.client.dialogs.DialogMethods.conversation>` method to easily
send and receive responses like a normal conversation.
It bases `ChatGetter <telethon.tl.custom.chatgetter.ChatGetter>`.
.. currentmodule:: telethon.tl.custom.conversation.Conversation
.. autosummary::
:nosignatures:
send_message
send_file
mark_read
get_response
get_reply
get_edit
wait_read
wait_event
cancel
cancel_all
AdminLogEvent
=============
The `AdminLogEvent <telethon.tl.custom.adminlogevent.AdminLogEvent>` object
is returned by the `client.get_admin_log()
<telethon.client.chats.ChatMethods.get_admin_log>` method to easily iterate
is returned by the `client.iter_admin_log()
<telethon.client.chats.ChatMethods.iter_admin_log>` method to easily iterate
over past "events" (deleted messages, edits, title changes, leaving members…)
These are all the properties you can find in it:
@ -270,7 +297,7 @@ Dialog
======
The `Dialog <telethon.tl.custom.dialog.Dialog>` object is returned when
you call `client.get_dialogs() <telethon.client.dialogs.DialogMethods.get_dialogs>`.
you call `client.iter_dialogs() <telethon.client.dialogs.DialogMethods.iter_dialogs>`.
.. currentmodule:: telethon.tl.custom.dialog.Dialog
@ -286,7 +313,7 @@ Draft
======
The `Draft <telethon.tl.custom.draft.Draft>` object is returned when
you call `client.get_drafts() <telethon.client.dialogs.DialogMethods.get_drafts>`.
you call `client.iter_drafts() <telethon.client.dialogs.DialogMethods.iter_drafts>`.
.. currentmodule:: telethon.tl.custom.draft.Draft

View File

@ -1 +1,2 @@
telethon
./
sphinx-rtd-theme~=1.3.0

View File

@ -1,3 +1,2 @@
markdown-it-py~=1.1.0
pyaes~=1.6.1
rsa~=4.7.2
pyaes
rsa

View File

@ -16,6 +16,7 @@ import os
import re
import shutil
import sys
import urllib.request
from pathlib import Path
from subprocess import run
@ -43,20 +44,22 @@ class TempWorkDir:
os.chdir(self.original)
API_REF_URL = 'https://tl.telethon.dev/'
GENERATOR_DIR = Path('telethon_generator')
LIBRARY_DIR = Path('telethon')
ERRORS_IN = GENERATOR_DIR / 'data/errors.csv'
ERRORS_OUT = LIBRARY_DIR / 'errors/_generated.py'
ERRORS_OUT = LIBRARY_DIR / 'errors/rpcerrorlist.py'
METHODS_IN = GENERATOR_DIR / 'data/methods.csv'
# Which raw API methods are covered by *friendly* methods in the client?
FRIENDLY_IN = GENERATOR_DIR / 'data/friendly.csv'
TLOBJECT_IN_TLS = [Path(x) for x in sorted(GENERATOR_DIR.glob('data/*.tl'))]
TLOBJECT_OUT = LIBRARY_DIR / '_tl'
TLOBJECT_MOD = 'telethon._tl'
TLOBJECT_IN_TLS = [Path(x) for x in GENERATOR_DIR.glob('data/*.tl')]
TLOBJECT_OUT = LIBRARY_DIR / 'tl'
IMPORT_DEPTH = 2
DOCS_IN_RES = GENERATOR_DIR / 'data/html'
DOCS_OUT = Path('docs')
@ -94,7 +97,7 @@ def generate(which, action='gen'):
if clean:
clean_tlobjects(TLOBJECT_OUT)
else:
generate_tlobjects(tlobjects, layer, TLOBJECT_MOD, TLOBJECT_OUT)
generate_tlobjects(tlobjects, layer, IMPORT_DEPTH, TLOBJECT_OUT)
if 'errors' in which:
which.remove('errors')
@ -155,14 +158,31 @@ def main(argv):
generate(argv[2:], argv[1])
elif len(argv) >= 2 and argv[1] == 'pypi':
# Make sure tl.telethon.dev is up-to-date first
with urllib.request.urlopen(API_REF_URL) as resp:
html = resp.read()
m = re.search(br'layer\s+(\d+)', html)
if not m:
print('Failed to check that the API reference is up to date:', API_REF_URL)
return
from telethon_generator.parsers import find_layer
layer = next(filter(None, map(find_layer, TLOBJECT_IN_TLS)))
published_layer = int(m[1])
if published_layer != layer:
print('Published layer', published_layer, 'does not match current layer', layer, '.')
print('Make sure to update the API reference site first:', API_REF_URL)
return
# (Re)generate the code to make sure we don't push without it
generate(['tl', 'errors'])
# Try importing the telethon module to assert it has no errors
try:
import telethon
except:
except Exception as e:
print('Packaging for PyPi aborted, importing the module failed.')
print(e)
return
remove_dirs = ['__pycache__', 'build', 'dist', 'Telethon.egg-info']
@ -208,7 +228,7 @@ def main(argv):
# See https://stackoverflow.com/a/40300957/4759433
# -> https://www.python.org/dev/peps/pep-0345/#requires-python
# -> http://setuptools.readthedocs.io/en/latest/setuptools.html
python_requires='>=3.7',
python_requires='>=3.5',
# See https://pypi.python.org/pypi?%3Aaction=list_classifiers
classifiers=[
@ -223,10 +243,10 @@ def main(argv):
'License :: OSI Approved :: MIT License',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
],
keywords='telegram api chat client library messaging mtproto',
packages=find_packages(exclude=[

View File

@ -1,11 +1,13 @@
# Note: the import order matters
from ._misc import helpers as _ # no dependencies
from . import _tl # no dependencies
from ._misc import utils as _ # depends on helpers and _tl
from ._misc import hints as _ # depends on types/custom
from ._client.account import ignore_takeout
from ._client.telegramclient import TelegramClient
from . import version, events, errors, enums
from .client.telegramclient import TelegramClient
from .network import connection
from .tl.custom import Button
from .tl import patched as _ # import for its side-effects
from . import version, events, utils, errors, types, functions, custom
__version__ = version.__version__
__all__ = [
'TelegramClient', 'Button',
'types', 'functions', 'custom', 'errors',
'events', 'utils', 'connection'
]

View File

@ -1,4 +0,0 @@
"""
This package defines the main `telethon._client.telegramclient.TelegramClient` instance
which delegates the work to free-standing functions defined in the rest of files.
"""

View File

@ -1,75 +0,0 @@
import functools
import inspect
import typing
import dataclasses
import asyncio
from contextvars import ContextVar
from .._misc import helpers, utils
from .. import _tl
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
ignore_takeout = ContextVar('ignore_takeout', default=False)
# TODO Make use of :tl:`InvokeWithMessagesRange` somehow
# For that, we need to use :tl:`GetSplitRanges` first.
class _Takeout:
def __init__(self, client, kwargs):
self._client = client
self._kwargs = kwargs
async def __aenter__(self):
await self._client.begin_takeout(**self._kwargs)
return self._client
async def __aexit__(self, exc_type, exc_value, traceback):
await self._client.end_takeout(success=exc_type is None)
def takeout(self: 'TelegramClient', **kwargs):
return _Takeout(self, kwargs)
async def begin_takeout(
self: 'TelegramClient',
*,
contacts: bool = None,
users: bool = None,
chats: bool = None,
megagroups: bool = None,
channels: bool = None,
files: bool = None,
max_file_size: bool = None,
) -> 'TelegramClient':
if self.takeout_active:
raise ValueError('a previous takeout session was already active')
takeout = await self(_tl.fn.account.InitTakeoutSession(
contacts=contacts,
message_users=users,
message_chats=chats,
message_megagroups=megagroups,
message_channels=channels,
files=files,
file_max_size=max_file_size
))
await self._replace_session_state(takeout_id=takeout.id)
def takeout_active(self: 'TelegramClient') -> bool:
return self._session_state.takeout_id is not None
async def end_takeout(self: 'TelegramClient', *, success: bool) -> bool:
if not self.takeout_active:
raise ValueError('no previous takeout session was active')
result = await self(_tl.fn.account.FinishTakeoutSession(success))
if not result:
raise ValueError("could not end the active takeout session")
await self._replace_session_state(takeout_id=None)

View File

@ -1,408 +0,0 @@
import asyncio
import getpass
import inspect
import os
import sys
import typing
import warnings
import functools
import time
import dataclasses
from .._misc import utils, helpers, password as pwd_mod
from .. import errors, _tl
from ..types import _custom
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
class StartingClient:
def __init__(self, client, start_fn):
self.client = client
self.start_fn = start_fn
async def __aenter__(self):
await self.start_fn()
return self.client
async def __aexit__(self, *args):
await self.client.__aexit__(*args)
def __await__(self):
return self.__aenter__().__await__()
def start(
self: 'TelegramClient',
phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '),
password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '),
*,
bot_token: str = None,
code_callback: typing.Callable[[], typing.Union[str, int]] = None,
first_name: str = 'New User',
last_name: str = '',
max_attempts: int = 3) -> 'TelegramClient':
if code_callback is None:
def code_callback():
return input('Please enter the code you received: ')
elif not callable(code_callback):
raise ValueError(
'The code_callback parameter needs to be a callable '
'function that returns the code you received by Telegram.'
)
if not phone and not bot_token:
raise ValueError('No phone number or bot token provided.')
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')
return StartingClient(self, functools.partial(_start,
self=self,
phone=phone,
password=password,
bot_token=bot_token,
code_callback=code_callback,
first_name=first_name,
last_name=last_name,
max_attempts=max_attempts
))
async def _start(
self: 'TelegramClient', phone, password, bot_token,
code_callback, first_name, last_name, max_attempts):
if not self.is_connected:
await self.connect()
# Rather than using `is_user_authorized`, use `get_me`. While this is
# more expensive and needs to retrieve more data from the server, it
# enables the library to warn users trying to login to a different
# account. See #1172.
me = await self.get_me()
if me is not None:
# The warnings here are on a best-effort and may fail.
if bot_token:
# bot_token's first part has the bot ID, but it may be invalid
# so don't try to parse as int (instead cast our ID to string).
if bot_token[:bot_token.find(':')] != str(me.id):
warnings.warn(
'the session already had an authorized user so it did '
'not login to the bot account using the provided '
'bot_token (it may not be using the user you expect)'
)
elif phone and not callable(phone) and utils.parse_phone(phone) != me.phone:
warnings.warn(
'the session already had an authorized user so it did '
'not login to the user account using the provided '
'phone (it may not be using the user you expect)'
)
return self
if not bot_token:
# Turn the callable into a valid phone number (or bot token)
while callable(phone):
value = phone()
if inspect.isawaitable(value):
value = await value
if ':' in value:
# Bot tokens have 'user_id:access_hash' format
bot_token = value
break
phone = utils.parse_phone(value) or phone
if bot_token:
await self.sign_in(bot_token=bot_token)
return self
me = None
attempts = 0
two_step_detected = False
await self.send_code_request(phone)
sign_up = False # assume login
while attempts < max_attempts:
try:
value = code_callback()
if inspect.isawaitable(value):
value = await value
# Since sign-in with no code works (it sends the code)
# we must double-check that here. Else we'll assume we
# logged in, and it will return None as the User.
if not value:
raise errors.PhoneCodeEmptyError(request=None)
if sign_up:
me = await self.sign_up(value, first_name, last_name)
else:
# Raises SessionPasswordNeededError if 2FA enabled
me = await self.sign_in(phone, code=value)
break
except errors.SessionPasswordNeededError:
two_step_detected = True
break
except errors.PhoneNumberOccupiedError:
sign_up = False
except errors.PhoneNumberUnoccupiedError:
sign_up = True
except (errors.PhoneCodeEmptyError,
errors.PhoneCodeExpiredError,
errors.PhoneCodeHashEmptyError,
errors.PhoneCodeInvalidError):
print('Invalid code. Please try again.', file=sys.stderr)
attempts += 1
else:
raise RuntimeError(
'{} consecutive sign-in attempts failed. Aborting'
.format(max_attempts)
)
if two_step_detected:
if not password:
raise ValueError(
"Two-step verification is enabled for this account. "
"Please provide the 'password' argument to 'start()'."
)
if callable(password):
for _ in range(max_attempts):
try:
value = password()
if inspect.isawaitable(value):
value = await value
me = await self.sign_in(phone=phone, password=value)
break
except errors.PasswordHashInvalidError:
print('Invalid password. Please try again',
file=sys.stderr)
else:
raise errors.PasswordHashInvalidError(request=None)
else:
me = await self.sign_in(phone=phone, password=password)
# We won't reach here if any step failed (exit by exception)
signed, name = 'Signed in successfully as', utils.get_display_name(me)
try:
print(signed, name)
except UnicodeEncodeError:
# Some terminals don't support certain characters
print(signed, name.encode('utf-8', errors='ignore')
.decode('ascii', errors='ignore'))
return self
async def sign_in(
self: 'TelegramClient',
*,
code: typing.Union[str, int] = None,
password: str = None,
bot_token: str = None,) -> 'typing.Union[_tl.User, _tl.auth.SentCode]':
if code and bot_token:
raise ValueError('Can only provide one of code or bot_token, not both')
if not code and not bot_token and not password:
raise ValueError('You must provide code, password, or bot_token.')
if code:
if not self._phone_code_hash:
raise ValueError('Must call client.send_code_request before sign in')
# May raise PhoneCodeEmptyError, PhoneCodeExpiredError,
# PhoneCodeHashEmptyError or PhoneCodeInvalidError.
try:
result = await self(_tl.fn.auth.SignIn(*self._phone_code_hash, str(code)))
password = None # user provided a password but it was not needed
except errors.SessionPasswordNeededError:
if not password:
raise
elif bot_token:
result = await self(_tl.fn.auth.ImportBotAuthorization(
flags=0, bot_auth_token=bot_token,
api_id=self._api_id, api_hash=self._api_hash
))
if password:
pwd = await self(_tl.fn.account.GetPassword())
result = await self(_tl.fn.auth.CheckPassword(
pwd_mod.compute_check(pwd, password)
))
if isinstance(result, _tl.auth.AuthorizationSignUpRequired):
# The method must return the User but we don't have it, so raise instead (matches pre-layer 104 behaviour)
self._tos = (result.terms_of_service, None)
raise errors.SignUpRequired()
return await _update_session_state(self, result.user)
async def sign_up(
self: 'TelegramClient',
first_name: str,
last_name: str = '',
*,
code: typing.Union[str, int]) -> '_tl.User':
if not self._phone_code_hash:
# This check is also present in sign_in but we do it here to customize the error message
raise ValueError('Must call client.send_code_request before sign up')
# To prevent abuse, one has to try to sign in before signing up. This
# is the current way in which Telegram validates the code to sign up.
#
# `sign_in` will set `_tos`, so if it's set we don't need to call it
# because the user already tried to sign in.
#
# We're emulating pre-layer 104 behaviour so except the right error:
try:
return await self.sign_in(code=code)
except errors.SignUpRequired:
pass # code is correct and was used, now need to sign in
result = await self(_tl.fn.auth.SignUp(
phone_number=phone,
phone_code_hash=phone_code_hash,
first_name=first_name,
last_name=last_name
))
return await _update_session_state(self, result.user)
async def get_tos(self):
first_time = self._tos is None
no_tos = self._tos and self._tos[0] is None
tos_expired = self._tos and self._tos[1] is not None and asyncio.get_running_loop().time() >= self._tos[1]
if first_time or no_tos or tos_expired:
result = await self(_tl.fn.help.GetTermsOfServiceUpdate())
tos = getattr(result, 'terms_of_service', None)
self._tos = (tos, asyncio.get_running_loop().time() + result.expires.timestamp() - time.time())
# not stored in the client to prevent a cycle
return _custom.TermsOfService._new(self, *self._tos)
async def _update_session_state(self, user, save=True):
"""
Callback called whenever the login or sign up process completes.
Returns the input user parameter.
"""
state = await self(_tl.fn.updates.GetState())
await _replace_session_state(
self,
save=save,
user_id=user.id,
bot=user.bot,
pts=state.pts,
qts=state.qts,
date=int(state.date.timestamp()),
seq=state.seq,
)
self._phone_code_hash = None
return _custom.User._new(self, user)
async def _replace_session_state(self, *, save=True, **changes):
new = dataclasses.replace(self._session_state, **changes)
await self._session.set_state(new)
self._session_state = new
if save:
await self._session.save()
async def send_code_request(
self: 'TelegramClient',
phone: str) -> 'SentCode':
phone = utils.parse_phone(phone)
if self._phone_code_hash and phone == self._phone_code_hash[0]:
result = await self(_tl.fn.auth.ResendCode(*self._phone_code_hash))
else:
try:
result = await self(_tl.fn.auth.SendCode(
phone, self._api_id, self._api_hash, _tl.CodeSettings()))
except errors.AuthRestartError:
return await self.send_code_request(phone)
# phone_code_hash may be empty, if it is, do not save it (#1283)
if not result.phone_code_hash:
# The hash is required to login, so this pretty much means send code failed
raise ValueError('Failed to send code')
self._phone_code_hash = (phone, result.phone_code_hash)
return _custom.SentCode._new(result)
def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> _custom.QrLogin:
return _custom.QrLoginManager(self, ignored_ids)
async def log_out(self: 'TelegramClient') -> bool:
try:
await self(_tl.fn.auth.LogOut())
except errors.RpcError:
return False
await self.disconnect()
return True
async def edit_2fa(
self: 'TelegramClient',
current_password: str = None,
new_password: str = None,
*,
hint: str = '',
email: str = None,
email_code_callback: typing.Callable[[int], str] = None) -> bool:
if new_password is None and current_password is None:
return False
if email and not callable(email_code_callback):
raise ValueError('email present without email_code_callback')
pwd = await self(_tl.fn.account.GetPassword())
pwd.new_algo.salt1 += os.urandom(32)
assert isinstance(pwd, _tl.account.Password)
if not pwd.has_password and current_password:
current_password = None
if current_password:
password = pwd_mod.compute_check(pwd, current_password)
else:
password = _tl.InputCheckPasswordEmpty()
if new_password:
new_password_hash = pwd_mod.compute_digest(
pwd.new_algo, new_password)
else:
new_password_hash = b''
try:
await self(_tl.fn.account.UpdatePasswordSettings(
password=password,
new_settings=_tl.account.PasswordInputSettings(
new_algo=pwd.new_algo,
new_password_hash=new_password_hash,
hint=hint,
email=email,
new_secure_settings=None
)
))
except errors.EmailUnconfirmedError as e:
code = email_code_callback(e.code_length)
if inspect.isawaitable(code):
code = await code
code = str(code)
await self(_tl.fn.account.ConfirmPasswordEmail(code))
return True

View File

@ -1,37 +0,0 @@
import typing
import asyncio
from ..types import _custom
from .._misc import hints
from .. import errors, _tl
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
async def inline_query(
self: 'TelegramClient',
bot: 'hints.DialogLike',
query: str,
*,
dialog: 'hints.DialogLike' = None,
offset: str = None,
geo_point: '_tl.GeoPoint' = None) -> _custom.InlineResults:
bot = await self._get_input_peer(bot)
if dialog:
peer = await self._get_input_peer(dialog)
else:
peer = _tl.InputPeerEmpty()
try:
result = await self(_tl.fn.messages.GetInlineBotResults(
bot=bot,
peer=peer,
query=query,
offset=offset or '',
geo_point=geo_point
))
except errors.BotResponseTimeoutError:
raise asyncio.TimeoutError from None
return _custom.InlineResults(self, result, entity=peer if dialog else None)

View File

@ -1,690 +0,0 @@
import asyncio
import inspect
import itertools
import string
import typing
import dataclasses
from .. import errors, _tl
from .._misc import helpers, utils, requestiter, tlobject, enums, hints
from ..types import _custom
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
_MAX_PARTICIPANTS_CHUNK_SIZE = 200
_MAX_ADMIN_LOG_CHUNK_SIZE = 100
_MAX_PROFILE_PHOTO_CHUNK_SIZE = 100
class _ChatAction:
def __init__(self, client, chat, action, *, delay, auto_cancel):
self._client = client
self._delay = delay
self._auto_cancel = auto_cancel
self._request = _tl.fn.messages.SetTyping(chat, action)
self._task = None
self._running = False
def __await__(self):
return self._once().__await__()
async def __aenter__(self):
self._request = dataclasses.replace(self._request, peer=await self._client._get_input_peer(self._request.peer))
self._running = True
self._task = asyncio.create_task(self._update())
return self
async def __aexit__(self, *args):
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _once(self):
self._request = dataclasses.replace(self._request, peer=await self._client._get_input_peer(self._request.peer))
await self._client(_tl.fn.messages.SetTyping(self._chat, self._action))
async def _update(self):
try:
while self._running:
await self._client(self._request)
await asyncio.sleep(self._delay)
except ConnectionError:
pass
except asyncio.CancelledError:
if self._auto_cancel:
await self._client(_tl.fn.messages.SetTyping(
self._chat, _tl.SendMessageCancelAction()))
@staticmethod
def _parse(action):
if isinstance(action, tlobject.TLObject) and action.SUBCLASS_OF_ID != 0x20b2cc21:
return action
return {
enums.Action.TYPING: _tl.SendMessageTypingAction(),
enums.Action.CONTACT: _tl.SendMessageChooseContactAction(),
enums.Action.GAME: _tl.SendMessageGamePlayAction(),
enums.Action.LOCATION: _tl.SendMessageGeoLocationAction(),
enums.Action.STICKER: _tl.SendMessageChooseStickerAction(),
enums.Action.RECORD_AUDIO: _tl.SendMessageRecordAudioAction(),
enums.Action.RECORD_ROUND: _tl.SendMessageRecordRoundAction(),
enums.Action.RECORD_VIDEO: _tl.SendMessageRecordVideoAction(),
enums.Action.AUDIO: _tl.SendMessageUploadAudioAction(1),
enums.Action.ROUND: _tl.SendMessageUploadRoundAction(1),
enums.Action.VIDEO: _tl.SendMessageUploadVideoAction(1),
enums.Action.PHOTO: _tl.SendMessageUploadPhotoAction(1),
enums.Action.DOCUMENT: _tl.SendMessageUploadDocumentAction(1),
enums.Action.CANCEL: _tl.SendMessageCancelAction(),
}[enums.Action(action)]
def progress(self, current, total):
if hasattr(self._request.action, 'progress'):
self._request = dataclasses.replace(
self._request,
action=dataclasses.replace(self._request.action, progress=100 * round(current / total))
)
class _ParticipantsIter(requestiter.RequestIter):
async def _init(self, entity, filter, search):
if not filter:
if search:
filter = _tl.ChannelParticipantsSearch(search)
else:
filter = _tl.ChannelParticipantsRecent()
else:
filter = enums.Participant(filter)
if filter == enums.Participant.ADMIN:
filter = _tl.ChannelParticipantsAdmins()
elif filter == enums.Participant.BOT:
filter = _tl.ChannelParticipantsBots()
elif filter == enums.Participant.KICKED:
filter = _tl.ChannelParticipantsKicked(search)
elif filter == enums.Participant.BANNED:
filter = _tl.ChannelParticipantsBanned(search)
elif filter == enums.Participant.CONTACT:
filter = _tl.ChannelParticipantsContacts(search)
else:
raise RuntimeError('unhandled enum variant')
entity = await self.client._get_input_peer(entity)
ty = helpers._entity_type(entity)
if search and (filter or ty != helpers._EntityType.CHANNEL):
# We need to 'search' ourselves unless we have a PeerChannel
search = search.casefold()
self.filter_entity = lambda ent: (
search in utils.get_display_name(ent).casefold() or
search in (getattr(ent, 'username', None) or '').casefold()
)
else:
self.filter_entity = lambda ent: True
if ty == helpers._EntityType.CHANNEL:
if self.limit <= 0:
# May not have access to the channel, but getFull can get the .total.
self.total = (await self.client(
_tl.fn.channels.GetFullChannel(entity)
)).full_chat.participants_count
raise StopAsyncIteration
self.seen = set()
self.request = _tl.fn.channels.GetParticipants(
channel=entity,
filter=filter or _tl.ChannelParticipantsSearch(search),
offset=0,
limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
hash=0
)
elif ty == helpers._EntityType.CHAT:
full = await self.client(
_tl.fn.messages.GetFullChat(entity.chat_id))
if not isinstance(
full.full_chat.participants, _tl.ChatParticipants):
# ChatParticipantsForbidden won't have ``.participants``
self.total = 0
raise StopAsyncIteration
self.total = len(full.full_chat.participants.participants)
users = {user.id: user for user in full.users}
for participant in full.full_chat.participants.participants:
if isinstance(participant, _tl.ChannelParticipantBanned):
user_id = participant.peer.user_id
else:
user_id = participant.user_id
user = users[user_id]
if not self.filter_entity(user):
continue
user = users[user_id]
self.buffer.append(user)
return True
else:
self.total = 1
if self.limit != 0:
user = await self.client.get_profile(entity)
if self.filter_entity(user):
self.buffer.append(user)
return True
async def _load_next_chunk(self):
# Only care about the limit for the first request
# (small amount of people).
#
# Most people won't care about getting exactly 12,345
# members so it doesn't really matter not to be 100%
# precise with being out of the offset/limit here.
self.request = dataclasses.replace(self.request, limit=min(
self.limit - self.request.offset, _MAX_PARTICIPANTS_CHUNK_SIZE))
if self.request.offset > self.limit:
return True
participants = await self.client(self.request)
self.total = participants.count
self.request = dataclasses.replace(self.request, offset=self.request.offset + len(participants.participants))
users = {user.id: user for user in participants.users}
for participant in participants.participants:
if isinstance(participant, _tl.ChannelParticipantBanned):
if not isinstance(participant.peer, _tl.PeerUser):
# May have the entire channel banned. See #3105.
continue
user_id = participant.peer.user_id
else:
user_id = participant.user_id
if isinstance(participant, types.ChannelParticipantLeft):
# These participants should be ignored. See #3231.
continue
user = users[user_id]
if not self.filter_entity(user) or user.id in self.seen:
continue
self.seen.add(user_id)
user = users[user_id]
self.buffer.append(user)
class _AdminLogIter(requestiter.RequestIter):
async def _init(
self, entity, admins, search, min_id, max_id,
join, leave, invite, restrict, unrestrict, ban, unban,
promote, demote, info, settings, pinned, edit, delete,
group_call
):
if any((join, leave, invite, restrict, unrestrict, ban, unban,
promote, demote, info, settings, pinned, edit, delete,
group_call)):
events_filter = _tl.ChannelAdminLogEventsFilter(
join=join, leave=leave, invite=invite, ban=restrict,
unban=unrestrict, kick=ban, unkick=unban, promote=promote,
demote=demote, info=info, settings=settings, pinned=pinned,
edit=edit, delete=delete, group_call=group_call
)
else:
events_filter = None
self.entity = await self.client._get_input_peer(entity)
admin_list = []
if admins:
if not utils.is_list_like(admins):
admins = (admins,)
for admin in admins:
admin_list.append(await self.client._get_input_peer(admin))
self.request = _tl.fn.channels.GetAdminLog(
self.entity, q=search or '', min_id=min_id, max_id=max_id,
limit=0, events_filter=events_filter, admins=admin_list or None
)
async def _load_next_chunk(self):
self.request = dataclasses.replace(self.request, limit=min(self.left, _MAX_ADMIN_LOG_CHUNK_SIZE))
r = await self.client(self.request)
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)}
self.request = dataclasses.replace(self.request, max_id=min((e.id for e in r.events), default=0))
for ev in r.events:
if isinstance(ev.action,
_tl.ChannelAdminLogEventActionEditMessage):
ev = dataclasses.replace(ev, action=dataclasses.replace(
ev.action,
prev_message=_custom.Message._new(self.client, ev.action.prev_message, entities, self.entity),
new_message=_custom.Message._new(self.client, ev.action.new_message, entities, self.entity)
))
elif isinstance(ev.action,
_tl.ChannelAdminLogEventActionDeleteMessage):
ev.action.message = _custom.Message._new(
self.client, ev.action.message, entities, self.entity)
self.buffer.append(_custom.AdminLogEvent(ev, entities))
if len(r.events) < self.request.limit:
return True
class _ProfilePhotoIter(requestiter.RequestIter):
async def _init(
self, entity, offset, max_id
):
entity = await self.client._get_input_peer(entity)
ty = helpers._entity_type(entity)
if ty == helpers._EntityType.USER:
self.request = _tl.fn.photos.GetUserPhotos(
entity,
offset=offset,
max_id=max_id,
limit=1
)
else:
self.request = _tl.fn.messages.Search(
peer=entity,
q='',
filter=_tl.InputMessagesFilterChatPhotos(),
min_date=None,
max_date=None,
offset_id=0,
add_offset=offset,
limit=1,
max_id=max_id,
min_id=0,
hash=0
)
if self.limit == 0:
self.request = dataclasses.replace(self.request, limit=1)
result = await self.client(self.request)
if isinstance(result, _tl.photos.Photos):
self.total = len(result.photos)
elif isinstance(result, _tl.messages.Messages):
self.total = len(result.messages)
else:
# Luckily both photosSlice and messages have a count for total
self.total = getattr(result, 'count', None)
async def _load_next_chunk(self):
self.request = dataclasses.replace(self.request, limit=min(self.left, _MAX_PROFILE_PHOTO_CHUNK_SIZE))
result = await self.client(self.request)
if isinstance(result, _tl.photos.Photos):
self.buffer = result.photos
self.left = len(self.buffer)
self.total = len(self.buffer)
elif isinstance(result, _tl.messages.Messages):
self.buffer = [x.action.photo for x in result.messages
if isinstance(x.action, _tl.MessageActionChatEditPhoto)]
self.left = len(self.buffer)
self.total = len(self.buffer)
elif isinstance(result, _tl.photos.PhotosSlice):
self.buffer = result.photos
self.total = result.count
if len(self.buffer) < self.request.limit:
self.left = len(self.buffer)
else:
self.request = dataclasses.replace(self.request, offset=self.request.offset + len(result.photos))
else:
# Some broadcast channels have a photo that this request doesn't
# retrieve for whatever random reason the Telegram server feels.
#
# This means the `total` count may be wrong but there's not much
# that can be done around it (perhaps there are too many photos
# and this is only a partial result so it's not possible to just
# use the len of the result).
self.total = getattr(result, 'count', None)
# Unconditionally fetch the full channel to obtain this photo and
# yield it with the rest (unless it's a duplicate).
seen_id = None
if isinstance(result, _tl.messages.ChannelMessages):
channel = await self.client(_tl.fn.channels.GetFullChannel(self.request.peer))
photo = channel.full_chat.chat_photo
if isinstance(photo, _tl.Photo):
self.buffer.append(photo)
seen_id = photo.id
self.buffer.extend(
x.action.photo for x in result.messages
if isinstance(x.action, _tl.MessageActionChatEditPhoto)
and x.action.photo.id != seen_id
)
if len(result.messages) < self.request.limit:
self.left = len(self.buffer)
elif result.messages:
self.request = dataclasses.replace(
self.request,
add_offset=0,
offset_id=result.messages[-1].id
)
def get_participants(
self: 'TelegramClient',
chat: 'hints.DialogLike',
limit: float = (),
*,
search: str = '',
filter: '_tl.TypeChannelParticipantsFilter' = None) -> _ParticipantsIter:
return _ParticipantsIter(
self,
limit,
entity=chat,
filter=filter,
search=search
)
def get_admin_log(
self: 'TelegramClient',
chat: 'hints.DialogLike',
limit: float = (),
*,
max_id: int = 0,
min_id: int = 0,
search: str = None,
admins: 'hints.DialogsLike' = None,
join: bool = None,
leave: bool = None,
invite: bool = None,
restrict: bool = None,
unrestrict: bool = None,
ban: bool = None,
unban: bool = None,
promote: bool = None,
demote: bool = None,
info: bool = None,
settings: bool = None,
pinned: bool = None,
edit: bool = None,
delete: bool = None,
group_call: bool = None) -> _AdminLogIter:
return _AdminLogIter(
self,
limit,
entity=chat,
admins=admins,
search=search,
min_id=min_id,
max_id=max_id,
join=join,
leave=leave,
invite=invite,
restrict=restrict,
unrestrict=unrestrict,
ban=ban,
unban=unban,
promote=promote,
demote=demote,
info=info,
settings=settings,
pinned=pinned,
edit=edit,
delete=delete,
group_call=group_call
)
def get_profile_photos(
self: 'TelegramClient',
profile: 'hints.DialogLike',
limit: int = (),
*,
offset: int = 0,
max_id: int = 0) -> _ProfilePhotoIter:
return _ProfilePhotoIter(
self,
limit,
entity=profile,
offset=offset,
max_id=max_id
)
def action(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
action: 'typing.Union[str, _tl.TypeSendMessageAction]',
*,
delay: float = 4,
auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]':
action = _ChatAction._parse(action)
return _ChatAction(
self, dialog, action, delay=delay, auto_cancel=auto_cancel)
async def edit_admin(
self: 'TelegramClient',
chat: 'hints.DialogLike',
user: 'hints.DialogLike',
*,
change_info: bool = None,
post_messages: bool = None,
edit_messages: bool = None,
delete_messages: bool = None,
ban_users: bool = None,
invite_users: bool = None,
pin_messages: bool = None,
add_admins: bool = None,
manage_call: bool = None,
anonymous: bool = None,
is_admin: bool = None,
title: str = None) -> _tl.Updates:
entity = await self._get_input_peer(chat)
user = await self._get_input_peer(user)
ty = helpers._entity_type(user)
perm_names = (
'change_info', 'post_messages', 'edit_messages', 'delete_messages',
'ban_users', 'invite_users', 'pin_messages', 'add_admins',
'anonymous', 'manage_call',
)
ty = helpers._entity_type(entity)
if ty == helpers._EntityType.CHANNEL:
# If we try to set these permissions in a megagroup, we
# would get a RIGHT_FORBIDDEN. However, it makes sense
# that an admin can post messages, so we want to avoid the error
if post_messages or edit_messages:
# TODO get rid of this once sessions cache this information
if entity.channel_id not in self._megagroup_cache:
full_entity = await self.get_profile(entity)
self._megagroup_cache[entity.channel_id] = full_entity.megagroup
if self._megagroup_cache[entity.channel_id]:
post_messages = None
edit_messages = None
perms = locals()
return await self(_tl.fn.channels.EditAdmin(entity, user, _tl.ChatAdminRights(**{
# A permission is its explicit (not-None) value or `is_admin`.
# This essentially makes `is_admin` be the default value.
name: perms[name] if perms[name] is not None else is_admin
for name in perm_names
}), rank=title or ''))
elif ty == helpers._EntityType.CHAT:
# If the user passed any permission in a small
# group chat, they must be a full admin to have it.
if is_admin is None:
is_admin = any(locals()[x] for x in perm_names)
return await self(_tl.fn.messages.EditChatAdmin(
entity, user, is_admin=is_admin))
else:
raise ValueError(
'You can only edit permissions in groups and channels')
async def edit_permissions(
self: 'TelegramClient',
chat: 'hints.DialogLike',
user: 'typing.Optional[hints.DialogLike]' = None,
until_date: 'hints.DateLike' = None,
*,
view_messages: bool = True,
send_messages: bool = True,
send_media: bool = True,
send_stickers: bool = True,
send_gifs: bool = True,
send_games: bool = True,
send_inline: bool = True,
embed_link_previews: bool = True,
send_polls: bool = True,
change_info: bool = True,
invite_users: bool = True,
pin_messages: bool = True) -> _tl.Updates:
entity = await self._get_input_peer(chat)
ty = helpers._entity_type(entity)
rights = _tl.ChatBannedRights(
until_date=until_date,
view_messages=not view_messages,
send_messages=not send_messages,
send_media=not send_media,
send_stickers=not send_stickers,
send_gifs=not send_gifs,
send_games=not send_games,
send_inline=not send_inline,
embed_links=not embed_link_previews,
send_polls=not send_polls,
change_info=not change_info,
invite_users=not invite_users,
pin_messages=not pin_messages
)
if user is None:
return await self(_tl.fn.messages.EditChatDefaultBannedRights(
peer=entity,
banned_rights=rights
))
user = await self._get_input_peer(user)
if isinstance(user, _tl.InputPeerSelf):
raise ValueError('You cannot restrict yourself')
return await self(_tl.fn.channels.EditBanned(
channel=entity,
participant=user,
banned_rights=rights
))
async def kick_participant(
self: 'TelegramClient',
chat: 'hints.DialogLike',
user: 'typing.Optional[hints.DialogLike]'
):
entity = await self._get_input_peer(chat)
user = await self._get_input_peer(user)
ty = helpers._entity_type(entity)
if ty == helpers._EntityType.CHAT:
resp = await self(_tl.fn.messages.DeleteChatUser(entity.chat_id, user))
elif ty == helpers._EntityType.CHANNEL:
if isinstance(user, _tl.InputPeerSelf):
# Despite no longer being in the channel, the account still
# seems to get the service message.
resp = await self(_tl.fn.channels.LeaveChannel(entity))
else:
resp = await self(_tl.fn.channels.EditBanned(
channel=entity,
participant=user,
banned_rights=_tl.ChatBannedRights(
until_date=None, view_messages=True)
))
await asyncio.sleep(0.5)
await self(_tl.fn.channels.EditBanned(
channel=entity,
participant=user,
banned_rights=_tl.ChatBannedRights(until_date=None)
))
else:
raise ValueError('You must pass either a channel or a chat')
return self._get_response_message(None, resp, entity)
async def get_permissions(
self: 'TelegramClient',
chat: 'hints.DialogLike',
user: 'hints.DialogLike' = None
) -> 'typing.Optional[_custom.ParticipantPermissions]':
entity = await self.get_profile(chat)
if not user:
if helpers._entity_type(entity) != helpers._EntityType.USER:
return entity.default_banned_rights
entity = await self._get_input_peer(entity)
user = await self._get_input_peer(user)
if helpers._entity_type(entity) == helpers._EntityType.CHANNEL:
participant = await self(_tl.fn.channels.GetParticipant(
entity,
user
))
return _custom.ParticipantPermissions(participant.participant, False)
elif helpers._entity_type(entity) == helpers._EntityType.CHAT:
chat = await self(_tl.fn.messages.GetFullChat(
entity
))
if isinstance(user, _tl.InputPeerSelf):
user = _tl.PeerUser(self._session_state.user_id)
for participant in chat.full_chat.participants.participants:
if participant.user_id == user.user_id:
return _custom.ParticipantPermissions(participant, True)
raise errors.USER_NOT_PARTICIPANT(400, 'USER_NOT_PARTICIPANT')
raise ValueError('You must pass either a channel or a chat')
async def get_stats(
self: 'TelegramClient',
chat: 'hints.DialogLike',
message: 'typing.Union[int, _tl.Message]' = None,
):
entity = await self._get_input_peer(chat)
message = utils.get_message_id(message)
if message is not None:
try:
req = _tl.fn.stats.GetMessageStats(entity, message)
return await self(req)
except errors.STATS_MIGRATE as e:
dc = e.dc
else:
# Don't bother fetching the Channel entity (costs a request), instead
# try to guess and if it fails we know it's the other one (best case
# no extra request, worst just one).
try:
req = _tl.fn.stats.GetBroadcastStats(entity)
return await self(req)
except errors.STATS_MIGRATE as e:
dc = e.dc
except errors.BROADCAST_REQUIRED:
req = _tl.fn.stats.GetMegagroupStats(entity)
try:
return await self(req)
except errors.STATS_MIGRATE as e:
dc = e.dc
sender = await self._borrow_exported_sender(dc)
try:
# req will be resolved to use the right types inside by now
return await sender.send(req)
finally:
await self._return_exported_sender(sender)

View File

@ -1,211 +0,0 @@
import asyncio
import inspect
import itertools
import typing
import dataclasses
from .. import errors, _tl
from .._misc import helpers, utils, requestiter, hints
from ..types import _custom
_MAX_CHUNK_SIZE = 100
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
def _dialog_message_key(peer, message_id):
"""
Get the key to get messages from a dialog.
We cannot just use the message ID because channels share message IDs,
and the peer ID is required to distinguish between them. But it is not
necessary in small group chats and private chats.
"""
return (peer.channel_id if isinstance(peer, _tl.PeerChannel) else None), message_id
class _DialogsIter(requestiter.RequestIter):
async def _init(
self, offset_date, offset_id, offset_peer, ignore_pinned, ignore_migrated, folder
):
self.request = _tl.fn.messages.GetDialogs(
offset_date=offset_date,
offset_id=offset_id,
offset_peer=offset_peer,
limit=1,
hash=0,
exclude_pinned=ignore_pinned,
folder_id=folder
)
if self.limit <= 0:
# Special case, get a single dialog and determine count
dialogs = await self.client(self.request)
self.total = getattr(dialogs, 'count', len(dialogs.dialogs))
raise StopAsyncIteration
self.seen = set()
self.offset_date = offset_date
self.ignore_migrated = ignore_migrated
async def _load_next_chunk(self):
self.request = dataclasses.replace(self.request, limit=min(self.left, _MAX_CHUNK_SIZE))
r = await self.client(self.request)
self.total = getattr(r, 'count', len(r.dialogs))
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)
if not isinstance(x, (_tl.UserEmpty, _tl.ChatEmpty))}
messages = {
_dialog_message_key(m.peer_id, m.id): _custom.Message._new(self.client, m, entities, None)
for m in r.messages
}
for d in r.dialogs:
# We check the offset date here because Telegram may ignore it
message = messages.get(_dialog_message_key(d.peer, d.top_message))
if self.offset_date:
date = getattr(message, 'date', None)
if not date or date.timestamp() > self.offset_date.timestamp():
continue
peer_id = utils.get_peer_id(d.peer)
if peer_id not in self.seen:
self.seen.add(peer_id)
if peer_id not in entities:
# > In which case can a UserEmpty appear in the list of banned members?
# > In a very rare cases. This is possible but isn't an expected behavior.
# Real world example: https://t.me/TelethonChat/271471
continue
cd = _custom.Dialog(self.client, d, entities, message)
if cd.dialog.pts:
self.client._channel_pts[cd.id] = cd.dialog.pts
if not self.ignore_migrated or getattr(
cd.entity, 'migrated_to', None) is None:
self.buffer.append(cd)
if len(r.dialogs) < self.request.limit\
or not isinstance(r, _tl.messages.DialogsSlice):
# Less than we requested means we reached the end, or
# we didn't get a DialogsSlice which means we got all.
return True
# We can't use `messages[-1]` as the offset ID / date.
# Why? Because pinned dialogs will mess with the order
# in this list. Instead, we find the last dialog which
# has a message, and use it as an offset.
last_message = next(filter(None, (
messages.get(_dialog_message_key(d.peer, d.top_message))
for d in reversed(r.dialogs)
)), None)
self.request = dataclasses.replace(
self.request,
exclude_pinned=True,
offset_id=last_message.id if last_message else 0,
offset_date=last_message.date if last_message else None,
offset_peer=self.buffer[-1].input_entity,
)
class _DraftsIter(requestiter.RequestIter):
async def _init(self, entities, **kwargs):
if not entities:
r = await self.client(_tl.fn.messages.GetAllDrafts())
items = r.updates
else:
peers = []
for entity in entities:
peers.append(_tl.InputDialogPeer(
await self.client._get_input_peer(entity)))
r = await self.client(_tl.fn.messages.GetPeerDialogs(peers))
items = r.dialogs
# TODO Maybe there should be a helper method for this?
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)}
self.buffer.extend(
_custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft)
for d in items
)
async def _load_next_chunk(self):
return []
def get_dialogs(
self: 'TelegramClient',
limit: float = (),
*,
offset_date: 'hints.DateLike' = None,
offset_id: int = 0,
offset_peer: 'hints.DialogLike' = _tl.InputPeerEmpty(),
ignore_pinned: bool = False,
ignore_migrated: bool = False,
folder: int = None,
) -> _DialogsIter:
return _DialogsIter(
self,
limit,
offset_date=offset_date,
offset_id=offset_id,
offset_peer=offset_peer,
ignore_pinned=ignore_pinned,
ignore_migrated=ignore_migrated,
folder=folder
)
def get_drafts(
self: 'TelegramClient',
dialog: 'hints.DialogsLike' = None
) -> _DraftsIter:
limit = None
if dialog:
if not utils.is_list_like(dialog):
dialog = (dialog,)
limit = len(dialog)
return _DraftsIter(self, limit, entities=dialog)
async def delete_dialog(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
*,
revoke: bool = False
):
# If we have enough information (`Dialog.delete` gives it to us),
# then we know we don't have to kick ourselves in deactivated chats.
if isinstance(entity, _tl.Chat):
deactivated = entity.deactivated
else:
deactivated = False
entity = await self._get_input_peer(dialog)
ty = helpers._entity_type(entity)
if ty == helpers._EntityType.CHANNEL:
return await self(_tl.fn.channels.LeaveChannel(entity))
if ty == helpers._EntityType.CHAT and not deactivated:
try:
result = await self(_tl.fn.messages.DeleteChatUser(
entity.chat_id, _tl.InputUserSelf(), revoke_history=revoke
))
except errors.PEER_ID_INVALID:
# Happens if we didn't have the deactivated information
result = None
else:
result = None
if not await self.is_bot():
await self(_tl.fn.messages.DeleteHistory(entity, 0, revoke=revoke))
return result

View File

@ -1,771 +0,0 @@
import datetime
import io
import os
import pathlib
import typing
import inspect
import asyncio
import dataclasses
from .._crypto import AES
from .._misc import utils, helpers, requestiter, tlobject, hints, enums
from .. import errors, _tl
try:
import aiohttp
except ImportError:
aiohttp = None
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
# Chunk sizes for upload.getFile must be multiples of the smallest size
MIN_CHUNK_SIZE = 4096
MAX_CHUNK_SIZE = 512 * 1024
# 2021-01-15, users reported that `errors.TimeoutError` can occur while downloading files.
TIMED_OUT_SLEEP = 1
class _DirectDownloadIter(requestiter.RequestIter):
async def _init(
self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data
):
self.request = _tl.fn.upload.GetFile(
file, offset=offset, limit=request_size)
self.total = file_size
self._stride = stride
self._chunk_size = chunk_size
self._last_part = None
self._msg_data = msg_data
self._timed_out = False
self._exported = dc_id and self.client._session_state.dc_id != dc_id
if not self._exported:
# The used sender will also change if ``FileMigrateError`` occurs
self._sender = self.client._sender
else:
# If this raises DcIdInvalidError, it means we tried exporting the same DC we're in.
# This should not happen, but if it does, it's a bug.
self._sender = await self.client._borrow_exported_sender(dc_id)
async def _load_next_chunk(self):
cur = await self._request()
self.buffer.append(cur)
if len(cur) < self.request.limit:
self.left = len(self.buffer)
await self.close()
else:
self.request = dataclasses.replace(self.request, offset=self.request.offset + self._stride)
async def _request(self):
try:
result = await self.client._call(self._sender, self.request)
self._timed_out = False
if isinstance(result, _tl.upload.FileCdnRedirect):
raise NotImplementedError # TODO Implement
else:
return result.bytes
except errors.TimeoutError as e:
if self._timed_out:
self.client._log[__name__].warning('Got two timeouts in a row while downloading file')
raise
self._timed_out = True
self.client._log[__name__].info('Got timeout while downloading file, retrying once')
await asyncio.sleep(TIMED_OUT_SLEEP)
return await self._request()
except errors.FileMigrateError as e:
self.client._log[__name__].info('File lives in another DC')
self._sender = await self.client._borrow_exported_sender(e.new_dc)
self._exported = True
return await self._request()
except errors.FilerefUpgradeNeededError as e:
# Only implemented for documents which are the ones that may take that long to download
if not self._msg_data \
or not isinstance(self.request.location, _tl.InputDocumentFileLocation) \
or self.request.location.thumb_size != '':
raise
self.client._log[__name__].info('File ref expired during download; refetching message')
chat, msg_id = self._msg_data
msg = await self.client.get_messages(chat, ids=msg_id)
if not isinstance(msg.media, _tl.MessageMediaDocument):
raise
document = msg.media.document
# Message media may have been edited for something else
if document.id != self.request.location.id:
raise
self.request.location = dataclasses.replace(self.request.location, file_reference=document.file_reference)
return await self._request()
async def close(self):
if not self._sender:
return
try:
if self._exported:
await self.client._return_exported_sender(self._sender)
elif self._sender != self.client._sender:
await self._sender.disconnect()
finally:
self._sender = None
async def __aenter__(self):
return self
async def __aexit__(self, *args):
await self.close()
class _GenericDownloadIter(_DirectDownloadIter):
async def _load_next_chunk(self):
# 1. Fetch enough for one chunk
data = b''
# 1.1. ``bad`` is how much into the data we have we need to offset
bad = self.request.offset % self.request.limit
before = self.request.offset
# 1.2. We have to fetch from a valid offset, so remove that bad part
self.request = dataclasses.replace(self.request, offset=self.request.offset - bad)
done = False
while not done and len(data) - bad < self._chunk_size:
cur = await self._request()
self.request = dataclasses.replace(self.request, offset=self.request.offset - self.request.limit)
data += cur
done = len(cur) < self.request.limit
# 1.3 Restore our last desired offset
self.request = dataclasses.replace(self.request, offset=before)
# 2. Fill the buffer with the data we have
# 2.1. Slicing `bytes` is expensive, yield `memoryview` instead
mem = memoryview(data)
# 2.2. The current chunk starts at ``bad`` offset into the data,
# and each new chunk is ``stride`` bytes apart of the other
for i in range(bad, len(data), self._stride):
self.buffer.append(mem[i:i + self._chunk_size])
# 2.3. We will yield this offset, so move to the next one
self.request = dataclasses.replace(self.request, offset=self.request.offset + self._stride)
# 2.4. If we are in the last chunk, we will return the last partial data
if done:
self.left = len(self.buffer)
await self.close()
return
# 2.5. If we are not done, we can't return incomplete chunks.
if len(self.buffer[-1]) != self._chunk_size:
self._last_part = self.buffer.pop().tobytes()
# 3. Be careful with the offsets. Re-fetching a bit of data
# is fine, since it greatly simplifies things.
# TODO Try to not re-fetch data
self.request = dataclasses.replace(self.request, offset=self.request.offset - self._stride)
async def download_profile_photo(
self: 'TelegramClient',
profile: 'hints.DialogLike',
file: 'hints.FileLike' = None,
*,
thumb,
progress_callback) -> typing.Optional[str]:
# hex(crc32(x.encode('ascii'))) for x in
# ('User', 'Chat', 'UserFull', 'ChatFull')
ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697)
# ('InputPeer', 'InputUser', 'InputChannel')
INPUTS = (0xc91c90b6, 0xe669bf46, 0x40f202fd)
entity = profile
if not isinstance(entity, tlobject.TLObject) or entity.SUBCLASS_OF_ID in INPUTS:
entity = await self.get_profile(entity)
possible_names = []
if entity.SUBCLASS_OF_ID not in ENTITIES:
photo = entity
else:
if not hasattr(entity, 'photo'):
# Special case: may be a ChatFull with photo:Photo
# This is different from a normal UserProfilePhoto and Chat
if not hasattr(entity, 'chat_photo'):
return None
return await _download_photo(
self, entity.chat_photo, file, date=None,
thumb=thumb, progress_callback=progress_callback
)
for attr in ('username', 'first_name', 'title'):
possible_names.append(getattr(entity, attr, None))
photo = entity.photo
if isinstance(photo, (_tl.UserProfilePhoto, _tl.ChatPhoto)):
thumb = enums.Size.ORIGINAL if thumb == () else enums.Size(thumb)
dc_id = photo.dc_id
loc = _tl.InputPeerPhotoFileLocation(
peer=await self._get_input_peer(entity),
photo_id=photo.photo_id,
big=thumb >= enums.Size.LARGE
)
else:
# It doesn't make any sense to check if `photo` can be used
# as input location, because then this method would be able
# to "download the profile photo of a message", i.e. its
# media which should be done with `download_media` instead.
return None
file = _get_proper_filename(
file, 'profile_photo', '.jpg',
possible_names=possible_names
)
try:
result = await _download_file(
self=self,
input_location=loc,
file=file,
dc_id=dc_id
)
return result if file is bytes else file
except errors.LocationInvalidError:
# See issue #500, Android app fails as of v4.6.0 (1155).
# The fix seems to be using the full channel chat photo.
ie = await self._get_input_peer(entity)
ty = helpers._entity_type(ie)
if ty == helpers._EntityType.CHANNEL:
full = await self(_tl.fn.channels.GetFullChannel(ie))
return await _download_photo(
self, full.full_chat.chat_photo, file,
date=None, progress_callback=progress_callback,
thumb=thumb
)
else:
# Until there's a report for chats, no need to.
return None
async def download_media(
self: 'TelegramClient',
media: 'hints.MessageLike',
file: 'hints.FileLike' = None,
*,
size = (),
progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]:
# Downloading large documents may be slow enough to require a new file reference
# to be obtained mid-download. Store (input chat, message id) so that the message
# can be re-fetched.
msg_data = None
# TODO This won't work for messageService
message = media
if isinstance(message, _tl.Message):
date = message.date
media = message.media
msg_data = (message.input_chat, message.id) if message.input_chat else None
else:
date = datetime.datetime.now()
media = message
if isinstance(media, _tl.MessageService):
if isinstance(message.action,
_tl.MessageActionChatEditPhoto):
media = media.photo
if isinstance(media, _tl.MessageMediaWebPage):
if isinstance(media.webpage, _tl.WebPage):
media = media.webpage.document or media.webpage.photo
if isinstance(media, (_tl.MessageMediaPhoto, _tl.Photo)):
return await _download_photo(
self, media, file, date, thumb, progress_callback
)
elif isinstance(media, (_tl.MessageMediaDocument, _tl.Document)):
return await _download_document(
self, media, file, date, thumb, progress_callback, msg_data
)
elif isinstance(media, _tl.MessageMediaContact):
return _download_contact(
self, media, file
)
elif isinstance(media, (_tl.WebDocument, _tl.WebDocumentNoProxy)):
return await _download_web_document(
self, media, file, progress_callback
)
async def _download_file(
self: 'TelegramClient',
input_location: 'hints.FileLike',
file: 'hints.OutFileLike' = None,
*,
part_size_kb: float = None,
file_size: int = None,
progress_callback: 'hints.ProgressCallback' = None,
dc_id: int = None,
key: bytes = None,
iv: bytes = None,
msg_data: tuple = None) -> typing.Optional[bytes]:
"""
Low-level method to download files from their input location.
Arguments
input_location (:tl:`InputFileLocation`):
The file location from which the file will be downloaded.
See `telethon.utils.get_input_location` source for a complete
list of supported _tl.
file (`str` | `file`, optional):
The output file path, directory, or stream-like object.
If the path exists and is a file, it will be overwritten.
If the file path is `None` or `bytes`, then the result
will be saved in memory and returned as `bytes`.
part_size_kb (`int`, optional):
Chunk size when downloading files. The larger, the less
requests will be made (up to 512KB maximum).
file_size (`int`, optional):
The file size that is about to be downloaded, if known.
Only used if ``progress_callback`` is specified.
progress_callback (`callable`, optional):
A callback function accepting two parameters:
``(downloaded bytes, total)``. Note that the
``total`` is the provided ``file_size``.
dc_id (`int`, optional):
The data center the library should connect to in order
to download the file. You shouldn't worry about this.
key ('bytes', optional):
In case of an encrypted upload (secret chats) a key is supplied
iv ('bytes', optional):
In case of an encrypted upload (secret chats) an iv is supplied
"""
if not part_size_kb:
if not file_size:
part_size_kb = 64 # Reasonable default
else:
part_size_kb = utils.get_appropriated_part_size(file_size)
part_size = int(part_size_kb * 1024)
if part_size % MIN_CHUNK_SIZE != 0:
raise ValueError(
'The part size must be evenly divisible by 4096.')
if isinstance(file, pathlib.Path):
file = str(file.absolute())
in_memory = file is None or file is bytes
if in_memory:
f = io.BytesIO()
elif isinstance(file, str):
# Ensure that we'll be able to download the media
helpers.ensure_parent_dir_exists(file)
f = open(file, 'wb')
else:
f = file
try:
async for chunk in _iter_download(
self, input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data):
if iv and key:
chunk = AES.decrypt_ige(chunk, key, iv)
r = f.write(chunk)
if inspect.isawaitable(r):
await r
if progress_callback:
r = progress_callback(f.tell(), file_size)
if inspect.isawaitable(r):
await r
# Not all IO objects have flush (see #1227)
if callable(getattr(f, 'flush', None)):
f.flush()
if in_memory:
return f.getvalue()
finally:
if isinstance(file, str) or in_memory:
f.close()
def iter_download(
self: 'TelegramClient',
media: 'hints.FileLike',
*,
offset: int = 0,
stride: int = None,
limit: int = (),
chunk_size: int = None,
request_size: int = MAX_CHUNK_SIZE,
file_size: int = None,
dc_id: int = None
):
return _iter_download(
self,
media,
offset=offset,
stride=stride,
limit=limit,
chunk_size=chunk_size,
request_size=request_size,
file_size=file_size,
dc_id=dc_id,
)
def _iter_download(
self: 'TelegramClient',
file: 'hints.FileLike',
*,
offset: int = 0,
stride: int = None,
limit: int = None,
chunk_size: int = None,
request_size: int = MAX_CHUNK_SIZE,
file_size: int = None,
dc_id: int = None,
msg_data: tuple = None
):
info = utils._get_file_info(file)
if info.dc_id is not None:
dc_id = info.dc_id
if file_size is None:
file_size = info.size
file = info.location
if chunk_size is None:
chunk_size = request_size
if limit is None and file_size is not None:
limit = (file_size + chunk_size - 1) // chunk_size
if stride is None:
stride = chunk_size
elif stride < chunk_size:
raise ValueError('stride must be >= chunk_size')
request_size -= request_size % MIN_CHUNK_SIZE
if request_size < MIN_CHUNK_SIZE:
request_size = MIN_CHUNK_SIZE
elif request_size > MAX_CHUNK_SIZE:
request_size = MAX_CHUNK_SIZE
if chunk_size == request_size \
and offset % MIN_CHUNK_SIZE == 0 \
and stride % MIN_CHUNK_SIZE == 0 \
and (limit is None or offset % limit == 0):
cls = _DirectDownloadIter
self._log[__name__].info('Starting direct file download in chunks of '
'%d at %d, stride %d', request_size, offset, stride)
else:
cls = _GenericDownloadIter
self._log[__name__].info('Starting indirect file download in chunks of '
'%d at %d, stride %d', request_size, offset, stride)
return cls(
self,
limit,
file=file,
dc_id=dc_id,
offset=offset,
stride=stride,
chunk_size=chunk_size,
request_size=request_size,
file_size=file_size,
msg_data=msg_data,
)
def _get_thumb(thumbs, thumb):
if isinstance(thumb, tlobject.TLObject):
return thumb
thumb = enums.Size(thumb)
return min(
thumbs,
default=None,
key=lambda t: abs(thumb - enums.Size(t.type))
)
def _download_cached_photo_size(self: 'TelegramClient', size, file):
# No need to download anything, simply write the bytes
if isinstance(size, _tl.PhotoStrippedSize):
data = utils.stripped_photo_to_jpg(size.bytes)
else:
data = size.bytes
if file is bytes:
return data
elif isinstance(file, str):
helpers.ensure_parent_dir_exists(file)
f = open(file, 'wb')
else:
f = file
try:
f.write(data)
finally:
if isinstance(file, str):
f.close()
return file
async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, progress_callback):
"""Specialized version of .download_media() for photos"""
# Determine the photo and its largest size
if isinstance(photo, _tl.MessageMediaPhoto):
photo = photo.photo
if not isinstance(photo, _tl.Photo):
return
# Include video sizes here (but they may be None so provide an empty list)
size = _get_thumb(photo.sizes + (photo.video_sizes or []), thumb)
if not size or isinstance(size, _tl.PhotoSizeEmpty):
return
if isinstance(size, _tl.VideoSize):
file = _get_proper_filename(file, 'video', '.mp4', date=date)
else:
file = _get_proper_filename(file, 'photo', '.jpg', date=date)
if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)):
return _download_cached_photo_size(self, size, file)
if isinstance(size, _tl.PhotoSizeProgressive):
file_size = max(size.sizes)
else:
file_size = size.size
result = await _download_file(
self=self,
input_location=_tl.InputPhotoFileLocation(
id=photo.id,
access_hash=photo.access_hash,
file_reference=photo.file_reference,
thumb_size=size.type
),
file=file,
file_size=file_size,
progress_callback=progress_callback
)
return result if file is bytes else file
def _get_kind_and_names(attributes):
"""Gets kind and possible names for :tl:`DocumentAttribute`."""
kind = 'document'
possible_names = []
for attr in attributes:
if isinstance(attr, _tl.DocumentAttributeFilename):
possible_names.insert(0, attr.file_name)
elif isinstance(attr, _tl.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'
return kind, possible_names
async def _download_document(
self, document, file, date, thumb, progress_callback, msg_data):
"""Specialized version of .download_media() for documents."""
if isinstance(document, _tl.MessageMediaDocument):
document = document.document
if not isinstance(document, _tl.Document):
return
if thumb == ():
kind, possible_names = _get_kind_and_names(document.attributes)
file = _get_proper_filename(
file, kind, utils.get_extension(document),
date=date, possible_names=possible_names
)
size = None
else:
file = _get_proper_filename(file, 'photo', '.jpg', date=date)
size = _get_thumb(document.thumbs, thumb)
if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)):
return _download_cached_photo_size(self, size, file)
result = await _download_file(
self=self,
input_location=_tl.InputDocumentFileLocation(
id=document.id,
access_hash=document.access_hash,
file_reference=document.file_reference,
thumb_size=size.type if size else ''
),
file=file,
file_size=size.size if size else document.size,
progress_callback=progress_callback,
msg_data=msg_data,
)
return result if file is bytes else file
def _download_contact(cls, mm_contact, file):
"""
Specialized version of .download_media() for contacts.
Will make use of the vCard 4.0 format.
"""
first_name = mm_contact.first_name
last_name = mm_contact.last_name
phone_number = mm_contact.phone_number
# Remove these pesky characters
first_name = first_name.replace(';', '')
last_name = (last_name or '').replace(';', '')
result = (
'BEGIN:VCARD\n'
'VERSION:4.0\n'
'N:{f};{l};;;\n'
'FN:{f} {l}\n'
'TEL;TYPE=cell;VALUE=uri:tel:+{p}\n'
'END:VCARD\n'
).format(f=first_name, l=last_name, p=phone_number).encode('utf-8')
if file is bytes:
return result
elif isinstance(file, str):
file = cls._get_proper_filename(
file, 'contact', '.vcard',
possible_names=[first_name, phone_number, last_name]
)
f = open(file, 'wb')
else:
f = file
try:
f.write(result)
finally:
# Only close the stream if we opened it
if isinstance(file, str):
f.close()
return file
async def _download_web_document(cls, web, file, progress_callback):
"""
Specialized version of .download_media() for web documents.
"""
if not aiohttp:
raise ValueError(
'Cannot download web documents without the aiohttp '
'dependency install it (pip install aiohttp)'
)
# TODO Better way to get opened handles of files and auto-close
in_memory = file is bytes
if in_memory:
f = io.BytesIO()
elif isinstance(file, str):
kind, possible_names = cls._get_kind_and_names(web.attributes)
file = cls._get_proper_filename(
file, kind, utils.get_extension(web),
possible_names=possible_names
)
f = open(file, 'wb')
else:
f = file
try:
async with aiohttp.ClientSession() as session:
# TODO Use progress_callback; get content length from response
# https://github.com/telegramdesktop/tdesktop/blob/c7e773dd9aeba94e2be48c032edc9a78bb50234e/Telegram/SourceFiles/ui/images.cpp#L1318-L1319
async with session.get(web.url) as response:
while True:
chunk = await response.content.read(128 * 1024)
if not chunk:
break
f.write(chunk)
finally:
if isinstance(file, str) or file is bytes:
f.close()
return f.getvalue() if in_memory else file
def _get_proper_filename(file, kind, extension,
date=None, possible_names=None):
"""Gets a proper filename for 'file', if this is a path.
'kind' should be the kind of the output file (photo, document...)
'extension' should be the extension to be added to the file if
the filename doesn't have any yet
'date' should be when this file was originally sent, if known
'possible_names' should be an ordered list of possible names
If no modification is made to the path, any existing file
will be overwritten.
If any modification is made to the path, this method will
ensure that no existing file will be overwritten.
"""
if isinstance(file, pathlib.Path):
file = str(file.absolute())
if file is not None and not isinstance(file, str):
# Probably a stream-like object, we cannot set a filename here
return file
if file is None:
file = ''
elif os.path.isfile(file):
# Make no modifications to valid existing paths
return file
if os.path.isdir(file) or not file:
try:
name = None if possible_names is None else next(
x for x in possible_names if x
)
except StopIteration:
name = None
if not name:
if not date:
date = datetime.datetime.now()
name = '{}_{}-{:02}-{:02}_{:02}-{:02}-{:02}'.format(
kind,
date.year, date.month, date.day,
date.hour, date.minute, date.second,
)
file = os.path.join(file, name)
directory, name = os.path.split(file)
name, ext = os.path.splitext(name)
if not ext:
ext = extension
result = os.path.join(directory, name + ext)
if not os.path.isfile(result):
return result
i = 1
while True:
result = os.path.join(directory, '{} ({}){}'.format(name, i, ext))
if not os.path.isfile(result):
return result
i += 1

View File

@ -1,175 +0,0 @@
import itertools
import re
import typing
from .._misc import helpers, utils
from ..types import _custom
from ..types._custom.inputmessage import InputMessage
from .. import _tl
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
async def _replace_with_mention(self: 'TelegramClient', entities, i, user):
"""
Helper method to replace ``entities[i]`` to mention ``user``,
or do nothing if it can't be found.
"""
try:
entities[i] = _tl.InputMessageEntityMentionName(
entities[i].offset, entities[i].length,
await self._get_input_peer(user)
)
return True
except (ValueError, TypeError):
return False
async def _parse_message_text(self: 'TelegramClient', message, parse_mode):
"""
Returns a (parsed message, entities) tuple depending on ``parse_mode``.
"""
if parse_mode == ():
parse, _ = InputMessage._default_parse_mode
else:
parse, _ = utils.sanitize_parse_mode(parse_mode)
original_message = message
message, msg_entities = parse(message)
if original_message and not message and not msg_entities:
raise ValueError("Failed to parse message")
for i in reversed(range(len(msg_entities))):
e = msg_entities[i]
if isinstance(e, _tl.MessageEntityTextUrl):
m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url)
if m:
user = int(m.group(1)) if m.group(1) else e.url
is_mention = await _replace_with_mention(self, msg_entities, i, user)
if not is_mention:
del msg_entities[i]
elif isinstance(e, (_tl.MessageEntityMentionName,
_tl.InputMessageEntityMentionName)):
is_mention = await _replace_with_mention(self, msg_entities, i, e.user_id)
if not is_mention:
del msg_entities[i]
return message, msg_entities
def _get_response_message(self: 'TelegramClient', request, result, input_chat):
"""
Extracts the response message known a request and Update result.
The request may also be the ID of the message to match.
If ``request is None`` this method returns ``{id: message}``.
If ``request.random_id`` is a list, this method returns a list too.
"""
if isinstance(result, _tl.UpdateShort):
updates = [result.update]
entities = {}
elif isinstance(result, (_tl.Updates, _tl.UpdatesCombined)):
updates = result.updates
entities = {utils.get_peer_id(x): x
for x in
itertools.chain(result.users, result.chats)}
else:
return None
random_to_id = {}
id_to_message = {}
for update in updates:
if isinstance(update, _tl.UpdateMessageID):
random_to_id[update.random_id] = update.id
elif isinstance(update, (
_tl.UpdateNewChannelMessage, _tl.UpdateNewMessage)):
message = _custom.Message._new(self, update.message, entities, input_chat)
# Pinning a message with `updatePinnedMessage` seems to
# always produce a service message we can't map so return
# it directly. The same happens for kicking users.
#
# It could also be a list (e.g. when sending albums).
#
# TODO this method is getting messier and messier as time goes on
if hasattr(request, 'random_id') or utils.is_list_like(request):
id_to_message[message.id] = message
else:
return message
elif (isinstance(update, _tl.UpdateEditMessage)
and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL):
message = _custom.Message._new(self, update.message, entities, input_chat)
# Live locations use `sendMedia` but Telegram responds with
# `updateEditMessage`, which means we won't have `id` field.
if hasattr(request, 'random_id'):
id_to_message[message.id] = message
elif request.id == message.id:
return message
elif (isinstance(update, _tl.UpdateEditChannelMessage)
and utils.get_peer_id(request.peer) ==
utils.get_peer_id(update.message.peer_id)):
if request.id == update.message.id:
return _custom.Message._new(self, update.message, entities, input_chat)
elif isinstance(update, _tl.UpdateNewScheduledMessage):
# Scheduled IDs may collide with normal IDs. However, for a
# single request there *shouldn't* be a mix between "some
# scheduled and some not".
id_to_message[update.message.id] = _custom.Message._new(self, update.message, entities, input_chat)
elif isinstance(update, _tl.UpdateMessagePoll):
if request.media.poll.id == update.poll_id:
return _custom.Message._new(self, _tl.Message(
id=request.id,
peer_id=utils.get_peer(request.peer),
media=_tl.MessageMediaPoll(
poll=update.poll,
results=update.results
),
date=None,
message=''
), entities, input_chat)
if request is None:
return id_to_message
random_id = request if isinstance(request, (int, list)) else getattr(request, 'random_id', None)
if random_id is None:
# Can happen when pinning a message does not actually produce a service message.
self._log[__name__].warning(
'No random_id in %s to map to, returning None message for %s', request, result)
return None
if not utils.is_list_like(random_id):
msg = id_to_message.get(random_to_id.get(random_id))
if not msg:
self._log[__name__].warning(
'Request %s had missing message mapping %s', request, result)
return msg
try:
return [id_to_message[random_to_id[rnd]] for rnd in random_id]
except KeyError:
# Sometimes forwards fail (`MESSAGE_ID_INVALID` if a message gets
# deleted or `WORKER_BUSY_TOO_LONG_RETRY` if there are issues at
# Telegram), in which case we get some "missing" message mappings.
# Log them with the hope that we can better work around them.
#
# This also happens when trying to forward messages that can't
# be forwarded because they don't exist (0, service, deleted)
# among others which could be (like deleted or existing).
self._log[__name__].warning(
'Request %s had missing message mappings %s', request, result)
return [
id_to_message.get(random_to_id[rnd])
if rnd in random_to_id
else None
for rnd in random_id
]

View File

@ -1,792 +0,0 @@
import inspect
import itertools
import time
import typing
import warnings
import dataclasses
import os
from .._misc import helpers, utils, requestiter, hints
from ..types import _custom
from ..types._custom.inputmessage import InputMessage
from .. import errors, _tl
_MAX_CHUNK_SIZE = 100
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
class _MessagesIter(requestiter.RequestIter):
"""
Common factor for all requests that need to iterate over messages.
"""
async def _init(
self, entity, offset_id, min_id, max_id,
from_user, offset_date, add_offset, filter, search, reply_to,
scheduled
):
# Note that entity being `None` will perform a global search.
if entity:
self.entity = await self.client._get_input_peer(entity)
else:
self.entity = None
if self.reverse:
raise ValueError('Cannot reverse global search')
# Telegram doesn't like min_id/max_id. If these IDs are low enough
# (starting from last_id - 100), the request will return nothing.
#
# We can emulate their behaviour locally by setting offset = max_id
# and simply stopping once we hit a message with ID <= min_id.
if self.reverse:
offset_id = max(offset_id, min_id)
if offset_id and max_id:
if max_id - offset_id <= 1:
raise StopAsyncIteration
if not max_id:
max_id = float('inf')
else:
offset_id = max(offset_id, max_id)
if offset_id and min_id:
if offset_id - min_id <= 1:
raise StopAsyncIteration
if self.reverse:
if offset_id:
offset_id += 1
elif not offset_date:
# offset_id has priority over offset_date, so don't
# set offset_id to 1 if we want to offset by date.
offset_id = 1
if from_user:
from_user = await self.client._get_input_peer(from_user)
self.from_id = await self.client._get_peer_id(from_user)
else:
self.from_id = None
# `messages.searchGlobal` only works with text `search` or `filter` queries.
# If we want to perform global a search with `from_user` we have to perform
# a normal `messages.search`, *but* we can make the entity be `inputPeerEmpty`.
if not self.entity and from_user:
self.entity = _tl.InputPeerEmpty()
if filter is None:
filter = _tl.InputMessagesFilterEmpty()
else:
filter = filter() if isinstance(filter, type) else filter
if not self.entity:
self.request = _tl.fn.messages.SearchGlobal(
q=search or '',
filter=filter,
min_date=None,
max_date=offset_date,
offset_rate=0,
offset_peer=_tl.InputPeerEmpty(),
offset_id=offset_id,
limit=1
)
elif scheduled:
self.request = _tl.fn.messages.GetScheduledHistory(
peer=entity,
hash=0
)
elif reply_to is not None:
self.request = _tl.fn.messages.GetReplies(
peer=self.entity,
msg_id=reply_to,
offset_id=offset_id,
offset_date=offset_date,
add_offset=add_offset,
limit=1,
max_id=0,
min_id=0,
hash=0
)
elif search is not None or not isinstance(filter, _tl.InputMessagesFilterEmpty) or from_user:
# Telegram completely ignores `from_id` in private chats
ty = helpers._entity_type(self.entity)
if ty == helpers._EntityType.USER:
# Don't bother sending `from_user` (it's ignored anyway),
# but keep `from_id` defined above to check it locally.
from_user = None
else:
# Do send `from_user` to do the filtering server-side,
# and set `from_id` to None to avoid checking it locally.
self.from_id = None
self.request = _tl.fn.messages.Search(
peer=self.entity,
q=search or '',
filter=filter,
min_date=None,
max_date=offset_date,
offset_id=offset_id,
add_offset=add_offset,
limit=0, # Search actually returns 0 items if we ask it to
max_id=0,
min_id=0,
hash=0,
from_id=from_user
)
# Workaround issue #1124 until a better solution is found.
# Telegram seemingly ignores `max_date` if `filter` (and
# nothing else) is specified, so we have to rely on doing
# a first request to offset from the ID instead.
#
# Even better, using `filter` and `from_id` seems to always
# trigger `RPC_CALL_FAIL` which is "internal issues"...
if not isinstance(filter, _tl.InputMessagesFilterEmpty) \
and offset_date and not search and not offset_id:
async for m in self.client.get_messages(
self.entity, 1, offset_date=offset_date):
self.request = dataclasses.replace(self.request, offset_id=m.id + 1)
else:
self.request = _tl.fn.messages.GetHistory(
peer=self.entity,
limit=1,
offset_date=offset_date,
offset_id=offset_id,
min_id=0,
max_id=0,
add_offset=add_offset,
hash=0
)
if self.limit <= 0:
# No messages, but we still need to know the total message count
result = await self.client(self.request)
if isinstance(result, _tl.messages.MessagesNotModified):
self.total = result.count
else:
self.total = getattr(result, 'count', len(result.messages))
raise StopAsyncIteration
if self.wait_time is None:
self.wait_time = 1 if self.limit > 3000 else 0
# When going in reverse we need an offset of `-limit`, but we
# also want to respect what the user passed, so add them together.
if self.reverse:
self.request.add_offset -= _MAX_CHUNK_SIZE
self.add_offset = add_offset
self.max_id = max_id
self.min_id = min_id
self.last_id = 0 if self.reverse else float('inf')
async def _load_next_chunk(self):
self.request = dataclasses.replace(self.request, limit=min(self.left, _MAX_CHUNK_SIZE))
if self.reverse and self.request.limit != _MAX_CHUNK_SIZE:
# Remember that we need -limit when going in reverse
self.request = dataclasses.replace(self.request, add_offset=self.add_offset - self.request.limit)
r = await self.client(self.request)
self.total = getattr(r, 'count', len(r.messages))
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)}
messages = reversed(r.messages) if self.reverse else r.messages
for message in messages:
if (isinstance(message, _tl.MessageEmpty)
or self.from_id and message.sender_id != self.from_id):
continue
if not self._message_in_range(message):
return True
# There has been reports that on bad connections this method
# was returning duplicated IDs sometimes. Using ``last_id``
# is an attempt to avoid these duplicates, since the message
# IDs are returned in descending order (or asc if reverse).
self.last_id = message.id
self.buffer.append(_custom.Message._new(self.client, message, entities, self.entity))
if len(r.messages) < self.request.limit:
return True
# Get the last message that's not empty (in some rare cases
# it can happen that the last message is :tl:`MessageEmpty`)
if self.buffer:
self._update_offset(self.buffer[-1], r)
else:
# There are some cases where all the messages we get start
# being empty. This can happen on migrated mega-groups if
# the history was cleared, and we're using search. Telegram
# acts incredibly weird sometimes. Messages are returned but
# only "empty", not their contents. If this is the case we
# should just give up since there won't be any new Message.
return True
def _message_in_range(self, message):
"""
Determine whether the given message is in the range or
it should be ignored (and avoid loading more chunks).
"""
# No entity means message IDs between chats may vary
if self.entity:
if self.reverse:
if message.id <= self.last_id or message.id >= self.max_id:
return False
else:
if message.id >= self.last_id or message.id <= self.min_id:
return False
return True
def _update_offset(self, last_message, response):
"""
After making the request, update its offset with the last message.
"""
self.request = dataclasses.replace(self.request, offset_id=last_message.id)
if self.reverse:
# We want to skip the one we already have
self.request = dataclasses.replace(self.request, offset_id=self.request.offset_id + 1)
if isinstance(self.request, _tl.fn.messages.Search):
# Unlike getHistory and searchGlobal that use *offset* date,
# this is *max* date. This means that doing a search in reverse
# will break it. Since it's not really needed once we're going
# (only for the first request), it's safe to just clear it off.
self.request = dataclasses.replace(self.request, max_date=None)
else:
# getHistory, searchGlobal and getReplies call it offset_date
self.request = dataclasses.replace(self.request, offset_date=last_message.date)
if isinstance(self.request, _tl.fn.messages.SearchGlobal):
if last_message.input_chat:
self.request = dataclasses.replace(self.request, offset_peer=last_message.input_chat)
else:
self.request = dataclasses.replace(self.request, offset_peer=_tl.InputPeerEmpty())
self.request = dataclasses.replace(self.request, offset_rate=getattr(response, 'next_rate', 0))
class _IDsIter(requestiter.RequestIter):
async def _init(self, entity, ids):
self.total = len(ids)
self._ids = list(reversed(ids)) if self.reverse else ids
self._offset = 0
self._entity = (await self.client._get_input_peer(entity)) if entity else None
self._ty = helpers._entity_type(self._entity) if self._entity else None
# 30s flood wait every 300 messages (3 requests of 100 each, 30 of 10, etc.)
if self.wait_time is None:
self.wait_time = 10 if self.limit > 300 else 0
async def _load_next_chunk(self):
ids = self._ids[self._offset:self._offset + _MAX_CHUNK_SIZE]
if not ids:
raise StopAsyncIteration
self._offset += _MAX_CHUNK_SIZE
from_id = None # By default, no need to validate from_id
if self._ty == helpers._EntityType.CHANNEL:
try:
r = await self.client(
_tl.fn.channels.GetMessages(self._entity, ids))
except errors.MessageIdsEmptyError:
# All IDs were invalid, use a dummy result
r = _tl.messages.MessagesNotModified(len(ids))
else:
r = await self.client(_tl.fn.messages.GetMessages(ids))
if self._entity:
from_id = await _get_peer(self.client, self._entity)
if isinstance(r, _tl.messages.MessagesNotModified):
self.buffer.extend(None for _ in ids)
return
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)}
# Telegram seems to return the messages in the order in which
# we asked them for, so we don't need to check it ourselves,
# unless some messages were invalid in which case Telegram
# may decide to not send them at all.
#
# The passed message IDs may not belong to the desired entity
# since the user can enter arbitrary numbers which can belong to
# arbitrary chats. Validate these unless ``from_id is None``.
for message in r.messages:
if isinstance(message, _tl.MessageEmpty) or (
from_id and message.peer_id != from_id):
self.buffer.append(None)
else:
self.buffer.append(_custom.Message._new(self.client, message, entities, self._entity))
async def _get_peer(self: 'TelegramClient', input_peer: 'hints.DialogLike'):
try:
return utils.get_peer(input_peer)
except TypeError:
# Can only be self by now
return _tl.PeerUser(await self._get_peer_id(input_peer))
def get_messages(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
limit: float = (),
*,
offset_date: 'hints.DateLike' = None,
offset_id: int = 0,
max_id: int = 0,
min_id: int = 0,
add_offset: int = 0,
search: str = None,
filter: 'typing.Union[_tl.TypeMessagesFilter, typing.Type[_tl.TypeMessagesFilter]]' = None,
from_user: 'hints.DialogLike' = None,
wait_time: float = None,
ids: 'typing.Union[int, typing.Sequence[int]]' = None,
reverse: bool = False,
reply_to: int = None,
scheduled: bool = False
) -> 'typing.Union[_MessagesIter, _IDsIter]':
if ids is not None:
if not utils.is_list_like(ids):
ids = [ids]
return _IDsIter(
client=self,
reverse=reverse,
wait_time=wait_time,
limit=len(ids),
entity=dialog,
ids=ids
)
return _MessagesIter(
client=self,
reverse=reverse,
wait_time=wait_time,
limit=limit,
entity=dialog,
offset_id=offset_id,
min_id=min_id,
max_id=max_id,
from_user=from_user,
offset_date=offset_date,
add_offset=add_offset,
filter=filter,
search=search,
reply_to=reply_to,
scheduled=scheduled
)
async def _get_comment_data(
self: 'TelegramClient',
entity: 'hints.DialogLike',
message: 'typing.Union[int, _tl.Message]'
):
r = await self(_tl.fn.messages.GetDiscussionMessage(
peer=entity,
msg_id=utils.get_message_id(message)
))
m = r.messages[0]
chat = next(c for c in r.chats if c.id == m.peer_id.channel_id)
return utils.get_input_peer(chat), m.id
async def send_message(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
message: 'hints.MessageLike' = '',
*,
# - Message contents
# Formatting
markdown: str = None,
html: str = None,
formatting_entities: list = None,
link_preview: bool = (),
# Media
file: typing.Optional[hints.FileLike] = None,
file_name: str = None,
mime_type: str = None,
thumb: str = False,
force_file: bool = False,
file_size: int = None,
# Media attributes
duration: int = None,
width: int = None,
height: int = None,
title: str = None,
performer: str = None,
supports_streaming: bool = False,
video_note: bool = False,
voice_note: bool = False,
waveform: bytes = None,
# Additional parametrization
silent: bool = False,
buttons: list = None,
ttl: int = None,
# - Send options
reply_to: 'typing.Union[int, _tl.Message]' = None,
send_as: 'hints.DialogLike' = None,
clear_draft: bool = False,
background: bool = None,
noforwards: bool = None,
schedule: 'hints.DateLike' = None,
comment_to: 'typing.Union[int, _tl.Message]' = None,
) -> '_tl.Message':
if isinstance(message, str):
message = InputMessage(
text=message,
markdown=markdown,
html=html,
formatting_entities=formatting_entities,
link_preview=link_preview,
file=file,
file_name=file_name,
mime_type=mime_type,
thumb=thumb,
force_file=force_file,
file_size=file_size,
duration=duration,
width=width,
height=height,
title=title,
performer=performer,
supports_streaming=supports_streaming,
video_note=video_note,
voice_note=voice_note,
waveform=waveform,
silent=silent,
buttons=buttons,
ttl=ttl,
)
elif isinstance(message, _custom.Message):
message = message._as_input()
elif not isinstance(message, InputMessage):
raise TypeError(f'message must be either str, Message or InputMessage, but got: {message!r}')
entity = await self._get_input_peer(dialog)
if comment_to is not None:
entity, reply_to = await _get_comment_data(self, entity, comment_to)
elif reply_to:
reply_to = utils.get_message_id(reply_to)
if message._file:
# TODO Properly implement allow_cache to reuse the sha256 of the file
# i.e. `None` was used
# TODO album
if message._file._should_upload_thumb():
message._file._set_uploaded_thumb(await self._upload_file(message._file._thumb))
if message._file._should_upload_file():
message._file._set_uploaded_file(await self._upload_file(message._file._file))
request = _tl.fn.messages.SendMedia(
entity, message._file._media, reply_to_msg_id=reply_to, message=message._text,
entities=message._fmt_entities, reply_markup=message._reply_markup, silent=message._silent,
schedule_date=schedule, clear_draft=clear_draft,
background=background, noforwards=noforwards, send_as=send_as,
random_id=int.from_bytes(os.urandom(8), 'big', signed=True),
)
else:
request = _tl.fn.messages.SendMessage(
peer=entity,
message=message._text,
entities=formatting_entities,
no_webpage=not link_preview,
reply_to_msg_id=utils.get_message_id(reply_to),
clear_draft=clear_draft,
silent=silent,
background=background,
reply_markup=_custom.button.build_reply_markup(buttons),
schedule_date=schedule,
noforwards=noforwards,
send_as=send_as,
random_id=int.from_bytes(os.urandom(8), 'big', signed=True),
)
result = await self(request)
if isinstance(result, _tl.UpdateShortSentMessage):
return _custom.Message._new(self, _tl.Message(
id=result.id,
peer_id=await _get_peer(self, entity),
message=message._text,
date=result.date,
out=result.out,
media=result.media,
entities=result.entities,
reply_markup=request.reply_markup,
ttl_period=result.ttl_period
), {}, entity)
return self._get_response_message(request, result, entity)
async def forward_messages(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]',
from_dialog: 'hints.DialogLike' = None,
*,
background: bool = None,
with_my_score: bool = None,
silent: bool = None,
as_album: bool = None,
schedule: 'hints.DateLike' = None,
noforwards: bool = None,
send_as: 'hints.DialogLike' = None
) -> 'typing.Sequence[_tl.Message]':
if as_album is not None:
warnings.warn('the as_album argument is deprecated and no longer has any effect')
entity = await self._get_input_peer(dialog)
if from_dialog:
from_peer = await self._get_input_peer(from_dialog)
from_peer_id = await self._get_peer_id(from_peer)
else:
from_peer = None
from_peer_id = None
def get_key(m):
if isinstance(m, int):
if from_peer_id is not None:
return from_peer_id
raise ValueError('from_peer must be given if integer IDs are used')
elif isinstance(m, _tl.Message):
return m.chat_id
else:
raise TypeError('Cannot forward messages of type {}'.format(type(m)))
sent = []
for _chat_id, chunk in itertools.groupby(messages, key=get_key):
chunk = list(chunk)
if isinstance(chunk[0], int):
chat = from_peer
else:
chat = await chunk[0].get_input_chat()
chunk = [m.id for m in chunk]
req = _tl.fn.messages.ForwardMessages(
from_peer=chat,
id=chunk,
to_peer=entity,
silent=silent,
background=background,
with_my_score=with_my_score,
schedule_date=schedule,
noforwards=noforwards,
send_as=send_as,
random_id=[int.from_bytes(os.urandom(8), 'big', signed=True) for _ in chunk],
)
result = await self(req)
sent.extend(self._get_response_message(req, result, entity))
return sent[0] if single else sent
async def edit_message(
self: 'TelegramClient',
dialog: 'typing.Union[hints.DialogLike, _tl.Message]',
message: 'hints.MessageLike' = None,
text: str = None,
*,
parse_mode: str = (),
attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None,
formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None,
link_preview: bool = True,
file: 'hints.FileLike' = None,
thumb: 'hints.FileLike' = None,
force_document: bool = False,
buttons: 'hints.MarkupLike' = None,
supports_streaming: bool = False,
schedule: 'hints.DateLike' = None
) -> '_tl.Message':
if formatting_entities is None:
text, formatting_entities = await self._parse_message_text(text, parse_mode)
file_handle, media, image = await self._file_to_media(file,
supports_streaming=supports_streaming,
thumb=thumb,
attributes=attributes,
force_document=force_document)
if isinstance(message, _tl.InputBotInlineMessageID):
request = _tl.fn.messages.EditInlineBotMessage(
id=message,
message=text,
no_webpage=not link_preview,
entities=formatting_entities,
media=media,
reply_markup=_custom.button.build_reply_markup(buttons)
)
# Invoke `messages.editInlineBotMessage` from the right datacenter.
# Otherwise, Telegram will error with `MESSAGE_ID_INVALID` and do nothing.
exported = self._session_state.dc_id != entity.dc_id
if exported:
try:
sender = await self._borrow_exported_sender(entity.dc_id)
return await self._call(sender, request)
finally:
await self._return_exported_sender(sender)
else:
return await self(request)
entity = await self._get_input_peer(dialog)
request = _tl.fn.messages.EditMessage(
peer=entity,
id=utils.get_message_id(message),
message=text,
no_webpage=not link_preview,
entities=formatting_entities,
media=media,
reply_markup=_custom.button.build_reply_markup(buttons),
schedule_date=schedule
)
msg = self._get_response_message(request, await self(request), entity)
return msg
async def delete_messages(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]',
*,
revoke: bool = True) -> 'typing.Sequence[_tl.messages.AffectedMessages]':
messages = (
m.id if isinstance(m, (
_tl.Message, _tl.MessageService, _tl.MessageEmpty))
else int(m) for m in messages
)
if dialog:
entity = await self._get_input_peer(dialog)
ty = helpers._entity_type(entity)
else:
# no entity (None), set a value that's not a channel for private delete
entity = None
ty = helpers._EntityType.USER
if ty == helpers._EntityType.CHANNEL:
res = await self([_tl.fn.channels.DeleteMessages(
entity, list(c)) for c in utils.chunks(messages)])
else:
res = await self([_tl.fn.messages.DeleteMessages(
list(c), revoke) for c in utils.chunks(messages)])
return sum(r.pts_count for r in res)
async def mark_read(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
message: 'hints.MessageIDLike' = None,
*,
clear_mentions: bool = False,
clear_reactions: bool = False) -> bool:
if not message:
max_id = 0
elif isinstance(message, int):
max_id = message
else:
max_id = message.id
entity = await self._get_input_peer(dialog)
if clear_mentions:
await self(_tl.fn.messages.ReadMentions(entity))
if clear_reactions:
await self(_tl.fn.messages.ReadReactions(entity))
if helpers._entity_type(entity) == helpers._EntityType.CHANNEL:
return await self(_tl.fn.channels.ReadHistory(
utils.get_input_channel(entity), max_id=max_id))
else:
return await self(_tl.fn.messages.ReadHistory(
entity, max_id=max_id))
return False
async def pin_message(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
message: 'typing.Optional[hints.MessageIDLike]',
*,
notify: bool = False,
pm_oneside: bool = False
):
return await _pin(self, dialog, message, unpin=False, notify=notify, pm_oneside=pm_oneside)
async def unpin_message(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
message: 'typing.Optional[hints.MessageIDLike]' = None,
*,
notify: bool = False
):
return await _pin(self, dialog, message, unpin=True, notify=notify)
async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False):
message = utils.get_message_id(message) or 0
entity = await self._get_input_peer(entity)
if message <= 0: # old behaviour accepted negative IDs to unpin
await self(_tl.fn.messages.UnpinAllMessages(entity))
return
request = _tl.fn.messages.UpdatePinnedMessage(
peer=entity,
id=message,
silent=not notify,
unpin=unpin,
pm_oneside=pm_oneside
)
result = await self(request)
# Unpinning does not produce a service message.
# Pinning a message that was already pinned also produces no service message.
# Pinning a message in your own chat does not produce a service message,
# but pinning on a private conversation with someone else does.
if unpin or not result.updates:
return
# Pinning a message that doesn't exist would RPC-error earlier
return self._get_response_message(request, result, entity)
async def send_reaction(
self: 'TelegramClient',
entity: 'hints.DialogLike',
message: 'hints.MessageIDLike',
reaction: typing.Optional[str] = None,
big: bool = False
):
message = utils.get_message_id(message) or 0
if not reaction:
get_default_request = _tl.fn.help.GetAppConfig()
app_config = await self(get_default_request)
reaction = (
next(
(
y for y in app_config.value
if "reactions_default" in y.key
)
)
).value.value
request = _tl.fn.messages.SendReaction(
big=big,
peer=entity,
msg_id=message,
reaction=reaction
)
result = await self(request)
for update in result.updates:
if isinstance(update, _tl.UpdateMessageReactions):
return update.reactions
if isinstance(update, _tl.UpdateEditMessage):
return update.message.reactions
async def set_quick_reaction(
self: 'TelegramClient',
reaction: str
):
request = _tl.fn.messages.SetDefaultReaction(
reaction=reaction
)
return await self(request)

View File

@ -1,474 +0,0 @@
import abc
import re
import asyncio
import collections
import logging
import platform
import time
import typing
import ipaddress
import dataclasses
import functools
from .. import version, __name__ as __base_name__, _tl
from .._crypto import rsa
from .._misc import markdown, enums, helpers
from .._network import MTProtoSender, Connection, transports
from .._sessions import Session, SQLiteSession, MemorySession
from .._sessions.types import DataCenter, SessionState, EntityType, ChannelState
from .._updates import EntityCache, MessageBox
DEFAULT_DC_ID = 2
DEFAULT_IPV4_IP = '149.154.167.51'
DEFAULT_IPV6_IP = '2001:67c:4e8:f002::a'
DEFAULT_PORT = 443
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
_base_log = logging.getLogger(__base_name__)
# In seconds, how long to wait before disconnecting a exported sender.
_DISCONNECT_EXPORTED_AFTER = 60
class _ExportState:
def __init__(self):
# ``n`` is the amount of borrows a given sender has;
# once ``n`` reaches ``0``, disconnect the sender after a while.
self._n = 0
self._zero_ts = 0
self._connected = False
def add_borrow(self):
self._n += 1
self._connected = True
def add_return(self):
self._n -= 1
assert self._n >= 0, 'returned sender more than it was borrowed'
if self._n == 0:
self._zero_ts = time.time()
def should_disconnect(self):
return (self._n == 0
and self._connected
and (time.time() - self._zero_ts) > _DISCONNECT_EXPORTED_AFTER)
def need_connect(self):
return not self._connected
def mark_disconnected(self):
assert self.should_disconnect(), 'marked as disconnected when it was borrowed'
self._connected = False
# TODO How hard would it be to support both `trio` and `asyncio`?
def init(
self: 'TelegramClient',
session: 'typing.Union[str, Session]',
api_id: int,
api_hash: str,
*,
# Logging.
base_logger: typing.Union[str, logging.Logger] = None,
# Connection parameters.
use_ipv6: bool = False,
proxy: typing.Union[tuple, dict] = None,
local_addr: typing.Union[str, tuple] = None,
device_model: str = None,
system_version: str = None,
app_version: str = None,
lang_code: str = 'en',
system_lang_code: str = 'en',
# Nice-to-have.
auto_reconnect: bool = True,
connect_timeout: int = 10,
connect_retries: int = 4,
connect_retry_delay: int = 1,
request_retries: int = 4,
flood_sleep_threshold: int = 60,
# Update handling.
catch_up: bool = False,
receive_updates: bool = True,
max_queued_updates: int = 100,
):
# Logging.
if isinstance(base_logger, str):
base_logger = logging.getLogger(base_logger)
elif not isinstance(base_logger, logging.Logger):
base_logger = _base_log
class _Loggers(dict):
def __missing__(self, key):
if key.startswith("telethon."):
key = key.split('.', maxsplit=1)[1]
return base_logger.getChild(key)
self._log = _Loggers()
# Sessions.
if isinstance(session, str) or session is None:
try:
session = SQLiteSession(session)
except ImportError:
import warnings
warnings.warn(
'The sqlite3 module is not available under this '
'Python installation and no _ session '
'instance was given; using MemorySession.\n'
'You will need to re-login every time unless '
'you use another session storage'
)
session = MemorySession()
elif not isinstance(session, Session):
raise TypeError(
'The given session must be a str or a Session instance.'
)
self._session = session
# In-memory copy of the session's state to avoid a roundtrip as it contains commonly-accessed values.
self._session_state = _default_session_state()
# Nice-to-have.
self._request_retries = request_retries
self._connect_retries = connect_retries
self._connect_retry_delay = connect_retry_delay or 0
self._connect_timeout = connect_timeout
self.flood_sleep_threshold = flood_sleep_threshold
self._flood_waited_requests = {} # prevent calls that would floodwait entirely
self._phone_code_hash = None # used during login to prevent exposing the hash to end users
self._tos = None # used during signup and when fetching tos (tos/expiry)
# Update handling.
self._catch_up = catch_up
self._no_updates = not receive_updates
self._updates_queue = asyncio.Queue(maxsize=max_queued_updates)
self._updates_handle = None
self._update_handlers = [] # sorted list
self._dispatching_update_handlers = False # while dispatching, if add/remove are called, we need to make a copy
self._message_box = MessageBox()
self._entity_cache = EntityCache() # required for proper update handling (to know when to getDifference)
# Connection parameters.
if not api_id or not api_hash:
raise ValueError(
"Your API ID or Hash cannot be empty or None. "
"Refer to docs.telethon.dev for more information.")
if local_addr is not None:
if use_ipv6 is False and ':' in local_addr:
raise TypeError('A local IPv6 address must only be used with `use_ipv6=True`.')
elif use_ipv6 is True and ':' not in local_addr:
raise TypeError('`use_ipv6=True` must only be used with a local IPv6 address.')
self._transport = transports.Full()
self._use_ipv6 = use_ipv6
self._local_addr = local_addr
self._proxy = proxy
self._auto_reconnect = auto_reconnect
self._api_id = int(api_id)
self._api_hash = api_hash
# Used on connection. Capture the variables in a lambda since
# exporting clients need to create this InvokeWithLayer.
system = platform.uname()
if system.machine in ('x86_64', 'AMD64'):
default_device_model = 'PC 64bit'
elif system.machine in ('i386','i686','x86'):
default_device_model = 'PC 32bit'
else:
default_device_model = system.machine
default_system_version = re.sub(r'-.+','',system.release)
self._init_request = functools.partial(
_tl.fn.InitConnection,
api_id=self._api_id,
device_model=device_model or default_device_model or 'Unknown',
system_version=system_version or default_system_version or '1.0',
app_version=app_version or self.__version__,
lang_code=lang_code,
system_lang_code=system_lang_code,
lang_pack='', # "langPacks are for official apps only"
)
self._sender = MTProtoSender(
loggers=self._log,
retries=self._connect_retries,
delay=self._connect_retry_delay,
auto_reconnect=self._auto_reconnect,
connect_timeout=self._connect_timeout,
updates_queue=self._updates_queue,
)
# Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders.
self._borrowed_senders = {}
self._borrow_sender_lock = asyncio.Lock()
def get_flood_sleep_threshold(self):
return self._flood_sleep_threshold
def set_flood_sleep_threshold(self, value):
# None -> 0, negative values don't really matter
self._flood_sleep_threshold = min(value or 0, 24 * 60 * 60)
def _default_session_state():
return SessionState(
user_id=0,
dc_id=DEFAULT_DC_ID,
bot=False,
pts=0,
qts=0,
date=0,
seq=0,
takeout_id=None,
)
async def connect(self: 'TelegramClient') -> None:
all_dcs = {dc.id: dc for dc in await self._session.get_all_dc()}
self._session_state = await self._session.get_state()
if self._session_state is None:
try_fetch_user = False
self._session_state = _default_session_state()
else:
try_fetch_user = self._session_state.user_id == 0
if self._catch_up:
channel_states = await self._session.get_all_channel_states()
self._message_box.load(self._session_state, channel_states)
for state in channel_states:
entity = await self._session.get_entity(EntityType.CHANNEL, state.channel_id)
if entity:
self._entity_cache.put(entity)
dc = all_dcs.get(self._session_state.dc_id)
if dc is None:
dc = DataCenter(
id=DEFAULT_DC_ID,
ipv4=None if self._use_ipv6 else int(ipaddress.ip_address(DEFAULT_IPV4_IP)),
ipv6=int(ipaddress.ip_address(DEFAULT_IPV6_IP)) if self._use_ipv6 else None,
port=DEFAULT_PORT,
auth=b'',
)
all_dcs[dc.id] = dc
# Use known key, if any
self._sender.auth_key.key = dc.auth
if not await self._sender.connect(Connection(
ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)),
port=dc.port,
transport=self._transport.recreate_fresh(),
loggers=self._log,
local_addr=self._local_addr,
)):
# We don't want to init or modify anything if we were already connected
return
if self._sender.auth_key.key != dc.auth:
all_dcs[dc.id] = dc = dataclasses.replace(dc, auth=self._sender.auth_key.key)
# Need to send invokeWithLayer for things to work out.
# Make the most out of this opportunity by also refreshing our state.
# During the v1 to v2 migration, this also correctly sets the IPv* columns.
config = await self._sender.send(_tl.fn.InvokeWithLayer(
_tl.LAYER, self._init_request(query=_tl.fn.help.GetConfig())
))
for dc in config.dc_options:
if dc.media_only or dc.tcpo_only or dc.cdn:
continue
ip = int(ipaddress.ip_address(dc.ip_address))
if dc.id in all_dcs:
if dc.ipv6:
all_dcs[dc.id] = dataclasses.replace(all_dcs[dc.id], port=dc.port, ipv6=ip)
else:
all_dcs[dc.id] = dataclasses.replace(all_dcs[dc.id], port=dc.port, ipv4=ip)
elif dc.ipv6:
all_dcs[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'')
else:
all_dcs[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'')
for dc in all_dcs.values():
await self._session.insert_dc(dc)
if try_fetch_user:
# If there was a previous session state, but the current user ID is 0, it means we've
# migrated and not yet populated the current user (or the client connected but never
# logged in). Attempt to fetch the user now. If it works, also get the update state.
me = await self.get_me()
if me:
await self._update_session_state(me, save=False)
await self._session.save()
self._updates_handle = asyncio.create_task(self._update_loop())
def is_connected(self: 'TelegramClient') -> bool:
return self._sender.is_connected()
async def disconnect(self: 'TelegramClient'):
await _disconnect(self)
# Also clean-up all exported senders because we're done with them
async with self._borrow_sender_lock:
for state, sender in self._borrowed_senders.values():
# Note that we're not checking for `state.should_disconnect()`.
# If the user wants to disconnect the client, ALL connections
# to Telegram (including exported senders) should be closed.
#
# Disconnect should never raise, so there's no try/except.
await sender.disconnect()
# Can't use `mark_disconnected` because it may be borrowed.
state._connected = False
# If any was borrowed
self._borrowed_senders.clear()
def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]):
init_proxy = None
self._proxy = proxy
# While `await client.connect()` passes new proxy on each new call,
# auto-reconnect attempts use already set up `_connection` inside
# the `_sender`, so the only way to change proxy between those
# is to directly inject parameters.
connection = getattr(self._sender, "_connection", None)
if connection:
if isinstance(connection, conns.TcpMTProxy):
connection._ip = proxy[0]
connection._port = proxy[1]
else:
connection._proxy = proxy
async def _disconnect(self: 'TelegramClient'):
"""
Disconnect only, without closing the session. Used in reconnections
to different data centers, where we don't want to close the session
file; user disconnects however should close it since it means that
their job with the client is complete and we should clean it up all.
"""
await self._sender.disconnect()
await helpers._cancel(self._log[__name__], updates_handle=self._updates_handle)
try:
await self._updates_handle
except asyncio.CancelledError:
pass
await self._session.insert_entities(self._entity_cache.get_all_entities())
session_state, channel_states = self._message_box.session_state()
for channel_id, pts in channel_states.items():
await self._session.insert_channel_state(ChannelState(channel_id=channel_id, pts=pts))
await self._replace_session_state(**session_state)
async def _switch_dc(self: 'TelegramClient', new_dc):
"""
Permanently switches the current connection to the new data center.
"""
self._log[__name__].info('Reconnecting to new data center %s', new_dc)
await self._replace_session_state(dc_id=new_dc)
await _disconnect(self)
return await self.connect()
async def _create_exported_sender(self: 'TelegramClient', dc_id):
"""
Creates a new exported `MTProtoSender` for the given `dc_id` and
returns it. This method should be used by `_borrow_exported_sender`.
"""
# Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt
# for clearly showing how to export the authorization
dc = next(dc for dc in await self._session.get_all_dc() if dc.id == dc_id)
# Can't reuse self._sender._connection as it has its own seqno.
#
# If one were to do that, Telegram would reset the connection
# with no further clues.
sender = MTProtoSender(loggers=self._log)
await self._sender.connect(Connection(
ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)),
port=dc.port,
transport=self._transport.recreate_fresh(),
loggers=self._log,
local_addr=self._local_addr,
))
self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc)
auth = await self(_tl.fn.auth.ExportAuthorization(dc_id))
req = _tl.fn.InvokeWithLayer(_tl.LAYER, self._init_request(
query=_tl.fn.auth.ImportAuthorization(id=auth.id, bytes=auth.bytes)
))
await sender.send(req)
return sender
async def _borrow_exported_sender(self: 'TelegramClient', dc_id):
"""
Borrows a connected `MTProtoSender` for the given `dc_id`.
If it's not cached, creates a new one if it doesn't exist yet,
and imports a freshly exported authorization key for it to be usable.
Once its job is over it should be `_return_exported_sender`.
"""
async with self._borrow_sender_lock:
self._log[__name__].debug('Borrowing sender for dc_id %d', dc_id)
state, sender = self._borrowed_senders.get(dc_id, (None, None))
if state is None:
state = _ExportState()
sender = await _create_exported_sender(self, dc_id)
sender.dc_id = dc_id
self._borrowed_senders[dc_id] = (state, sender)
elif state.need_connect():
dc = next(dc for dc in await self._session.get_all_dc() if dc.id == dc_id)
await self._sender.connect(Connection(
ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)),
port=dc.port,
transport=self._transport.recreate_fresh(),
loggers=self._log,
local_addr=self._local_addr,
))
state.add_borrow()
return sender
async def _return_exported_sender(self: 'TelegramClient', sender):
"""
Returns a borrowed exported sender. If all borrows have
been returned, the sender is cleanly disconnected.
"""
async with self._borrow_sender_lock:
self._log[__name__].debug('Returning borrowed sender for dc_id %d', sender.dc_id)
state, _ = self._borrowed_senders[sender.dc_id]
state.add_return()
async def _clean_exported_senders(self: 'TelegramClient'):
"""
Cleans-up all unused exported senders by disconnecting them.
"""
async with self._borrow_sender_lock:
for dc_id, (state, sender) in self._borrowed_senders.items():
if state.should_disconnect():
self._log[__name__].info(
'Disconnecting borrowed sender for DC %d', dc_id)
# Disconnect should never raise
await sender.disconnect()
state.mark_disconnected()

File diff suppressed because it is too large Load Diff

View File

@ -1,282 +0,0 @@
import asyncio
import inspect
import itertools
import random
import sys
import time
import traceback
import typing
import logging
import inspect
import bisect
import warnings
from collections import deque
from ..errors._rpcbase import RpcError
from .._events.raw import Raw
from .._events.base import StopPropagation, EventBuilder, EventHandler
from .._events.filters import make_filter, NotResolved
from .._misc import utils
from .. import _tl
from ..types._custom import User, Chat
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
Callback = typing.Callable[[typing.Any], typing.Any]
async def set_receive_updates(self: 'TelegramClient', receive_updates):
self._no_updates = not receive_updates
if receive_updates:
await self(_tl.fn.updates.GetState())
async def run_until_disconnected(self: 'TelegramClient'):
# Make a high-level request to notify that we want updates
await self(_tl.fn.updates.GetState())
await self._sender.wait_disconnected()
def on(self: 'TelegramClient', *events, priority=0, **filters):
def decorator(f):
for event in events:
self.add_event_handler(f, event, priority=priority, **filters)
return f
return decorator
def add_event_handler(
self: 'TelegramClient',
callback=None,
event=None,
priority=0,
**filters
):
if callback is None:
return functools.partial(add_event_handler, self, event=event, priority=priority, **filters)
if event is None:
for param in inspect.signature(callback).parameters.values():
event = None if param.annotation is inspect.Signature.empty else param.annotation
break # only check the first parameter
if event is None:
event = Raw
if not inspect.iscoroutinefunction(callback):
raise TypeError(f'callback was not an async def function: {callback!r}')
if not isinstance(event, type):
raise TypeError(f'event type was not a type (an instance of something was probably used): {event!r}')
if not isinstance(priority, int):
raise TypeError(f'priority was not an integer: {priority!r}')
if not issubclass(event, EventBuilder):
try:
if event.SUBCLASS_OF_ID != 0x9f89304e:
raise TypeError(f'invalid raw update type for the event handler: {event!r}')
if 'types' in filters:
warnings.warn('"types" filter is ignored when the event type already is a raw update')
filters['types'] = event
event = Raw
except AttributeError:
raise TypeError(f'unrecognized event handler type: {param.annotation!r}')
handler = EventHandler(event, callback, priority, make_filter(**filters))
if self._dispatching_update_handlers:
# Now that there's a copy, we're no longer dispatching from the old update_handlers,
# so we can modify it. This is why we can turn the flag off.
self._update_handlers = self._update_handlers[:]
self._dispatching_update_handlers = False
bisect.insort(self._update_handlers, handler)
return handler
def remove_event_handler(
self: 'TelegramClient',
callback=None,
event=None,
*,
priority=None,
):
if callback is None and event is None and priority is None:
raise ValueError('must specify at least one of callback, event or priority')
if not self._update_handlers:
return [] # won't be removing anything (some code paths rely on non-empty lists)
if self._dispatching_update_handlers:
# May be an unnecessary copy if nothing was removed, but that's not a big deal.
self._update_handlers = self._update_handlers[:]
self._dispatching_update_handlers = False
if isinstance(callback, EventHandler):
if event is not None or priority is not None:
warnings.warn('event and priority are ignored when removing EventHandler instances')
index = bisect.bisect_left(self._update_handlers, callback)
try:
if self._update_handlers[index] == callback:
return [self._update_handlers.pop(index)]
except IndexError:
pass
return []
if priority is not None:
# can binary-search (using a dummy EventHandler)
index = bisect.bisect_right(self._update_handlers, EventHandler(None, None, priority, None))
try:
while self._update_handlers[index].priority == priority:
index += 1
except IndexError:
pass
removed = []
while index > 0 and self._update_handlers[index - 1].priority == priority:
index -= 1
if callback is not None and self._update_handlers[index].callback != callback:
continue
if event is not None and self._update_handlers[index].event != event:
continue
removed.append(self._update_handlers.pop(index))
return removed
# slow-path, remove all matching
removed = []
for index in reversed(range(len(self._update_handlers))):
handler = self._update_handlers[index]
if callback is not None and handler._callback != callback:
continue
if event is not None and handler._event != event:
continue
removed.append(self._update_handlers.pop(index))
return removed
def list_event_handlers(self: 'TelegramClient')\
-> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]':
return self._update_handlers[:]
async def catch_up(self: 'TelegramClient'):
# The update loop is probably blocked on either timeout or an update to arrive.
# Unblock the loop by pushing a dummy update which will always trigger a gap.
# This, in return, causes the update loop to catch up.
await self._updates_queue.put(_tl.UpdatesTooLong())
async def _update_loop(self: 'TelegramClient'):
try:
updates_to_dispatch = deque()
while self.is_connected:
if updates_to_dispatch:
await _dispatch(self, *updates_to_dispatch.popleft())
continue
get_diff = self._message_box.get_difference()
if get_diff:
self._log[__name__].info('Getting difference for account updates')
diff = await self(get_diff)
updates, users, chats = self._message_box.apply_difference(diff, self._entity_cache)
updates_to_dispatch.extend(_preprocess_updates(self, updates, users, chats))
continue
get_diff = self._message_box.get_channel_difference(self._entity_cache)
if get_diff:
self._log[__name__].info('Getting difference for channel updates')
diff = await self(get_diff)
updates, users, chats = self._message_box.apply_channel_difference(get_diff, diff, self._entity_cache)
updates_to_dispatch.extend(_preprocess_updates(self, updates, users, chats))
continue
deadline = self._message_box.check_deadlines()
try:
updates = await asyncio.wait_for(
self._updates_queue.get(),
deadline - asyncio.get_running_loop().time()
)
except asyncio.TimeoutError:
self._log[__name__].info('Timeout waiting for updates expired')
continue
processed = []
try:
users, chats = self._message_box.process_updates(updates, self._entity_cache, processed)
except GapError:
continue # get(_channel)_difference will start returning requests
updates_to_dispatch.extend(_preprocess_updates(self, processed, users, chats))
except Exception:
self._log[__name__].exception('Fatal error handling updates (this is a bug in Telethon, please report it)')
def _preprocess_updates(self, updates, users, chats):
self._entity_cache.extend(users, chats)
entities = Entities(self, users, chats)
return ((u, entities) for u in updates)
class Entities:
def __init__(self, client, users, chats):
self.self_id = client._session_state.user_id
self._client = client
self._entities = {e.id: e for e in itertools.chain(
(User._new(client, u) for u in users),
(Chat._new(client, c) for u in chats),
)}
def get(self, peer):
if not peer:
return None
id = utils.get_peer_id(peer)
try:
return self._entities[id]
except KeyError:
entity = self._client._entity_cache.get(query.user_id)
if not entity:
raise RuntimeError('Update is missing a hash but did not trigger a gap')
self._entities[entity.id] = User(self._client, entity) if entity.is_user else Chat(self._client, entity)
return self._entities[entity.id]
async def _dispatch(self, update, entities):
self._dispatching_update_handlers = True
try:
event_cache = {}
for handler in self._update_handlers:
event = event_cache.get(handler._event)
if not event:
# build can fail if we're missing an access hash; we want this to crash
event_cache[handler._event] = event = handler._event._build(self, update, entities)
while True:
# filters can be modified at any time, and there can be any amount of them which are not yet resolved
try:
if handler._filter(event):
try:
await handler._callback(event)
except StopPropagation:
return
except Exception:
name = getattr(handler._callback, '__name__', repr(handler._callback))
self._log[__name__].exception('Unhandled exception on %s (this is likely a bug in your code)', name)
except NotResolved as nr:
try:
await nr.unresolved.resolve()
continue
except Exception as e:
# we cannot really do much about this; it might be a temporary network issue
warnings.warn(f'failed to resolve filter, handler will be skipped: {e}: {nr.unresolved!r}')
except Exception as e:
# invalid filter (e.g. types when types were not used as input)
warnings.warn(f'invalid filter applied, handler will be skipped: {e}: {e.filter!r}')
# we only want to continue on unresolved filter (to check if there are more unresolved)
break
finally:
self._dispatching_update_handlers = False

View File

@ -1,406 +0,0 @@
import hashlib
import io
import itertools
import os
import pathlib
import re
import typing
from io import BytesIO
from .._crypto import AES
from .._misc import utils, helpers, hints
from ..types import _custom
from .. import _tl
try:
import PIL
import PIL.Image
except ImportError:
PIL = None
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
def _resize_photo_if_needed(
file, is_image, width=1280, height=1280, background=(255, 255, 255)):
# https://github.com/telegramdesktop/tdesktop/blob/12905f0dcb9d513378e7db11989455a1b764ef75/Telegram/SourceFiles/boxes/photo_crop_box.cpp#L254
if (not is_image
or PIL is None
or (isinstance(file, io.IOBase) and not file.seekable())):
return file
if isinstance(file, bytes):
file = io.BytesIO(file)
before = file.tell() if isinstance(file, io.IOBase) else None
try:
# Don't use a `with` block for `image`, or `file` would be closed.
# See https://github.com/LonamiWebs/Telethon/issues/1121 for more.
image = PIL.Image.open(file)
try:
kwargs = {'exif': image.info['exif']}
except KeyError:
kwargs = {}
if image.width <= width and image.height <= height:
return file
image.thumbnail((width, height), PIL.Image.ANTIALIAS)
alpha_index = image.mode.find('A')
if alpha_index == -1:
# If the image mode doesn't have alpha
# channel then don't bother masking it away.
result = image
else:
# We could save the resized image with the original format, but
# JPEG often compresses better -> smaller size -> faster upload
# We need to mask away the alpha channel ([3]), since otherwise
# IOError is raised when trying to save alpha channels in JPEG.
result = PIL.Image.new('RGB', image.size, background)
result.paste(image, mask=image.split()[alpha_index])
buffer = io.BytesIO()
result.save(buffer, 'JPEG', **kwargs)
buffer.seek(0)
return buffer
except IOError:
return file
finally:
if before is not None:
file.seek(before, io.SEEK_SET)
async def send_file(
self: 'TelegramClient',
dialog: 'hints.DialogLike',
file: typing.Optional[hints.FileLike] = None,
*,
# - Message contents
# Formatting
caption: 'hints.MessageLike' = '',
markdown: str = None,
html: str = None,
formatting_entities: list = None,
link_preview: bool = (),
# Media
file_name: str = None,
mime_type: str = None,
thumb: str = False,
force_file: bool = False,
file_size: int = None,
# Media attributes
duration: int = None,
width: int = None,
height: int = None,
title: str = None,
performer: str = None,
supports_streaming: bool = False,
video_note: bool = False,
voice_note: bool = False,
waveform: bytes = None,
# Additional parametrization
silent: bool = False,
buttons: list = None,
ttl: int = None,
# - Send options
reply_to: 'typing.Union[int, _tl.Message]' = None,
clear_draft: bool = False,
background: bool = None,
noforwards: bool = None,
send_as: 'hints.DialogLike' = None,
schedule: 'hints.DateLike' = None,
comment_to: 'typing.Union[int, _tl.Message]' = None,
) -> '_tl.Message':
self.send_message(
dialog=dialog,
message=caption,
markdown=markdown,
html=html,
formatting_entities=formatting_entities,
link_preview=link_preview,
file=file,
file_name=file_name,
mime_type=mime_type,
thumb=thumb,
force_file=force_file,
file_size=file_size,
duration=duration,
width=width,
height=height,
title=title,
performer=performer,
supports_streaming=supports_streaming,
video_note=video_note,
voice_note=voice_note,
waveform=waveform,
silent=silent,
buttons=buttons,
ttl=ttl,
reply_to=reply_to,
clear_draft=clear_draft,
background=background,
schedule=schedule,
comment_to=comment_to,
noforwards=noforwards,
send_as=send_as
)
async def _send_album(self: 'TelegramClient', entity, files, caption='',
progress_callback=None, reply_to=None,
parse_mode=(), silent=None, schedule=None,
supports_streaming=None, clear_draft=None,
force_document=False, background=None, ttl=None,
send_as=None, noforwards=None):
"""Specialized version of .send_file for albums"""
# We don't care if the user wants to avoid cache, we will use it
# anyway. Why? The cached version will be exactly the same thing
# 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_peer(entity)
if not utils.is_list_like(caption):
caption = (caption,)
captions = []
for c in reversed(caption): # Pop from the end (so reverse)
captions.append(await self._parse_message_text(c or '', parse_mode))
reply_to = utils.get_message_id(reply_to)
# Need to upload the media first, but only if they're not cached yet
media = []
for file in files:
# Albums want :tl:`InputMedia` which, in theory, includes
# :tl:`InputMediaUploadedPhoto`. However using that will
# make it `raise MediaInvalidError`, so we need to upload
# it as media and then convert that to :tl:`InputMediaPhoto`.
fh, fm, _ = await _file_to_media(
self, file, supports_streaming=supports_streaming,
force_document=force_document, ttl=ttl)
if isinstance(fm, (_tl.InputMediaUploadedPhoto, _tl.InputMediaPhotoExternal)):
r = await self(_tl.fn.messages.UploadMedia(
entity, media=fm
))
fm = utils.get_input_media(r.photo)
elif isinstance(fm, _tl.InputMediaUploadedDocument):
r = await self(_tl.fn.messages.UploadMedia(
entity, media=fm
))
fm = utils.get_input_media(
r.document, supports_streaming=supports_streaming)
if captions:
caption, msg_entities = captions.pop()
else:
caption, msg_entities = '', None
media.append(_tl.InputSingleMedia(
fm,
message=caption,
entities=msg_entities
# random_id is autogenerated
))
# Now we can construct the multi-media request
request = _tl.fn.messages.SendMultiMedia(
entity, reply_to_msg_id=reply_to, multi_media=media,
silent=silent, schedule_date=schedule, clear_draft=clear_draft,
background=background, noforwards=noforwards, send_as=send_as
)
result = await self(request)
random_ids = [m.random_id for m in media]
return self._get_response_message(random_ids, result, entity)
async def upload_file(
self: 'TelegramClient',
file: 'hints.FileLike',
*,
part_size_kb: float = None,
file_size: int = None,
file_name: str = None,
use_cache: type = None,
key: bytes = None,
iv: bytes = None,
progress_callback: 'hints.ProgressCallback' = None) -> '_tl.TypeInputFile':
"""
Uploads a file to Telegram's servers, without sending it.
.. note::
Generally, you want to use `send_file` instead.
This method returns a handle (an instance of :tl:`InputFile` or
:tl:`InputFileBig`, as required) which can be later used before
it expires (they are usable during less than a day).
Uploading a file will simply return a "handle" to the file stored
remotely in the Telegram servers, which can be later used on. This
will **not** upload the file to your own chat or any chat at all.
Arguments
file (`str` | `bytes` | `file`):
The path of the file, byte array, or stream that will be sent.
Note that if a byte array or a stream is given, a filename
or its type won't be inferred, and it will be sent as an
"unnamed application/octet-stream".
part_size_kb (`int`, optional):
Chunk size when uploading files. The larger, the less
requests will be made (up to 512KB maximum).
file_size (`int`, optional):
The size of the file to be uploaded, which will be determined
automatically if not specified.
If the file size can't be determined beforehand, the entire
file will be read in-memory to find out how large it is.
file_name (`str`, optional):
The file name which will be used on the resulting InputFile.
If not specified, the name will be taken from the ``file``
and if this is not a `str`, it will be ``"unnamed"``.
use_cache (`type`, optional):
This parameter currently does nothing, but is kept for
backward-compatibility (and it may get its use back in
the future).
key ('bytes', optional):
In case of an encrypted upload (secret chats) a key is supplied
iv ('bytes', optional):
In case of an encrypted upload (secret chats) an iv is supplied
progress_callback (`callable`, optional):
A callback function accepting two parameters:
``(sent bytes, total)``.
Returns
:tl:`InputFileBig` if the file size is larger than 10MB,
`InputSizedFile <telethon.tl._custom.inputsizedfile.InputSizedFile>`
(subclass of :tl:`InputFile`) otherwise.
Example
.. code-block:: python
# Photos as photo and document
file = await client.upload_file('photo.jpg')
await client.send_file(chat, file) # sends as photo
await client.send_file(chat, file, force_document=True) # sends as document
file.name = 'not a photo.jpg'
await client.send_file(chat, file, force_document=True) # document, new name
# As song or as voice note
file = await client.upload_file('song.ogg')
await client.send_file(chat, file) # sends as song
await client.send_file(chat, file, voice_note=True) # sends as voice note
"""
if isinstance(file, (_tl.InputFile, _tl.InputFileBig)):
return file # Already uploaded
pos = 0
async with helpers._FileStream(file, file_size=file_size) as stream:
# Opening the stream will determine the correct file size
file_size = stream.file_size
if not part_size_kb:
part_size_kb = utils.get_appropriated_part_size(file_size)
if part_size_kb > 512:
raise ValueError('The part size must be less or equal to 512KB')
part_size = int(part_size_kb * 1024)
if part_size % 1024 != 0:
raise ValueError(
'The part size must be evenly divisible by 1024')
# Set a default file name if None was specified
file_id = helpers.generate_random_long()
if not file_name:
file_name = stream.name or str(file_id)
# If the file name lacks extension, add it if possible.
# Else Telegram complains with `PHOTO_EXT_INVALID_ERROR`
# even if the uploaded image is indeed a photo.
if not os.path.splitext(file_name)[-1]:
file_name += utils._get_extension(stream)
# Determine whether the file is too big (over 10MB) or not
# Telegram does make a distinction between smaller or larger files
is_big = file_size > 10 * 1024 * 1024
hash_md5 = hashlib.md5()
part_count = (file_size + part_size - 1) // part_size
self._log[__name__].info('Uploading file of %d bytes in %d chunks of %d',
file_size, part_count, part_size)
pos = 0
for part_index in range(part_count):
# Read the file by in chunks of size part_size
part = await helpers._maybe_await(stream.read(part_size))
if not isinstance(part, bytes):
raise TypeError(
'file descriptor returned {}, not bytes (you must '
'open the file in bytes mode)'.format(type(part)))
# `file_size` could be wrong in which case `part` may not be
# `part_size` before reaching the end.
if len(part) != part_size and part_index < part_count - 1:
raise ValueError(
'read less than {} before reaching the end; either '
'`file_size` or `read` are wrong'.format(part_size))
pos += len(part)
# Encryption part if needed
if key and iv:
part = AES.encrypt_ige(part, key, iv)
if not is_big:
# Bit odd that MD5 is only needed for small files and not
# big ones with more chance for corruption, but that's
# what Telegram wants.
hash_md5.update(part)
# The SavePart is different depending on whether
# the file is too large or not (over or less than 10MB)
if is_big:
request = _tl.fn.upload.SaveBigFilePart(
file_id, part_index, part_count, part)
else:
request = _tl.fn.upload.SaveFilePart(
file_id, part_index, part)
result = await self(request)
if result:
self._log[__name__].debug('Uploaded %d/%d',
part_index + 1, part_count)
if progress_callback:
await helpers._maybe_await(progress_callback(pos, file_size))
else:
raise RuntimeError(
'Failed to upload file part {}.'.format(part_index))
if is_big:
return _tl.InputFileBig(file_id, part_count, file_name)
else:
return _custom.InputSizedFile(
file_id, part_count, file_name, md5=hash_md5, size=file_size
)

View File

@ -1,398 +0,0 @@
import asyncio
import datetime
import itertools
import time
import typing
import dataclasses
from ..errors._custom import MultiError
from ..errors._rpcbase import RpcError, ServerError, FloodError, InvalidDcError, UnauthorizedError
from .._misc import helpers, utils, hints
from .._sessions.types import Entity
from .. import errors, _tl
from ..types import _custom
from .account import ignore_takeout
_NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!')
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta):
return (
'Sleeping%s for %ds (%s) on %s flood wait',
' early' if early else '',
delay,
td(seconds=delay),
request.__class__.__name__
)
async def call(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None):
return await _call(self, self._sender, request, ordered=ordered, flood_sleep_threshold=flood_sleep_threshold)
async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None):
if flood_sleep_threshold is None:
flood_sleep_threshold = self.flood_sleep_threshold
requests = (request if utils.is_list_like(request) else (request,))
new_requests = []
for r in requests:
if not isinstance(r, _tl.TLRequest):
raise _NOT_A_REQUEST()
r = await r._resolve(self, utils)
# Avoid making the request if it's already in a flood wait
if r.CONSTRUCTOR_ID in self._flood_waited_requests:
due = self._flood_waited_requests[r.CONSTRUCTOR_ID]
diff = round(due - time.time())
if diff <= 3: # Flood waits below 3 seconds are "ignored"
self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None)
elif diff <= flood_sleep_threshold:
self._log[__name__].info(*_fmt_flood(diff, r, early=True))
await asyncio.sleep(diff)
self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None)
else:
raise errors.FLOOD_WAIT(420, f'FLOOD_WAIT_{diff}', request=r)
if self._session_state.takeout_id and not ignore_takeout.get():
r = _tl.fn.InvokeWithTakeout(self._session_state.takeout_id, r)
if self._no_updates:
r = _tl.fn.InvokeWithoutUpdates(r)
new_requests.append(r)
request = new_requests if utils.is_list_like(request) else new_requests[0]
request_index = 0
last_error = None
self._last_request = time.time()
for attempt in helpers.retry_range(self._request_retries):
try:
future = sender.send(request, ordered=ordered)
if isinstance(future, list):
results = []
exceptions = []
for f in future:
try:
result = await f
except RpcError as e:
exceptions.append(e)
results.append(None)
continue
exceptions.append(None)
results.append(result)
request_index += 1
if any(x is not None for x in exceptions):
raise MultiError(exceptions, results, requests)
else:
return results
else:
result = await future
return result
except ServerError as e:
last_error = e
self._log[__name__].warning(
'Telegram is having internal issues %s: %s',
e.__class__.__name__, e)
await asyncio.sleep(2)
except FloodError as e:
last_error = e
if utils.is_list_like(request):
request = request[request_index]
# SLOWMODE_WAIT is chat-specific, not request-specific
if not isinstance(e, errors.SLOWMODE_WAIT):
self._flood_waited_requests\
[request.CONSTRUCTOR_ID] = time.time() + e.seconds
# In test servers, FLOOD_WAIT_0 has been observed, and sleeping for
# such a short amount will cause retries very fast leading to issues.
if e.seconds == 0:
e.seconds = 1
if e.seconds <= self.flood_sleep_threshold:
self._log[__name__].info(*_fmt_flood(e.seconds, request))
await asyncio.sleep(e.seconds)
else:
raise
except InvalidDcError as e:
last_error = e
self._log[__name__].info('Phone migrated to %d', e.new_dc)
should_raise = isinstance(e, (
errors.PHONE_MIGRATE, errors.NETWORK_MIGRATE
))
if should_raise and await self.is_user_authorized():
raise
await self._switch_dc(e.new_dc)
raise last_error
async def get_me(self: 'TelegramClient') \
-> 'typing.Union[_tl.User, _tl.InputPeerUser]':
try:
return _custom.User._new(self, (await self(_tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0])
except UnauthorizedError:
return None
async def is_bot(self: 'TelegramClient') -> bool:
return self._session_state.bot if self._session_state else False
async def is_user_authorized(self: 'TelegramClient') -> bool:
try:
# Any request that requires authorization will work
await self(_tl.fn.updates.GetState())
return True
except RpcError:
return False
async def get_profile(
self: 'TelegramClient',
profile: 'hints.DialogsLike') -> 'hints.Entity':
entity = profile
single = not utils.is_list_like(entity)
if single:
entity = (entity,)
# Group input entities by string (resolve username),
# input users (get users), input chat (get chats) and
# input channels (get channels) to get the most entities
# in the less amount of calls possible.
inputs = []
for x in entity:
if isinstance(x, str):
inputs.append(x)
else:
inputs.append(await self._get_input_peer(x))
lists = {
helpers._EntityType.USER: [],
helpers._EntityType.CHAT: [],
helpers._EntityType.CHANNEL: [],
}
for x in inputs:
try:
lists[helpers._entity_type(x)].append(x)
except TypeError:
pass
users = lists[helpers._EntityType.USER]
chats = lists[helpers._EntityType.CHAT]
channels = lists[helpers._EntityType.CHANNEL]
if users:
# GetUsers has a limit of 200 per call
tmp = []
while users:
curr, users = users[:200], users[200:]
tmp.extend(await self(_tl.fn.users.GetUsers(curr)))
users = tmp
if chats: # TODO Handle chats slice?
chats = (await self(
_tl.fn.messages.GetChats([x.chat_id for x in chats]))).chats
if channels:
channels = (await self(
_tl.fn.channels.GetChannels(channels))).chats
# Merge users, chats and channels into a single dictionary
id_entity = {
utils.get_peer_id(x): x
for x in itertools.chain(users, chats, channels)
}
# We could check saved usernames and put them into the users,
# chats and channels list from before. While this would reduce
# the amount of ResolveUsername calls, it would fail to catch
# username changes.
result = []
for x in inputs:
if isinstance(x, str):
result.append(await _get_entity_from_string(self, x))
elif not isinstance(x, _tl.InputPeerSelf):
result.append(id_entity[utils.get_peer_id(x)])
else:
result.append(next(
u for u in id_entity.values()
if isinstance(u, _tl.User) and u.is_self
))
return result[0] if single else result
async def _get_input_peer(
self: 'TelegramClient',
peer: 'hints.DialogLike') -> '_tl.TypeInputPeer':
# Short-circuit if the input parameter directly maps to an InputPeer
try:
return utils.get_input_peer(peer)
except TypeError:
pass
# Then come known strings that take precedence
if peer in ('me', 'self'):
return _tl.InputPeerSelf()
# No InputPeer, cached peer, or known string. Fetch from session cache
try:
peer_id = utils.get_peer_id(peer)
except TypeError:
pass
else:
entity = await self._session.get_entity(None, peer_id)
if entity:
if entity.ty in (Entity.USER, Entity.BOT):
return _tl.InputPeerUser(entity.id, entity.hash)
elif entity.ty in (Entity.GROUP):
return _tl.InputPeerChat(peer.chat_id)
elif entity.ty in (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP):
return _tl.InputPeerChannel(entity.id, entity.hash)
# Only network left to try
if isinstance(peer, str):
return utils.get_input_peer(
await _get_entity_from_string(self, peer))
# If we're a bot and the user has messaged us privately users.getUsers
# will work with access_hash = 0. Similar for channels.getChannels.
# If we're not a bot but the user is in our contacts, it seems to work
# regardless. These are the only two special-cased requests.
peer = utils.get_peer(peer)
if isinstance(peer, _tl.PeerUser):
users = await self(_tl.fn.users.GetUsers([
_tl.InputUser(peer.user_id, access_hash=0)]))
if users and not isinstance(users[0], _tl.UserEmpty):
# If the user passed a valid ID they expect to work for
# channels but would be valid for users, we get UserEmpty.
# Avoid returning the invalid empty input peer for that.
#
# We *could* try to guess if it's a channel first, and if
# it's not, work as a chat and try to validate it through
# another request, but that becomes too much work.
return utils.get_input_peer(users[0])
elif isinstance(peer, _tl.PeerChat):
return _tl.InputPeerChat(peer.chat_id)
elif isinstance(peer, _tl.PeerChannel):
try:
channels = await self(_tl.fn.channels.GetChannels([
_tl.InputChannel(peer.channel_id, access_hash=0)]))
return utils.get_input_peer(channels.chats[0])
except errors.CHANNEL_INVALID:
pass
raise ValueError(
'Could not find the input peer for {} ({}). Please read https://'
'docs.telethon.dev/en/latest/concepts/entities.html to'
' find out more details.'
.format(peer, type(peer).__name__)
)
async def _get_peer_id(
self: 'TelegramClient',
peer: 'hints.DialogLike') -> int:
if isinstance(peer, int):
return utils.get_peer_id(peer)
try:
if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6):
# 0x2d45687, 0xc91c90b6 == crc32(b'Peer') and b'InputPeer'
peer = await self._get_input_peer(peer)
except AttributeError:
peer = await self._get_input_peer(peer)
if isinstance(peer, _tl.InputPeerSelf):
peer = _tl.PeerUser(self._session_state.user_id)
return utils.get_peer_id(peer)
async def _get_entity_from_string(self: 'TelegramClient', string):
"""
Gets a full entity from the given string, which may be a phone or
a username, and processes all the found entities on the session.
The string may also be a user link, or a channel/chat invite link.
This method has the side effect of adding the found users to the
session database, so it can be queried later without API calls,
if this option is enabled on the session.
Returns the found entity, or raises TypeError if not found.
"""
phone = utils.parse_phone(string)
if phone:
try:
for user in (await self(
_tl.fn.contacts.GetContacts(0))).users:
if user.phone == phone:
return user
except errors.BOT_METHOD_INVALID:
raise ValueError('Cannot get entity by phone number as a '
'bot (try using integer IDs, not strings)')
elif string.lower() in ('me', 'self'):
return await self.get_me()
else:
username, is_join_chat = utils.parse_username(string)
if is_join_chat:
invite = await self(
_tl.fn.messages.CheckChatInvite(username))
if isinstance(invite, _tl.ChatInvite):
raise ValueError(
'Cannot get entity from a channel (or group) '
'that you are not part of. Join the group and retry'
)
elif isinstance(invite, _tl.ChatInviteAlready):
return invite.chat
elif username:
try:
result = await self(
_tl.fn.contacts.ResolveUsername(username))
except errors.USERNAME_NOT_OCCUPIED as e:
raise ValueError('No user has "{}" as username'
.format(username)) from e
try:
pid = utils.get_peer_id(result.peer)
if isinstance(result.peer, _tl.PeerUser):
return next(x for x in result.users if x.id == pid)
else:
return next(x for x in result.chats if x.id == pid)
except StopIteration:
pass
raise ValueError(
'Cannot find any entity corresponding to "{}"'.format(string)
)
async def _get_input_dialog(self: 'TelegramClient', dialog):
"""
Returns a :tl:`InputDialogPeer`. This is a bit tricky because
it may or not need access to the client to convert what's given
into an input entity.
"""
try:
if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer')
return dataclasses.replace(dialog, peer=await self._get_input_peer(dialog.peer))
elif dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer')
return _tl.InputDialogPeer(dialog)
except AttributeError:
pass
return _tl.InputDialogPeer(await self._get_input_peer(dialog))
async def _get_input_notify(self: 'TelegramClient', notify):
"""
Returns a :tl:`InputNotifyPeer`. This is a bit tricky because
it may or not need access to the client to convert what's given
into an input entity.
"""
try:
if notify.SUBCLASS_OF_ID == 0x58981615:
if isinstance(notify, _tl.InputNotifyPeer):
return dataclasses.replace(notify, peer=await self._get_input_peer(notify.peer))
return notify
except AttributeError:
pass
return _tl.InputNotifyPeer(await self._get_input_peer(notify))

View File

@ -1,339 +0,0 @@
import asyncio
import time
import weakref
from .base import EventBuilder
from .._misc import utils
from .. import _tl
from ..types import _custom
_IGNORE_MAX_SIZE = 100 # len()
_IGNORE_MAX_AGE = 5 # seconds
# IDs to ignore, and when they were added. If it grows too large, we will
# remove old entries. Although it should generally not be bigger than 10,
# it may be possible some updates are not processed and thus not removed.
_IGNORE_DICT = {}
_HACK_DELAY = 0.5
class AlbumHack:
"""
When receiving an album from a different data-center, they will come in
separate `Updates`, so we need to temporarily remember them for a while
and only after produce the event.
Of course events are not designed for this kind of wizardy, so this is
a dirty hack that gets the job done.
When cleaning up the code base we may want to figure out a better way
to do this, or just leave the album problem to the users; the update
handling code is bad enough as it is.
"""
def __init__(self, client, event):
# It's probably silly to use a weakref here because this object is
# very short-lived but might as well try to do "the right thing".
self._client = weakref.ref(client)
self._event = event # parent event
self._due = asyncio.get_running_loop().time() + _HACK_DELAY
asyncio.create_task(self.deliver_event())
def extend(self, messages):
client = self._client()
if client: # weakref may be dead
self._event.messages.extend(messages)
self._due = asyncio.get_running_loop().time() + _HACK_DELAY
async def deliver_event(self):
while True:
client = self._client()
if client is None:
return # weakref is dead, nothing to deliver
diff = self._due - asyncio.get_running_loop().time()
if diff <= 0:
# We've hit our due time, deliver event. It won't respect
# sequential updates but fixing that would just worsen this.
await client._dispatch_event(self._event)
return
del client # Clear ref and sleep until our due time
await asyncio.sleep(diff)
class Album(EventBuilder, _custom.chatgetter.ChatGetter, _custom.sendergetter.SenderGetter):
"""
Occurs whenever you receive an album. This event only exists
to ease dealing with an unknown amount of messages that belong
to the same album.
Members:
messages (Sequence[`Message <telethon.tl._custom.message.Message>`]):
The list of messages belonging to the same album.
Example
.. code-block:: python
from telethon import events
@client.on(events.Album)
async def handler(event):
# Counting how many photos or videos the album has
print('Got an album with', len(event), 'items')
# Forwarding the album as a whole to some chat
event.forward_to(chat)
# Printing the caption
print(event.text)
# Replying to the fifth item in the album
await event.messages[4].reply('Cool!')
"""
def __init__(self, messages):
message = messages[0]
if not message.out and isinstance(message.peer_id, _tl.PeerUser):
# Incoming message (e.g. from a bot) has peer_id=us, and
# from_id=bot (the actual "chat" from a user's perspective).
chat_peer = message.from_id
else:
chat_peer = message.peer_id
_custom.chatgetter.ChatGetter.__init__(self, chat_peer=chat_peer, broadcast=bool(message.post))
_custom.sendergetter.SenderGetter.__init__(self, message.sender_id)
self.messages = messages
def _build(cls, client, update, entities):
if not others:
return # We only care about albums which come inside the same Updates
if isinstance(update,
(_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)):
if not isinstance(update.message, _tl.Message):
return # We don't care about MessageService's here
group = update.message.grouped_id
if group is None:
return # It must be grouped
# Check whether we are supposed to skip this update, and
# if we do also remove it from the ignore list since we
# won't need to check against it again.
if _IGNORE_DICT.pop(id(update), None):
return
# Check if the ignore list is too big, and if it is clean it
# TODO time could technically go backwards; time is not monotonic
now = time.time()
if len(_IGNORE_DICT) > _IGNORE_MAX_SIZE:
for i in [i for i, t in _IGNORE_DICT.items() if now - t > _IGNORE_MAX_AGE]:
del _IGNORE_DICT[i]
# Add the other updates to the ignore list
for u in others:
if u is not update:
_IGNORE_DICT[id(u)] = now
# Figure out which updates share the same group and use those
return cls.Event([
u.message for u in others
if (isinstance(u, (_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage))
and isinstance(u.message, _tl.Message)
and u.message.grouped_id == group)
])
self = cls.__new__(cls)
self._client = client
self._sender = entities.get(_tl.PeerUser(update.user_id))
self._chat = entities.get(_tl.PeerUser(update.user_id))
return self
def _set_client(self, client):
super()._set_client(client)
self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities)
self.messages = [
_custom.Message._new(client, m, self._entities, None)
for m in self.messages
]
if len(self.messages) == 1:
# This will require hacks to be a proper album event
hack = client._albums.get(self.grouped_id)
if hack is None:
client._albums[self.grouped_id] = AlbumHack(client, self)
else:
hack.extend(self.messages)
@property
def grouped_id(self):
"""
The shared ``grouped_id`` between all the messages.
"""
return self.messages[0].grouped_id
@property
def text(self):
"""
The message text of the first photo with a caption,
formatted using the client's default parse mode.
"""
return next((m.text for m in self.messages if m.text), '')
@property
def raw_text(self):
"""
The raw message text of the first photo
with a caption, ignoring any formatting.
"""
return next((m.raw_text for m in self.messages if m.raw_text), '')
@property
def is_reply(self):
"""
`True` if the album is a reply to some other message.
Remember that you can access the ID of the message
this one is replying to through `reply_to_msg_id`,
and the `Message` object with `get_reply_message()`.
"""
# Each individual message in an album all reply to the same message
return self.messages[0].is_reply
@property
def forward(self):
"""
The `Forward <telethon.tl._custom.forward.Forward>`
information for the first message in the album if it was forwarded.
"""
# Each individual message in an album all reply to the same message
return self.messages[0].forward
# endregion Public Properties
# region Public Methods
async def get_reply_message(self):
"""
The `Message <telethon.tl._custom.message.Message>`
that this album is replying to, or `None`.
The result will be cached after its first use.
"""
return await self.messages[0].get_reply_message()
async def respond(self, *args, **kwargs):
"""
Responds to the album (not as a reply). Shorthand for
`telethon.client.messages.MessageMethods.send_message`
with ``entity`` already set.
"""
return await self.messages[0].respond(*args, **kwargs)
async def reply(self, *args, **kwargs):
"""
Replies to the first photo in the album (as a reply). Shorthand
for `telethon.client.messages.MessageMethods.send_message`
with both ``entity`` and ``reply_to`` already set.
"""
return await self.messages[0].reply(*args, **kwargs)
async def forward_to(self, *args, **kwargs):
"""
Forwards the entire album. Shorthand for
`telethon.client.messages.MessageMethods.forward_messages`
with both ``messages`` and ``from_peer`` already set.
"""
if self._client:
kwargs['messages'] = self.messages
kwargs['from_peer'] = await self.get_input_chat()
return await self._client.forward_messages(*args, **kwargs)
async def edit(self, *args, **kwargs):
"""
Edits the first caption or the message, or the first messages'
caption if no caption is set, iff it's outgoing. Shorthand for
`telethon.client.messages.MessageMethods.edit_message`
with both ``entity`` and ``message`` already set.
Returns `None` if the message was incoming,
or the edited `Message` otherwise.
.. note::
This is different from `client.edit_message
<telethon.client.messages.MessageMethods.edit_message>`
and **will respect** the previous state of the message.
For example, if the message didn't have a link preview,
the edit won't add one by default, and you should force
it by setting it to `True` if you want it.
This is generally the most desired and convenient behaviour,
and will work for link previews and message buttons.
"""
for msg in self.messages:
if msg.raw_text:
return await msg.edit(*args, **kwargs)
return await self.messages[0].edit(*args, **kwargs)
async def delete(self, *args, **kwargs):
"""
Deletes the entire album. You're responsible for checking whether
you have the permission to do so, or to except the error otherwise.
Shorthand for
`telethon.client.messages.MessageMethods.delete_messages` with
``entity`` and ``message_ids`` already set.
"""
if self._client:
return await self._client.delete_messages(
await self.get_input_chat(), self.messages,
*args, **kwargs
)
async def mark_read(self):
"""
Marks the entire album as read. Shorthand for
`client.mark_read()
<telethon.client.messages.MessageMethods.mark_read>`
with both ``entity`` and ``message`` already set.
"""
if self._client:
await self._client.mark_read(
await self.get_input_chat(), max_id=self.messages[-1].id)
async def pin(self, *, notify=False):
"""
Pins the first photo in the album. Shorthand for
`telethon.client.messages.MessageMethods.pin_message`
with both ``entity`` and ``message`` already set.
"""
return await self.messages[0].pin(notify=notify)
def __len__(self):
"""
Return the amount of messages in the album.
Equivalent to ``len(self.messages)``.
"""
return len(self.messages)
def __iter__(self):
"""
Iterate over the messages in the album.
Equivalent to ``iter(self.messages)``.
"""
return iter(self.messages)
def __getitem__(self, n):
"""
Access the n'th message in the album.
Equivalent to ``event.messages[n]``.
"""
return self.messages[n]

View File

@ -1,63 +0,0 @@
import abc
import functools
from .filters import Filter
class StopPropagation(Exception):
"""
If this exception is raised in any of the handlers for a given event,
it will stop the execution of all other registered event handlers.
It can be seen as the ``StopIteration`` in a for loop but for events.
Example usage:
>>> from telethon import TelegramClient, events
>>> client = TelegramClient(...)
>>>
>>> @client.on(events.NewMessage)
... async def delete(event):
... await event.delete()
... # No other event handler will have a chance to handle this event
... raise StopPropagation
...
>>> @client.on(events.NewMessage)
... async def _(event):
... # Will never be reached, because it is the second handler
... pass
"""
# For some reason Sphinx wants the silly >>> or
# it will show warnings and look bad when generated.
pass
class EventBuilder(abc.ABC):
@classmethod
@abc.abstractmethod
def _build(cls, client, update, entities):
"""
Builds an event for the given update if possible, or returns None.
`entities` must have `get(Peer) -> User|Chat` and `self_id`,
which must be the current user's ID.
"""
@functools.total_ordering
class EventHandler:
__slots__ = ('_event', '_callback', '_priority', '_filter')
def __init__(self, event: EventBuilder, callback: callable, priority: int, filter: Filter):
self._event = event
self._callback = callback
self._priority = priority
self._filter = filter
def __eq__(self, other):
return self is other
def __lt__(self, other):
return self._priority < other._priority
def __call__(self, *args, **kwargs):
return self._callback(*args, **kwargs)

View File

@ -1,267 +0,0 @@
import re
import struct
import asyncio
import functools
from .base import EventBuilder
from .._misc import utils
from .. import _tl
from ..types import _custom
def auto_answer(func):
@functools.wraps(func)
async def wrapped(self, *args, **kwargs):
if self._answered:
return await func(*args, **kwargs)
else:
return (await asyncio.gather(
self._answer(),
func(*args, **kwargs),
))[1]
return wrapped
class CallbackQuery(EventBuilder, _custom.chatgetter.ChatGetter, _custom.sendergetter.SenderGetter):
"""
Occurs whenever you sign in as a bot and a user
clicks one of the inline buttons on your messages.
Note that the `chats` parameter will **not** work with normal
IDs or peers if the clicked inline button comes from a "via bot"
message. The `chats` parameter also supports checking against the
`chat_instance` which should be used for inline callbacks.
Members:
query (:tl:`UpdateBotCallbackQuery`):
The original :tl:`UpdateBotCallbackQuery`.
data_match (`obj`, optional):
The object returned by the ``data=`` parameter
when creating the event builder, if any. Similar
to ``pattern_match`` for the new message event.
pattern_match (`obj`, optional):
Alias for ``data_match``.
Example
.. code-block:: python
from telethon import events, Button
# Handle all callback queries and check data inside the handler
@client.on(events.CallbackQuery)
async def handler(event):
if event.data == b'yes':
await event.answer('Correct answer!')
# Handle only callback queries with data being b'no'
@client.on(events.CallbackQuery(data=b'no'))
async def handler(event):
# Pop-up message with alert
await event.answer('Wrong answer!', alert=True)
# Send a message with buttons users can click
async def main():
await client.send_message(user, 'Yes or no?', buttons=[
Button.inline('Yes!', b'yes'),
Button.inline('Nope', b'no')
])
"""
@classmethod
def _build(cls, client, update, entities):
query = update
if isinstance(update, _tl.UpdateBotCallbackQuery):
peer = update.peer
msg_id = update.msg_id
elif isinstance(update, _tl.UpdateInlineBotCallbackQuery):
# See https://github.com/LonamiWebs/Telethon/pull/1005
# The long message ID is actually just msg_id + peer_id
msg_id, pid = struct.unpack('<ii', struct.pack('<q', update.msg_id.id))
peer = _tl.PeerChannel(-pid) if pid < 0 else _tl.PeerUser(pid)
else:
return None
self = cls.__new__(cls)
self._client = client
self._sender = entities.get(_tl.PeerUser(query.user_id))
self._chat = entities.get(peer)
self.query = query
self.data_match = None
self.pattern_match = None
self._message = None
self._answered = False
return self
@property
def id(self):
"""
Returns the query ID. The user clicking the inline
button is the one who generated this random ID.
"""
return self.query.query_id
@property
def message_id(self):
"""
Returns the message ID to which the clicked inline button belongs.
"""
return self._message_id
@property
def data(self):
"""
Returns the data payload from the original inline button.
"""
return self.query.data
@property
def chat_instance(self):
"""
Unique identifier for the chat where the callback occurred.
Useful for high scores in games.
"""
return self.query.chat_instance
async def get_message(self):
"""
Returns the message to which the clicked inline button belongs.
"""
if self._message is not None:
return self._message
try:
self._message = await self._client.get_messages(self.chat, ids=self._message_id)
except ValueError:
return
return self._message
async def answer(
self, message=None, cache_time=0, *, url=None, alert=False):
"""
Answers the callback query (and stops the loading circle).
Args:
message (`str`, optional):
The toast message to show feedback to the user.
cache_time (`int`, optional):
For how long this result should be cached on
the user's client. Defaults to 0 for no cache.
url (`str`, optional):
The URL to be opened in the user's client. Note that
the only valid URLs are those of games your bot has,
or alternatively a 't.me/your_bot?start=xyz' parameter.
alert (`bool`, optional):
Whether an alert (a pop-up dialog) should be used
instead of showing a toast. Defaults to `False`.
"""
if self._answered:
return
res = await self._client(_tl.fn.messages.SetBotCallbackAnswer(
query_id=self.query.query_id,
cache_time=cache_time,
alert=alert,
message=message,
url=url,
))
self._answered = True
return res
@property
def via_inline(self):
"""
Whether this callback was generated from an inline button sent
via an inline query or not. If the bot sent the message itself
with buttons, and one of those is clicked, this will be `False`.
If a user sent the message coming from an inline query to the
bot, and one of those is clicked, this will be `True`.
If it's `True`, it's likely that the bot is **not** in the
chat, so methods like `respond` or `delete` won't work (but
`edit` will always work).
"""
return isinstance(self.query, _tl.UpdateInlineBotCallbackQuery)
@auto_answer
async def respond(self, *args, **kwargs):
"""
Responds to the message (not as a reply). Shorthand for
`telethon.client.messages.MessageMethods.send_message` with
``entity`` already set.
This method will also `answer` the callback if necessary.
This method will likely fail if `via_inline` is `True`.
"""
return await self._client.send_message(
await self.get_input_chat(), *args, **kwargs)
@auto_answer
async def reply(self, *args, **kwargs):
"""
Replies to the message (as a reply). Shorthand for
`telethon.client.messages.MessageMethods.send_message` with
both ``entity`` and ``reply_to`` already set.
This method will also `answer` the callback if necessary.
This method will likely fail if `via_inline` is `True`.
"""
kwargs['reply_to'] = self.query.msg_id
return await self._client.send_message(
await self.get_input_chat(), *args, **kwargs)
@auto_answer
async def edit(self, *args, **kwargs):
"""
Edits the message. Shorthand for
`telethon.client.messages.MessageMethods.edit_message` with
the ``entity`` set to the correct :tl:`InputBotInlineMessageID`.
Returns `True` if the edit was successful.
This method will also `answer` the callback if necessary.
.. note::
This method won't respect the previous message unlike
`Message.edit <telethon.tl._custom.message.Message.edit>`,
since the message object is normally not present.
"""
if isinstance(self.query.msg_id, _tl.InputBotInlineMessageID):
return await self._client.edit_message(
None, self.query.msg_id, *args, **kwargs
)
else:
return await self._client.edit_message(
await self.get_input_chat(), self.query.msg_id,
*args, **kwargs
)
@auto_answer
async def delete(self, *args, **kwargs):
"""
Deletes the message. Shorthand for
`telethon.client.messages.MessageMethods.delete_messages` with
``entity`` and ``message_ids`` already set.
If you need to delete more than one message at once, don't use
this `delete` method. Use a
`telethon.client.telegramclient.TelegramClient` instance directly.
This method will also `answer` the callback if necessary.
This method will likely fail if `via_inline` is `True`.
"""
return await self._client.delete_messages(
await self.get_input_chat(), [self.query.msg_id],
*args, **kwargs
)

View File

@ -1,451 +0,0 @@
from .base import EventBuilder
from .._misc import utils
from .. import _tl
from ..types import _custom
class ChatAction(EventBuilder):
"""
Occurs on certain chat actions:
* Whenever a new chat is created.
* Whenever a chat's title or photo is changed or removed.
* Whenever a new message is pinned.
* Whenever a user scores in a game.
* Whenever a user joins or is added to the group.
* Whenever a user is removed or leaves a group if it has
less than 50 members or the removed user was a bot.
Note that "chat" refers to "small group, megagroup and broadcast
channel", whereas "group" refers to "small group and megagroup" only.
Members:
action_message (`MessageAction <https://tl.telethon.dev/types/message_action.html>`_):
The message invoked by this Chat Action.
new_pin (`bool`):
`True` if there is a new pin.
new_photo (`bool`):
`True` if there's a new chat photo (or it was removed).
photo (:tl:`Photo`, optional):
The new photo (or `None` if it was removed).
user_added (`bool`):
`True` if the user was added by some other.
user_joined (`bool`):
`True` if the user joined on their own.
user_left (`bool`):
`True` if the user left on their own.
user_kicked (`bool`):
`True` if the user was kicked by some other.
user_approved (`bool`):
`True` if the user's join request was approved.
along with `user_joined` will be also True.
created (`bool`, optional):
`True` if this chat was just created.
new_title (`str`, optional):
The new title string for the chat, if applicable.
new_score (`str`, optional):
The new score string for the game, if applicable.
unpin (`bool`):
`True` if the existing pin gets unpinned.
Example
.. code-block:: python
from telethon import events
@client.on(events.ChatAction)
async def handler(event):
# Welcome every new user
if event.user_joined:
await event.reply('Welcome to the group!')
"""
@classmethod
def _build(cls, client, update, entities):
where = None
new_photo = None
added_by = None
kicked_by = None
created = None
from_approval = None
users = None
new_title = None
pin_ids = None
pin = None
new_score = None
# Rely on specific pin updates for unpins, but otherwise ignore them
# for new pins (we'd rather handle the new service message with pin,
# so that we can act on that message').
if isinstance(update, _tl.UpdatePinnedChannelMessages) and not update.pinned:
where = _tl.PeerChannel(update.channel_id)
pin_ids = update.messages
pin = update.pinned
elif isinstance(update, _tl.UpdatePinnedMessages) and not update.pinned:
where = update.peer
pin_ids = update.messages
pin = update.pinned
elif isinstance(update, _tl.UpdateChatParticipantAdd):
where = _tl.PeerChat(update.chat_id)
added_by = update.inviter_id or True
users = update.user_id
elif isinstance(update, _tl.UpdateChatParticipantDelete):
where = _tl.PeerChat(update.chat_id)
kicked_by = True
users = update.user_id
# UpdateChannel is sent if we leave a channel, and the update._entities
# set by _process_update would let us make some guesses. However it's
# better not to rely on this. Rely only in MessageActionChatDeleteUser.
elif (isinstance(update, (
_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage))
and isinstance(update.message, _tl.MessageService)):
msg = update.message
action = update.message.action
if isinstance(action, _tl.MessageActionChatJoinedByLink):
where = msg
added_by = True
users = msg.from_id
elif isinstance(action, _tl.MessageActionChatAddUser):
# If a user adds itself, it means they joined via the public chat username
added_by = ([msg.sender_id] == action.users) or msg.from_id
where = msg
added_by = added_by
users = action.users
elif isinstance(action, _tl.MessageActionChatJoinedByRequest):
# user joined from join request (after getting admin approval)
where = msg
from_approval = True
users = msg.from_id
elif isinstance(action, _tl.MessageActionChatDeleteUser):
where = msg
kicked_by = utils.get_peer_id(msg.from_id) if msg.from_id else True
users = action.user_id
elif isinstance(action, _tl.MessageActionChatCreate):
where = msg
users = action.users
created = True
new_title = action.title
elif isinstance(action, _tl.MessageActionChannelCreate):
where = msg
created = True
users = msg.from_id
new_title = action.title
elif isinstance(action, _tl.MessageActionChatEditTitle):
where = msg
users = msg.from_id
new_title = action.title
elif isinstance(action, _tl.MessageActionChatEditPhoto):
where = msg
users = msg.from_id
new_photo = action.photo
elif isinstance(action, _tl.MessageActionChatDeletePhoto):
where = msg
users = msg.from_id
new_photo = True
elif isinstance(action, _tl.MessageActionPinMessage) and msg.reply_to:
where = msg
pin_ids=[msg.reply_to_msg_id]
elif isinstance(action, _tl.MessageActionGameScore):
where = msg
new_score = action.score
self = cls.__new__(cls)
self._client = client
if isinstance(where, _tl.MessageService):
self.action_message = where
where = where.peer_id
else:
self.action_message = None
self._chat = entities.get(where)
self.new_pin = pin_ids is not None
self._pin_ids = pin_ids
self._pinned_messages = None
self.new_photo = new_photo is not None
self.photo = \
new_photo if isinstance(new_photo, _tl.Photo) else None
self._added_by = None
self._kicked_by = None
self.user_added = self.user_joined = self.user_left = \
self.user_kicked = self.unpin = False
if added_by is True or from_approval is True:
self.user_joined = True
elif added_by:
self.user_added = True
self._added_by = added_by
self.user_approved = from_approval
# If `from_id` was not present (it's `True`) or the affected
# user was "kicked by itself", then it left. Else it was kicked.
if kicked_by is True or (users is not None and kicked_by == users):
self.user_left = True
elif kicked_by:
self.user_kicked = True
self._kicked_by = kicked_by
self.created = bool(created)
if isinstance(users, list):
self._user_ids = [utils.get_peer_id(u) for u in users]
elif users:
self._user_ids = [utils.get_peer_id(users)]
else:
self._user_ids = []
self._users = None
self._input_users = None
self.new_title = new_title
self.new_score = new_score
self.unpin = not pin
return self
async def respond(self, *args, **kwargs):
"""
Responds to the chat action message (not as a reply). Shorthand for
`telethon.client.messages.MessageMethods.send_message` with
``entity`` already set.
"""
return await self._client.send_message(
await self.get_input_chat(), *args, **kwargs)
async def reply(self, *args, **kwargs):
"""
Replies to the chat action message (as a reply). Shorthand for
`telethon.client.messages.MessageMethods.send_message` with
both ``entity`` and ``reply_to`` already set.
Has the same effect as `respond` if there is no message.
"""
if not self.action_message:
return await self.respond(*args, **kwargs)
kwargs['reply_to'] = self.action_message.id
return await self._client.send_message(
await self.get_input_chat(), *args, **kwargs)
async def delete(self, *args, **kwargs):
"""
Deletes the chat action message. You're responsible for checking
whether you have the permission to do so, or to except the error
otherwise. Shorthand for
`telethon.client.messages.MessageMethods.delete_messages` with
``entity`` and ``message_ids`` already set.
Does nothing if no message action triggered this event.
"""
if not self.action_message:
return
return await self._client.delete_messages(
await self.get_input_chat(), [self.action_message],
*args, **kwargs
)
async def get_pinned_message(self):
"""
If ``new_pin`` is `True`, this returns the `Message
<telethon.tl.custom.message.Message>` object that was pinned.
"""
if self._pinned_messages is None:
await self.get_pinned_messages()
if self._pinned_messages:
return self._pinned_messages[0]
async def get_pinned_messages(self):
"""
If ``new_pin`` is `True`, this returns a `list` of `Message
<telethon.tl.custom.message.Message>` objects that were pinned.
"""
if not self._pin_ids:
return self._pin_ids # either None or empty list
chat = await self.get_input_chat()
if chat:
self._pinned_messages = await self._client.get_messages(
self._input_chat, ids=self._pin_ids)
return self._pinned_messages
@property
def added_by(self):
"""
The user who added ``users``, if applicable (`None` otherwise).
"""
if self._added_by and not isinstance(self._added_by, _tl.User):
aby = self._entities.get(utils.get_peer_id(self._added_by))
if aby:
self._added_by = aby
return self._added_by
async def get_added_by(self):
"""
Returns `added_by` but will make an API call if necessary.
"""
if not self.added_by and self._added_by:
self._added_by = await self._client.get_profile(self._added_by)
return self._added_by
@property
def kicked_by(self):
"""
The user who kicked ``users``, if applicable (`None` otherwise).
"""
if self._kicked_by and not isinstance(self._kicked_by, _tl.User):
kby = self._entities.get(utils.get_peer_id(self._kicked_by))
if kby:
self._kicked_by = kby
return self._kicked_by
async def get_kicked_by(self):
"""
Returns `kicked_by` but will make an API call if necessary.
"""
if not self.kicked_by and self._kicked_by:
self._kicked_by = await self._client.get_profile(self._kicked_by)
return self._kicked_by
@property
def user(self):
"""
The first user that takes part in this action. For example, who joined.
Might be `None` if the information can't be retrieved or
there is no user taking part.
"""
if self.users:
return self._users[0]
async def get_user(self):
"""
Returns `user` but will make an API call if necessary.
"""
if self.users or await self.get_users():
return self._users[0]
@property
def input_user(self):
"""
Input version of the ``self.user`` property.
"""
if self.input_users:
return self._input_users[0]
async def get_input_user(self):
"""
Returns `input_user` but will make an API call if necessary.
"""
if self.input_users or await self.get_input_users():
return self._input_users[0]
@property
def user_id(self):
"""
Returns the marked signed ID of the first user, if any.
"""
if self._user_ids:
return self._user_ids[0]
@property
def users(self):
"""
A list of users that take part in this action. For example, who joined.
Might be empty if the information can't be retrieved or there
are no users taking part.
"""
if not self._user_ids:
return []
if self._users is None:
self._users = [
self._entities[user_id]
for user_id in self._user_ids
if user_id in self._entities
]
return self._users
async def get_users(self):
"""
Returns `users` but will make an API call if necessary.
"""
if not self._user_ids:
return []
# Note: we access the property first so that it fills if needed
if (self.users is None or len(self._users) != len(self._user_ids)) and self.action_message:
await self.action_message._reload_message()
self._users = [
u for u in self.action_message.action_entities
if isinstance(u, (_tl.User, _tl.UserEmpty))]
return self._users
@property
def input_users(self):
"""
Input version of the ``self.users`` property.
"""
if self._input_users is None and self._user_ids:
self._input_users = []
for user_id in self._user_ids:
# Try to get it from our entities
try:
self._input_users.append(utils.get_input_peer(self._entities[user_id]))
continue
except (KeyError, TypeError):
pass
return self._input_users or []
async def get_input_users(self):
"""
Returns `input_users` but will make an API call if necessary.
"""
if not self._user_ids:
return []
# Note: we access the property first so that it fills if needed
if (self.input_users is None or len(self._input_users) != len(self._user_ids)) and self.action_message:
self._input_users = [
utils.get_input_peer(u)
for u in self.action_message.action_entities
if isinstance(u, (_tl.User, _tl.UserEmpty))]
return self._input_users or []
@property
def user_ids(self):
"""
Returns the marked signed ID of the users, if any.
"""
if self._user_ids:
return self._user_ids[:]

View File

@ -1,150 +0,0 @@
from .base import Filter, And, Or, Not, Identity, Always, Never
from .generic import Types
from .entities import Chats, Senders
from .messages import Incoming, Outgoing, Pattern, Data
_sentinel = object()
def make_filter(
chats=_sentinel,
blacklist_chats=_sentinel,
func=_sentinel,
types=_sentinel,
incoming=_sentinel,
outgoing=_sentinel,
senders=_sentinel,
blacklist_senders=_sentinel,
forwards=_sentinel,
pattern=_sentinel,
data=_sentinel,
):
"""
Create a new `And` filter joining all the filters specified as input parameters.
Not all filters may have an effect on all events.
chats (`entity`, optional):
May be one or more entities (username/peer/etc.), preferably IDs.
By default, only matching chats will be handled.
blacklist_chats (`bool`, optional):
Whether to treat the chats as a blacklist instead of
as a whitelist (default). This means that every chat
will be handled *except* those specified in ``chats``
which will be ignored if ``blacklist_chats=True``.
func (`callable`, optional):
A callable (async or not) function that should accept the event as input
parameter, and return a value indicating whether the event
should be dispatched or not (any truthy value will do, it
does not need to be a `bool`). It works like a custom filter:
.. code-block:: python
@client.on(events.NewMessage(func=lambda e: e.is_private))
async def handler(event):
pass # code here
incoming (`bool`, optional):
If set to `True`, only **incoming** messages will be handled.
If set to `False`, incoming messages will be ignored.
If both incoming are outgoing are set, whichever is true will be handled.
outgoing (`bool`, optional):
If set to `True`, only **outgoing** messages will be handled.
If set to `False`, outgoing messages will be ignored.
If both incoming are outgoing are set, whichever is true will be handled.
senders (`entity`, optional):
Unlike `chats`, this parameter filters the *senders* of the
message. That is, only messages *sent by these users* will be
handled. Use `chats` if you want private messages with this/these
users. `senders` lets you filter by messages sent by *one or
more* users across the desired chats (doesn't need a list).
blacklist_senders (`bool`):
Whether to treat the senders as a blacklist instead of
as a whitelist (default). This means that every sender
will be handled *except* those specified in ``senders``
which will be ignored if ``blacklist_senders=True``.
forwards (`bool`, optional):
Whether forwarded messages should be handled or not. By default,
both forwarded and normal messages are included. If it's `True`
*only* forwards will be handled. If it's `False` only messages
that are *not* forwards will be handled.
pattern (`str`, `callable`, `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.
data (`bytes`, `str`, `callable`, optional):
If set, the inline button payload data must match this data.
A UTF-8 string can also be given, a regex or a callable. For
instance, to check against ``'data_1'`` and ``'data_2'`` you
can use ``re.compile(b'data_')``.
types (`list` | `tuple` | `type`, optional):
The type or types that the :tl:`Update` instance must be.
Equivalent to ``if not isinstance(update, types): return``.
"""
filters = []
if chats is not _sentinel:
f = Chats(chats)
if blacklist_chats is not _sentinel and blacklist_chats:
f = Not(f)
filters.append(f)
if func is not _sentinel:
filters.append(Identity(func))
if types is not _sentinel:
filters.append(Types(types))
if incoming is not _sentinel:
if outgoing is not _sentinel:
if incoming and outgoing:
pass # no need to filter
elif incoming:
filters.append(Incoming())
elif outgoing:
filters.append(Outgoing())
else:
return Never() # why?
elif incoming:
filters.append(Incoming())
else:
filters.append(Outgoing())
elif outgoing is not _sentinel:
if outgoing:
filters.append(Outgoing())
else:
filters.append(Incoming())
if senders is not _sentinel:
f = Senders(senders)
if blacklist_senders is not _sentinel and blacklist_senders:
f = Not(f)
filters.append(f)
if forwards is not _sentinel:
filters.append(Forward())
if pattern is not _sentinel:
filters.append(Pattern(pattern))
if data is not _sentinel:
filters.append(Data(data))
return And(*filters) if filters else Always()
class NotResolved(ValueError):
def __init__(self, unresolved):
super().__init__()
self.unresolved = unresolved

View File

@ -1,78 +0,0 @@
import abc
class Filter(abc.ABC):
@abc.abstractmethod
def __call__(self, event):
return True
def __and__(self, other):
return And(self, other)
def __or__(self, other):
return Or(self, other)
def __invert__(self):
return Not(self)
class And(Filter):
"""
All underlying filters must return `True` for this filter to be `True`.
"""
def __init__(self, *filters):
self._filters = filters
def __call__(self, event):
return all(f(event) for f in self._filters)
class Or(Filter):
"""
At least one underlying filter must return `True` for this filter to be `True`.
"""
def __init__(self, *filters):
self._filters = filters
def __call__(self, event):
return any(f(event) for f in self._filters)
class Not(Filter):
"""
The underlying filter must return `False` for this filter to be `True`.
"""
def __init__(self, filter):
self._filter = filter
def __call__(self, event):
return not self._filter(event)
class Identity(Filter):
"""
Return the value of the underlying filter (or callable) without any modifications.
"""
def __init__(self, filter):
self._filter = filter
def __call__(self, event):
return self._filter(event)
class Always(Filter):
"""
This filter always returns `True`, and is used as the "empty filter".
"""
def __call__(self, event):
return True
class Never(Filter):
"""
This filter always returns `False`, and is used when an impossible filter is made
(for example, neither outgoing nor incoming is always false). This can be used to
"turn off" handlers without removing them.
"""
def __call__(self, event):
return False

View File

@ -1,25 +0,0 @@
from .base import Filter
class Chats:
"""
The update type must match the specified instances for the filter to return `True`.
This is most useful for raw API.
"""
def __init__(self, types):
self._types = types
def __call__(self, event):
return isinstance(event, self._types)
class Senders:
"""
The update type must match the specified instances for the filter to return `True`.
This is most useful for raw API.
"""
def __init__(self, types):
self._types = types
def __call__(self, event):
return isinstance(event, self._types)

View File

@ -1,13 +0,0 @@
from .base import Filter
class Types:
"""
The update type must match the specified instances for the filter to return `True`.
This is most useful for raw API.
"""
def __init__(self, types):
self._types = types
def __call__(self, event):
return isinstance(event, self._types)

View File

@ -1,44 +0,0 @@
import re
from .base import Filter
class Incoming:
"""
The update must be something the client received from another user,
and not something the current user sent.
"""
def __call__(self, event):
return not event.out
class Outgoing:
"""
The update must be something the current user sent,
and not something received from another user.
"""
def __call__(self, event):
return event.out
class Pattern:
"""
The update type must match the specified instances for the filter to return `True`.
This is most useful for raw API.
"""
def __init__(self, pattern):
self._pattern = re.compile(pattern).match
def __call__(self, event):
return self._pattern(event.text)
class Data:
"""
The update type must match the specified instances for the filter to return `True`.
This is most useful for raw API.
"""
def __init__(self, data):
self._data = re.compile(data).match
def __call__(self, event):
return self._data(event.data)

View File

@ -1,200 +0,0 @@
import inspect
import re
import asyncio
from .base import EventBuilder
from .._misc import utils
from .. import _tl
from ..types import _custom
class InlineQuery(EventBuilder, _custom.chatgetter.ChatGetter, _custom.sendergetter.SenderGetter):
"""
Occurs whenever you sign in as a bot and a user
sends an inline query such as ``@bot query``.
Members:
query (:tl:`UpdateBotInlineQuery`):
The original :tl:`UpdateBotInlineQuery`.
Make sure to access the `text` property of the query if
you want the text rather than the actual query object.
pattern_match (`obj`, optional):
The resulting object from calling the passed ``pattern``
function, which is ``re.compile(...).match`` by default.
Example
.. code-block:: python
from telethon import events
@client.on(events.InlineQuery)
async def handler(event):
builder = event.builder
# Two options (convert user text to UPPERCASE or lowercase)
await event.answer([
builder.article('UPPERCASE', text=event.text.upper()),
builder.article('lowercase', text=event.text.lower()),
])
"""
@classmethod
def _build(cls, client, update, entities):
if not isinstance(update, _tl.UpdateBotInlineQuery):
return None
self = cls.__new__(cls)
self._client = client
self._sender = entities.get(_tl.PeerUser(update.user_id))
self._chat = entities.get(_tl.PeerUser(update.user_id))
self.query = update
self.pattern_match = None
self._answered = False
return self
@property
def id(self):
"""
Returns the unique identifier for the query ID.
"""
return self.query.query_id
@property
def text(self):
"""
Returns the text the user used to make the inline query.
"""
return self.query.query
@property
def offset(self):
"""
The string the user's client used as an offset for the query.
This will either be empty or equal to offsets passed to `answer`.
"""
return self.query.offset
@property
def geo(self):
"""
If the user location is requested when using inline mode
and the user's device is able to send it, this will return
the :tl:`GeoPoint` with the position of the user.
"""
return self.query.geo
@property
def builder(self):
"""
Returns a new `InlineBuilder
<telethon.tl.custom.inlinebuilder.InlineBuilder>` instance.
See the documentation for `builder` to know what kind of answers
can be given.
"""
return _custom.InlineBuilder(self._client)
async def answer(
self, results=None, cache_time=0, *,
gallery=False, next_offset=None, private=False,
switch_pm=None, switch_pm_param=''):
"""
Answers the inline query with the given results.
Args:
results (`list`, optional):
A list of :tl:`InputBotInlineResult` to use.
You should use `builder` to create these:
.. code-block:: python
builder = inline.builder
r1 = builder.article('Be nice', text='Have a nice day')
r2 = builder.article('Be bad', text="I don't like you")
await inline.answer([r1, r2])
You can send up to 50 results as documented in
https://core.telegram.org/bots/api#answerinlinequery.
Sending more will raise ``ResultsTooMuchError``,
and you should consider using `next_offset` to
paginate them.
cache_time (`int`, optional):
For how long this result should be cached on
the user's client. Defaults to 0 for no cache.
gallery (`bool`, optional):
Whether the results should show as a gallery (grid) or not.
next_offset (`str`, optional):
The offset the client will send when the user scrolls the
results and it repeats the request.
private (`bool`, optional):
Whether the results should be cached by Telegram
(not private) or by the user's client (private).
switch_pm (`str`, optional):
If set, this text will be shown in the results
to allow the user to switch to private messages.
switch_pm_param (`str`, optional):
Optional parameter to start the bot with if
`switch_pm` was used.
Example:
.. code-block:: python
@bot.on(events.InlineQuery)
async def handler(event):
builder = event.builder
rev_text = event.text[::-1]
await event.answer([
builder.article('Reverse text', text=rev_text),
builder.photo('/path/to/photo.jpg')
])
"""
if self._answered:
return
if results:
futures = [self._as_future(x) for x in results]
await asyncio.wait(futures)
# All futures will be in the `done` *set* that `wait` returns.
#
# Precisely because it's a `set` and not a `list`, it
# will not preserve the order, but since all futures
# completed we can use our original, ordered `list`.
results = [x.result() for x in futures]
else:
results = []
if switch_pm:
switch_pm = _tl.InlineBotSwitchPM(switch_pm, switch_pm_param)
return await self._client(
_tl.fn.messages.SetInlineBotResults(
query_id=self.query.query_id,
results=results,
cache_time=cache_time,
gallery=gallery,
next_offset=next_offset,
private=private,
switch_pm=switch_pm
)
)
@staticmethod
def _as_future(obj):
if inspect.isawaitable(obj):
return asyncio.ensure_future(obj)
f = asyncio.get_running_loop().create_future()
f.set_result(obj)
return f

View File

@ -1,136 +0,0 @@
from .base import EventBuilder
from .._misc import utils
from .. import _tl
class MessageRead(EventBuilder):
"""
Occurs whenever one or more messages are read in a chat.
Members:
max_id (`int`):
Up to which message ID has been read. Every message
with an ID equal or lower to it have been read.
outbox (`bool`):
`True` if someone else has read your messages.
contents (`bool`):
`True` if what was read were the contents of a message.
This will be the case when e.g. you play a voice note.
It may only be set on ``inbox`` events.
Example
.. code-block:: python
from telethon import events
@client.on(events.MessageRead)
async def handler(event):
# Log when someone reads your messages
print('Someone has read all your messages until', event.max_id)
@client.on(events.MessageRead(inbox=True))
async def handler(event):
# Log when you read message in a chat (from your "inbox")
print('You have read messages until', event.max_id)
"""
def __init__(self, peer=None, max_id=None, out=False, contents=False,
message_ids=None):
self.outbox = out
self.contents = contents
self._message_ids = message_ids or []
self._messages = None
self.max_id = max_id or max(message_ids or [], default=None)
super().__init__(peer, self.max_id)
@classmethod
def _build(cls, client, update, entities):
out = False
contents = False
message_ids = None
if isinstance(update, _tl.UpdateReadHistoryInbox):
peer = update.peer
max_id = update.max_id
out = False
elif isinstance(update, _tl.UpdateReadHistoryOutbox):
peer = update.peer
max_id = update.max_id
out = True
elif isinstance(update, _tl.UpdateReadChannelInbox):
peer = _tl.PeerChannel(update.channel_id)
max_id = update.max_id
out = False
elif isinstance(update, _tl.UpdateReadChannelOutbox):
peer = _tl.PeerChannel(update.channel_id)
max_id = update.max_id
out = True
elif isinstance(update, _tl.UpdateReadMessagesContents):
peer = None
message_ids = update.messages
contents = True
elif isinstance(update, _tl.UpdateChannelReadMessagesContents):
peer = _tl.PeerChannel(update.channel_id)
message_ids = update.messages
contents = True
self = cls.__new__(cls)
self._client = client
self._chat = entities.get(peer)
return self
@property
def inbox(self):
"""
`True` if you have read someone else's messages.
"""
return not self.outbox
@property
def message_ids(self):
"""
The IDs of the messages **which contents'** were read.
Use :meth:`is_read` if you need to check whether a message
was read instead checking if it's in here.
"""
return self._message_ids
async def get_messages(self):
"""
Returns the list of `Message <telethon.tl.custom.message.Message>`
**which contents'** were read.
Use :meth:`is_read` if you need to check whether a message
was read instead checking if it's in here.
"""
if self._messages is None:
chat = await self.get_input_chat()
if not chat:
self._messages = []
else:
self._messages = await self._client.get_messages(
chat, ids=self._message_ids)
return self._messages
def is_read(self, message):
"""
Returns `True` if the given message (or its ID) has been read.
If a list-like argument is provided, this method will return a
list of booleans indicating which messages have been read.
"""
if utils.is_list_like(message):
return [(m if isinstance(m, int) else m.id) <= self.max_id
for m in message]
else:
return (message if isinstance(message, int)
else message.id) <= self.max_id
def __contains__(self, message):
"""`True` if the message(s) are read message."""
if utils.is_list_like(message):
return all(self.is_read(message))
else:
return self.is_read(message)

View File

@ -1,104 +0,0 @@
import re
from .base import EventBuilder
from .._misc import utils
from .. import _tl
from ..types import _custom
class NewMessage(EventBuilder, _custom.Message):
"""
Represents the event of a new message. This event can be treated
to all effects as a `Message <telethon.tl.custom.message.Message>`,
so please **refer to its documentation** to know what you can do
with this event.
Members:
message (`Message <telethon.tl.custom.message.Message>`):
This is the only difference with the received
`Message <telethon.tl.custom.message.Message>`, and will
return the `telethon.tl.custom.message.Message` itself,
not the text.
See `Message <telethon.tl.custom.message.Message>` for
the rest of available members and methods.
pattern_match (`obj`):
The resulting object from calling the passed ``pattern`` function.
Here's an example using a string (defaults to regex match):
>>> from telethon import TelegramClient, events
>>> client = TelegramClient(...)
>>>
>>> @client.on(events.NewMessage(pattern=r'hi (\\w+)!'))
... async def handler(event):
... # In this case, the result is a ``Match`` object
... # since the `str` pattern was converted into
... # the ``re.compile(pattern).match`` function.
... print('Welcomed', event.pattern_match.group(1))
...
>>>
Example
.. code-block:: python
import asyncio
from telethon import events
@client.on(events.NewMessage(pattern='(?i)hello.+'))
async def handler(event):
# Respond whenever someone says "Hello" and something else
await event.reply('Hey!')
@client.on(events.NewMessage(outgoing=True, pattern='!ping'))
async def handler(event):
# Say "!pong" whenever you send "!ping", then delete both messages
m = await event.respond('!pong')
await asyncio.sleep(5)
await client.delete_messages(event.chat_id, [event.id, m.id])
"""
@classmethod
def _build(cls, client, update, entities):
if isinstance(update,
(_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)):
if not isinstance(update.message, _tl.Message):
return # We don't care about MessageService's here
msg = update.message
elif isinstance(update, _tl.UpdateShortMessage):
msg = _tl.Message(
out=update.out,
mentioned=update.mentioned,
media_unread=update.media_unread,
silent=update.silent,
id=update.id,
peer_id=_tl.PeerUser(update.user_id),
from_id=_tl.PeerUser(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=update.reply_to,
entities=update.entities,
ttl_period=update.ttl_period
)
elif isinstance(update, _tl.UpdateShortChatMessage):
msg = _tl.Message(
out=update.out,
mentioned=update.mentioned,
media_unread=update.media_unread,
silent=update.silent,
id=update.id,
from_id=_tl.PeerUser(self_id if update.out else update.from_id),
peer_id=_tl.PeerChat(update.chat_id),
message=update.message,
date=update.date,
fwd_from=update.fwd_from,
via_bot_id=update.via_bot_id,
reply_to=update.reply_to,
entities=update.entities,
ttl_period=update.ttl_period
)
else:
return
return cls._new(client, msg, entities, None)

View File

@ -1,23 +0,0 @@
from .base import EventBuilder
from .._misc import utils
class Raw(EventBuilder):
"""
Raw events are not actual events. Instead, they are the raw
:tl:`Update` object that Telegram sends. You normally shouldn't
need these.
Example
.. code-block:: python
from telethon import events
@client.on(events.Raw)
async def handler(update):
# Print all incoming updates
print(update.stringify())
"""
@classmethod
def _build(cls, client, update, entities):
return update

View File

@ -1,301 +0,0 @@
import datetime
import functools
from .base import EventBuilder
from .._misc import utils
from .. import _tl
from ..types import _custom
# TODO Either the properties are poorly named or they should be
# different events, but that would be a breaking change.
#
# TODO There are more "user updates", but bundling them all up
# in a single place will make it annoying to use (since
# the user needs to check for the existence of `None`).
#
# TODO Handle UpdateUserBlocked, UpdateUserName, UpdateUserPhone, UpdateUserPhoto
def _requires_action(function):
@functools.wraps(function)
def wrapped(self):
return None if self.action is None else function(self)
return wrapped
def _requires_status(function):
@functools.wraps(function)
def wrapped(self):
return None if self.status is None else function(self)
return wrapped
class UserUpdate(EventBuilder, _custom.chatgetter.ChatGetter, _custom.sendergetter.SenderGetter):
"""
Occurs whenever a user goes online, starts typing, etc.
Members:
status (:tl:`UserStatus`, optional):
The user status if the update is about going online or offline.
You should check this attribute first before checking any
of the seen within properties, since they will all be `None`
if the status is not set.
action (:tl:`SendMessageAction`, optional):
The "typing" action if any the user is performing if any.
You should check this attribute first before checking any
of the typing properties, since they will all be `None`
if the action is not set.
Example
.. code-block:: python
from telethon import events
@client.on(events.UserUpdate)
async def handler(event):
# If someone is uploading, say something
if event.uploading:
await client.send_message(event.user_id, 'What are you sending?')
"""
@classmethod
def _build(cls, client, update, entities):
chat_peer = None
status = None
if isinstance(update, _tl.UpdateUserStatus):
peer = _tl.PeerUser(update.user_id)
status = update.status
typing = None
elif isinstance(update, _tl.UpdateChannelUserTyping):
peer = update.from_id
chat_peer = _tl.PeerChannel(update.channel_id)
typing = update.action
elif isinstance(update, _tl.UpdateChatUserTyping):
peer = update.from_id
chat_peer = _tl.PeerChat(update.chat_id)
typing = update.action
elif isinstance(update, _tl.UpdateUserTyping):
peer = update.user_id
typing = update.action
else:
return None
self = cls.__new__(cls)
self._client = client
self._sender = entities.get(peer)
self._chat = entities.get(chat_peer or peer)
self.status = status
self.action = typing
return self
@property
def user(self):
"""Alias for `sender <telethon.tl.custom.sendergetter.SenderGetter.sender>`."""
return self.sender
async def get_user(self):
"""Alias for `get_sender <telethon.tl.custom.sendergetter.SenderGetter.get_sender>`."""
return await self.get_sender()
@property
def input_user(self):
"""Alias for `input_sender <telethon.tl.custom.sendergetter.SenderGetter.input_sender>`."""
return self.input_sender
@property
def user_id(self):
"""Alias for `sender_id <telethon.tl.custom.sendergetter.SenderGetter.sender_id>`."""
return self.sender_id
@property
@_requires_action
def typing(self):
"""
`True` if the action is typing a message.
"""
return isinstance(self.action, _tl.SendMessageTypingAction)
@property
@_requires_action
def uploading(self):
"""
`True` if the action is uploading something.
"""
return isinstance(self.action, (
_tl.SendMessageChooseContactAction,
_tl.SendMessageChooseStickerAction,
_tl.SendMessageUploadAudioAction,
_tl.SendMessageUploadDocumentAction,
_tl.SendMessageUploadPhotoAction,
_tl.SendMessageUploadRoundAction,
_tl.SendMessageUploadVideoAction
))
@property
@_requires_action
def recording(self):
"""
`True` if the action is recording something.
"""
return isinstance(self.action, (
_tl.SendMessageRecordAudioAction,
_tl.SendMessageRecordRoundAction,
_tl.SendMessageRecordVideoAction
))
@property
@_requires_action
def playing(self):
"""
`True` if the action is playing a game.
"""
return isinstance(self.action, _tl.SendMessageGamePlayAction)
@property
@_requires_action
def cancel(self):
"""
`True` if the action was cancelling other actions.
"""
return isinstance(self.action, _tl.SendMessageCancelAction)
@property
@_requires_action
def geo(self):
"""
`True` if what's being uploaded is a geo.
"""
return isinstance(self.action, _tl.SendMessageGeoLocationAction)
@property
@_requires_action
def audio(self):
"""
`True` if what's being recorded/uploaded is an audio.
"""
return isinstance(self.action, (
_tl.SendMessageRecordAudioAction,
_tl.SendMessageUploadAudioAction
))
@property
@_requires_action
def round(self):
"""
`True` if what's being recorded/uploaded is a round video.
"""
return isinstance(self.action, (
_tl.SendMessageRecordRoundAction,
_tl.SendMessageUploadRoundAction
))
@property
@_requires_action
def video(self):
"""
`True` if what's being recorded/uploaded is an video.
"""
return isinstance(self.action, (
_tl.SendMessageRecordVideoAction,
_tl.SendMessageUploadVideoAction
))
@property
@_requires_action
def contact(self):
"""
`True` if what's being uploaded (selected) is a contact.
"""
return isinstance(self.action, _tl.SendMessageChooseContactAction)
@property
@_requires_action
def document(self):
"""
`True` if what's being uploaded is document.
"""
return isinstance(self.action, _tl.SendMessageUploadDocumentAction)
@property
@_requires_action
def sticker(self):
"""
`True` if what's being uploaded is a sticker.
"""
return isinstance(self.action, _tl.SendMessageChooseStickerAction)
@property
@_requires_action
def photo(self):
"""
`True` if what's being uploaded is a photo.
"""
return isinstance(self.action, _tl.SendMessageUploadPhotoAction)
@property
@_requires_action
def last_seen(self):
"""
Exact `datetime.datetime` when the user was last seen if known.
"""
if isinstance(self.status, _tl.UserStatusOffline):
return self.status.was_online
@property
@_requires_status
def until(self):
"""
The `datetime.datetime` until when the user should appear online.
"""
if isinstance(self.status, _tl.UserStatusOnline):
return self.status.expires
def _last_seen_delta(self):
if isinstance(self.status, _tl.UserStatusOffline):
return datetime.datetime.now(tz=datetime.timezone.utc) - self.status.was_online
elif isinstance(self.status, _tl.UserStatusOnline):
return datetime.timedelta(days=0)
elif isinstance(self.status, _tl.UserStatusRecently):
return datetime.timedelta(days=1)
elif isinstance(self.status, _tl.UserStatusLastWeek):
return datetime.timedelta(days=7)
elif isinstance(self.status, _tl.UserStatusLastMonth):
return datetime.timedelta(days=30)
else:
return datetime.timedelta(days=365)
@property
@_requires_status
def online(self):
"""
`True` if the user is currently online,
"""
return self._last_seen_delta() <= datetime.timedelta(days=0)
@property
@_requires_status
def recently(self):
"""
`True` if the user was seen within a day.
"""
return self._last_seen_delta() <= datetime.timedelta(days=1)
@property
@_requires_status
def within_weeks(self):
"""
`True` if the user was seen within 7 days.
"""
return self._last_seen_delta() <= datetime.timedelta(days=7)
@property
@_requires_status
def within_months(self):
"""
`True` if the user was seen within 30 days.
"""
return self._last_seen_delta() <= datetime.timedelta(days=30)

View File

@ -1,131 +0,0 @@
from enum import Enum
def _impl_op(which):
def op(self, other):
if not isinstance(other, type(self)):
return NotImplemented
return getattr(self._val(), which)(other._val())
return op
class ConnectionMode(Enum):
FULL = 'full'
INTERMEDIATE = 'intermediate'
ABRIDGED = 'abridged'
class Participant(Enum):
ADMIN = 'admin'
BOT = 'bot'
KICKED = 'kicked'
BANNED = 'banned'
CONTACT = 'contact'
class Action(Enum):
TYPING = 'typing'
CONTACT = 'contact'
GAME = 'game'
LOCATION = 'location'
STICKER = 'sticker'
RECORD_AUDIO = 'record-audio'
RECORD_VOICE = RECORD_AUDIO
RECORD_ROUND = 'record-round'
RECORD_VIDEO = 'record-video'
AUDIO = 'audio'
VOICE = AUDIO
SONG = AUDIO
ROUND = 'round'
VIDEO = 'video'
PHOTO = 'photo'
DOCUMENT = 'document'
FILE = DOCUMENT
CANCEL = 'cancel'
class Size(Enum):
"""
See https://core.telegram.org/api/files#image-thumbnail-types.
* ``'s'``. The image fits within a box of 100x100.
* ``'m'``. The image fits within a box of 320x320.
* ``'x'``. The image fits within a box of 800x800.
* ``'y'``. The image fits within a box of 1280x1280.
* ``'w'``. The image fits within a box of 2560x2560.
* ``'a'``. The image was cropped to be at most 160x160.
* ``'b'``. The image was cropped to be at most 320x320.
* ``'c'``. The image was cropped to be at most 640x640.
* ``'d'``. The image was cropped to be at most 1280x1280.
* ``'i'``. The image comes inline (no need to download anything).
* ``'j'``. Only the image outline is present (for stickers).
* ``'u'``. The image is actually a short MPEG4 animated video.
* ``'v'``. The image is actually a short MPEG4 video preview.
The sorting order is first dimensions, then ``cropped < boxed < video < other``.
"""
SMALL = 's'
MEDIUM = 'm'
LARGE = 'x'
EXTRA_LARGE = 'y'
ORIGINAL = 'w'
CROPPED_SMALL = 'a'
CROPPED_MEDIUM = 'b'
CROPPED_LARGE = 'c'
CROPPED_EXTRA_LARGE = 'd'
INLINE = 'i'
OUTLINE = 'j'
ANIMATED = 'u'
VIDEO = 'v'
def __hash__(self):
return object.__hash__(self)
__sub__ = _impl_op('__sub__')
__lt__ = _impl_op('__lt__')
__le__ = _impl_op('__le__')
__eq__ = _impl_op('__eq__')
__ne__ = _impl_op('__ne__')
__gt__ = _impl_op('__gt__')
__ge__ = _impl_op('__ge__')
def _val(self):
return self._category() * 100 + self._size()
def _category(self):
return {
Size.SMALL: 2,
Size.MEDIUM: 2,
Size.LARGE: 2,
Size.EXTRA_LARGE: 2,
Size.ORIGINAL: 2,
Size.CROPPED_SMALL: 1,
Size.CROPPED_MEDIUM: 1,
Size.CROPPED_LARGE: 1,
Size.CROPPED_EXTRA_LARGE: 1,
Size.INLINE: 4,
Size.OUTLINE: 5,
Size.ANIMATED: 3,
Size.VIDEO: 3,
}[self]
def _size(self):
return {
Size.SMALL: 1,
Size.MEDIUM: 3,
Size.LARGE: 5,
Size.EXTRA_LARGE: 6,
Size.ORIGINAL: 7,
Size.CROPPED_SMALL: 2,
Size.CROPPED_MEDIUM: 3,
Size.CROPPED_LARGE: 4,
Size.CROPPED_EXTRA_LARGE: 6,
# 0, since they're not the original photo at all
Size.INLINE: 0,
Size.OUTLINE: 0,
# same size as original or extra large (videos are large)
Size.ANIMATED: 7,
Size.VIDEO: 6,
}[self]

View File

@ -1,60 +0,0 @@
import datetime
import typing
from . import helpers
from .. import _tl
from ..types import _custom
Phone = str
Username = str
PeerID = int
Dialog = typing.Union[_tl.User, _tl.Chat, _tl.Channel]
FullDialog = typing.Union[_tl.UserFull, _tl.messages.ChatFull, _tl.ChatFull, _tl.ChannelFull]
DialogLike = typing.Union[
Phone,
Username,
PeerID,
_tl.TypePeer,
_tl.TypeInputPeer,
Dialog,
FullDialog
]
DialogsLike = typing.Union[DialogLike, typing.Sequence[DialogLike]]
ButtonLike = typing.Union[_tl.TypeKeyboardButton, _custom.Button]
MarkupLike = typing.Union[
_tl.TypeReplyMarkup,
ButtonLike,
typing.Sequence[ButtonLike],
typing.Sequence[typing.Sequence[ButtonLike]]
]
TotalList = helpers.TotalList
DateLike = typing.Optional[typing.Union[float, datetime.datetime, datetime.date, datetime.timedelta]]
LocalPath = str
ExternalUrl = str
BotFileID = str
FileLike = typing.Union[
LocalPath,
ExternalUrl,
BotFileID,
bytes,
typing.BinaryIO,
_tl.TypeMessageMedia,
_tl.TypeInputFile,
_tl.TypeInputFileLocation
]
OutFileLike = typing.Union[
str,
typing.Type[bytes],
typing.BinaryIO
]
MessageLike = typing.Union[str, _tl.Message]
MessageIDLike = typing.Union[int, _tl.Message, _tl.TypeInputMessage]
ProgressCallback = typing.Callable[[int, int], None]

View File

@ -1,225 +0,0 @@
"""
Simple HTML -> Telegram entity parser.
"""
import struct
from collections import deque
from html import escape
from html.parser import HTMLParser
from typing import Iterable, Optional, Tuple, List
from .._misc import helpers
from .. import _tl
# Helpers from markdown.py
def _add_surrogate(text):
return ''.join(
''.join(chr(y) for y in struct.unpack('<HH', x.encode('utf-16le')))
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text
)
def _del_surrogate(text):
return text.encode('utf-16', 'surrogatepass').decode('utf-16')
class HTMLToTelegramParser(HTMLParser):
def __init__(self):
super().__init__()
self.text = ''
self.entities = []
self._building_entities = {}
self._open_tags = deque()
self._open_tags_meta = deque()
def handle_starttag(self, tag, attrs):
self._open_tags.appendleft(tag)
self._open_tags_meta.appendleft(None)
attrs = dict(attrs)
EntityType = None
args = {}
if tag == 'strong' or tag == 'b':
EntityType = _tl.MessageEntityBold
elif tag == 'em' or tag == 'i':
EntityType = _tl.MessageEntityItalic
elif tag == 'u':
EntityType = _tl.MessageEntityUnderline
elif tag == 'del' or tag == 's':
EntityType = _tl.MessageEntityStrike
elif tag == 'tg-spoiler':
EntityType = _tl.MessageEntitySpoiler
elif tag == 'blockquote':
EntityType = _tl.MessageEntityBlockquote
elif tag == 'code':
try:
# If we're in the middle of a <pre> tag, this <code> tag is
# probably intended for syntax highlighting.
#
# Syntax highlighting is set with
# <code class='language-...'>codeblock</code>
# inside <pre> tags
pre = self._building_entities['pre']
try:
pre.language = attrs['class'][len('language-'):]
except KeyError:
pass
except KeyError:
EntityType = _tl.MessageEntityCode
elif tag == 'pre':
EntityType = _tl.MessageEntityPre
args['language'] = ''
elif tag == 'a':
try:
url = attrs['href']
except KeyError:
return
if url.startswith('mailto:'):
url = url[len('mailto:'):]
EntityType = _tl.MessageEntityEmail
else:
if self.get_starttag_text() == url:
EntityType = _tl.MessageEntityUrl
else:
EntityType = _tl.MessageEntityTextUrl
args['url'] = url
url = None
self._open_tags_meta.popleft()
self._open_tags_meta.appendleft(url)
if EntityType and tag not in self._building_entities:
self._building_entities[tag] = EntityType(
offset=len(self.text),
# The length will be determined when closing the tag.
length=0,
**args)
def handle_data(self, text):
previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else ''
if previous_tag == 'a':
url = self._open_tags_meta[0]
if url:
text = url
for tag, entity in self._building_entities.items():
entity.length += len(text)
self.text += text
def handle_endtag(self, tag):
try:
self._open_tags.popleft()
self._open_tags_meta.popleft()
except IndexError:
pass
entity = self._building_entities.pop(tag, None)
if entity:
self.entities.append(entity)
def parse(html: str) -> Tuple[str, List[_tl.TypeMessageEntity]]:
"""
Parses the given HTML message and returns its stripped representation
plus a list of the _tl.MessageEntity's that were found.
:param html: the message with HTML to be parsed.
:return: a tuple consisting of (clean message, [message entities]).
"""
if not html:
return html, []
parser = HTMLToTelegramParser()
parser.feed(_add_surrogate(html))
text = helpers.strip_text(parser.text, parser.entities)
return _del_surrogate(text), parser.entities
def unparse(text: str, entities: Iterable[_tl.TypeMessageEntity], _offset: int = 0,
_length: Optional[int] = None) -> str:
"""
Performs the reverse operation to .parse(), effectively returning HTML
given a normal text and its _tl.MessageEntity's.
:param text: the text to be reconverted into HTML.
:param entities: the _tl.MessageEntity's applied to the text.
:return: a HTML representation of the combination of both inputs.
"""
if not text:
return text
elif not entities:
return escape(text)
text = _add_surrogate(text)
if _length is None:
_length = len(text)
html = []
last_offset = 0
for i, entity in enumerate(entities):
if entity.offset >= _offset + _length:
break
relative_offset = entity.offset - _offset
if relative_offset > last_offset:
html.append(escape(text[last_offset:relative_offset]))
elif relative_offset < last_offset:
continue
skip_entity = False
length = entity.length
# If we are in the middle of a surrogate nudge the position by +1.
# Otherwise we would end up with malformed text and fail to encode.
# For example of bad input: "Hi \ud83d\ude1c"
# https://en.wikipedia.org/wiki/UTF-16#U+010000_to_U+10FFFF
while helpers.within_surrogate(text, relative_offset, length=_length):
relative_offset += 1
while helpers.within_surrogate(text, relative_offset + length, length=_length):
length += 1
entity_text = unparse(text=text[relative_offset:relative_offset + length],
entities=entities[i + 1:],
_offset=entity.offset, _length=length)
entity_type = type(entity)
if entity_type == _tl.MessageEntityBold:
html.append('<strong>{}</strong>'.format(entity_text))
elif entity_type == _tl.MessageEntityItalic:
html.append('<em>{}</em>'.format(entity_text))
elif entity_type == _tl.MessageEntityCode:
html.append('<code>{}</code>'.format(entity_text))
elif entity_type == _tl.MessageEntityUnderline:
html.append('<u>{}</u>'.format(entity_text))
elif entity_type == _tl.MessageEntityStrike:
html.append('<del>{}</del>'.format(entity_text))
elif entity_type == _tl.MessageEntityBlockquote:
html.append('<blockquote>{}</blockquote>'.format(entity_text))
elif entity_type == _tl.MessageEntityPre:
if entity.language:
html.append(
"<pre>\n"
" <code class='language-{}'>\n"
" {}\n"
" </code>\n"
"</pre>".format(entity.language, entity_text))
else:
html.append('<pre><code>{}</code></pre>'
.format(entity_text))
elif entity_type == _tl.MessageEntityEmail:
html.append('<a href="mailto:{0}">{0}</a>'.format(entity_text))
elif entity_type == _tl.MessageEntityUrl:
html.append('<a href="{0}">{0}</a>'.format(entity_text))
elif entity_type == _tl.MessageEntityTextUrl:
html.append('<a href="{}">{}</a>'
.format(escape(entity.url), entity_text))
elif entity_type == _tl.MessageEntityMentionName:
html.append('<a href="tg://user?id={}">{}</a>'
.format(entity.user_id, entity_text))
else:
skip_entity = True
last_offset = relative_offset + (0 if skip_entity else length)
while helpers.within_surrogate(text, last_offset, length=_length):
last_offset += 1
html.append(escape(text[last_offset:]))
return _del_surrogate(''.join(html))

View File

@ -1,169 +0,0 @@
"""
Simple markdown parser which does not support nesting. Intended primarily
for use within the library, which attempts to handle emojies correctly,
since they seem to count as two characters and it's a bit strange.
"""
import re
import warnings
import markdown_it
from .helpers import add_surrogate, del_surrogate, within_surrogate, strip_text
from .. import _tl
from .._misc import tlobject
MARKDOWN = markdown_it.MarkdownIt().enable('strikethrough')
DELIMITERS = {
_tl.MessageEntityBlockquote: ('> ', ''),
_tl.MessageEntityBold: ('**', '**'),
_tl.MessageEntityCode: ('`', '`'),
_tl.MessageEntityItalic: ('_', '_'),
_tl.MessageEntityStrike: ('~~', '~~'),
_tl.MessageEntitySpoiler: ('||', '||'),
_tl.MessageEntityUnderline: ('# ', ''),
}
# Not trying to be complete; just enough to have an alternative (mostly for inline underline).
# The fact headings are treated as underline is an implementation detail.
TAG_PATTERN = re.compile(r'<\s*(/?)\s*(\w+)')
HTML_TO_TYPE = {
'i': ('em_close', 'em_open'),
'em': ('em_close', 'em_open'),
'b': ('strong_close', 'strong_open'),
'strong': ('strong_close', 'strong_open'),
's': ('s_close', 's_open'),
'del': ('s_close', 's_open'),
'u': ('heading_open', 'heading_close'),
'mark': ('heading_open', 'heading_close'),
}
def expand_inline_and_html(tokens):
for token in tokens:
if token.type == 'inline':
yield from expand_inline_and_html(token.children)
elif token.type == 'html_inline':
match = TAG_PATTERN.match(token.content)
if match:
close, tag = match.groups()
tys = HTML_TO_TYPE.get(tag.lower())
if tys:
token.type = tys[bool(close)]
token.nesting = -1 if close else 1
yield token
else:
yield token
def parse(message):
"""
Parses the given markdown message and returns its stripped representation
plus a list of the _tl.MessageEntity's that were found.
"""
if not message:
return message, []
def push(ty, **extra):
nonlocal message, entities, token
if token.nesting > 0:
entities.append(ty(offset=len(message), length=0, **extra))
else:
for entity in reversed(entities):
if isinstance(entity, ty):
entity.length = len(message) - entity.offset
break
parsed = MARKDOWN.parse(add_surrogate(message.strip()))
message = ''
entities = []
last_map = [0, 0]
for token in expand_inline_and_html(parsed):
if token.map is not None and token.map != last_map:
# paragraphs, quotes fences have a line mapping. Use it to determine how many newlines to insert.
# But don't inssert any (leading) new lines if we're yet to reach the first textual content, or
# if the mappings are the same (e.g. a quote then opens a paragraph but the mapping is equal).
if message:
message += '\n' + '\n' * (token.map[0] - last_map[-1])
last_map = token.map
if token.type in ('blockquote_close', 'blockquote_open'):
push(_tl.MessageEntityBlockquote)
elif token.type == 'code_block':
entities.append(_tl.MessageEntityPre(offset=len(message), length=len(token.content), language=''))
message += token.content
elif token.type == 'code_inline':
entities.append(_tl.MessageEntityCode(offset=len(message), length=len(token.content)))
message += token.content
elif token.type in ('em_close', 'em_open'):
push(_tl.MessageEntityItalic)
elif token.type == 'fence':
entities.append(_tl.MessageEntityPre(offset=len(message), length=len(token.content), language=token.info))
message += token.content[:-1] # remove a single trailing newline
elif token.type == 'hardbreak':
message += '\n'
elif token.type in ('heading_close', 'heading_open'):
push(_tl.MessageEntityUnderline)
elif token.type == 'hr':
message += '\u2015\n\n'
elif token.type in ('link_close', 'link_open'):
if token.markup != 'autolink': # telegram already picks up on these automatically
push(_tl.MessageEntityTextUrl, url=token.attrs.get('href'))
elif token.type in ('s_close', 's_open'):
push(_tl.MessageEntityStrike)
elif token.type == 'softbreak':
message += ' '
elif token.type in ('strong_close', 'strong_open'):
push(_tl.MessageEntityBold)
elif token.type == 'text':
message += token.content
return del_surrogate(message), entities
def unparse(text, entities):
"""
Performs the reverse operation to .parse(), effectively returning
markdown-like syntax given a normal text and its _tl.MessageEntity's.
Because there are many possible ways for markdown to produce a certain
output, this function cannot invert .parse() perfectly.
"""
if not text or not entities:
return text
if isinstance(entities, tlobject.TLObject):
entities = (entities,)
text = add_surrogate(text)
insert_at = []
for entity in entities:
s = entity.offset
e = entity.offset + entity.length
delimiter = DELIMITERS.get(type(entity), None)
if delimiter:
insert_at.append((s, delimiter[0]))
insert_at.append((e, delimiter[1]))
elif isinstance(entity, _tl.MessageEntityPre):
insert_at.append((s, f'```{entity.language}\n'))
insert_at.append((e, '```\n'))
elif isinstance(entity, _tl.MessageEntityTextUrl):
insert_at.append((s, '['))
insert_at.append((e, f']({entity.url})'))
elif isinstance(entity, _tl.MessageEntityMentionName):
insert_at.append((s, '['))
insert_at.append((e, f'](tg://user?id={entity.user_id})'))
insert_at.sort(key=lambda t: t[0])
while insert_at:
at, what = insert_at.pop()
# If we are in the middle of a surrogate nudge the position by -1.
# Otherwise we would end up with malformed text and fail to encode.
# For example of bad input: "Hi \ud83d\ude1c"
# https://en.wikipedia.org/wiki/UTF-16#U+010000_to_U+10FFFF
while within_surrogate(text, at):
at += 1
text = text[:at] + what + text[at:]
return del_surrogate(text)

View File

@ -1,63 +0,0 @@
import asyncio
import socket
from .transports.transport import Transport
CHUNK_SIZE = 32 * 1024
# TODO ideally the mtproto impl would also be sans-io, but that's less pressing
class Connection:
def __init__(self, ip, port, *, transport: Transport, loggers, local_addr=None):
self._ip = ip
self._port = port
self._log = loggers[__name__]
self._local_addr = local_addr
self._sock = None
self._in_buffer = bytearray()
self._transport = transport
async def connect(self, timeout=None, ssl=None):
"""
Establishes a connection with the server.
"""
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False)
if self._local_addr:
sock.bind(self._local_addr)
# TODO https://github.com/LonamiWebs/Telethon/issues/1337 may be an issue again
# perhaps we just need to ignore async connect on windows and block?
await asyncio.wait_for(loop.sock_connect(sock, (self._ip, self._port)), timeout)
self._sock = sock
async def disconnect(self):
self._sock.close()
self._sock = None
async def send(self, data):
if not self._sock:
raise ConnectionError('not connected')
loop = asyncio.get_running_loop()
await loop.sock_sendall(self._sock, self._transport.pack(data))
async def recv(self):
if not self._sock:
raise ConnectionError('not connected')
loop = asyncio.get_running_loop()
while True:
try:
length, body = self._transport.unpack(self._in_buffer)
del self._in_buffer[:length]
return body
except EOFError:
self._in_buffer += await loop.sock_recv(self._sock, CHUNK_SIZE)
def __str__(self):
return f'{self._ip}:{self._port}/{self._transport.__class__.__name__}'

View File

@ -1,4 +0,0 @@
from .transport import Transport
from .abridged import Abridged
from .full import Full
from .intermediate import Intermediate

View File

@ -1,43 +0,0 @@
from .transport import Transport
import struct
class Abridged(Transport):
def __init__(self):
self._init = False
def recreate_fresh(self):
return type(self)()
def pack(self, input: bytes) -> bytes:
if self._init:
header = b''
else:
header = b'\xef'
self._init = True
length = len(data) >> 2
if length < 127:
length = struct.pack('B', length)
else:
length = b'\x7f' + int.to_bytes(length, 3, 'little')
return header + length + data
def unpack(self, input: bytes) -> (int, bytes):
if len(input) < 4:
raise EOFError()
length = input[0]
if length < 127:
offset = 1
else:
offset = 4
length = struct.unpack('<i', input[1:4] + b'\0')[0]
length = (length << 2) + offset
if len(input) < length:
raise EOFError()
return length, input[offset:length]

View File

@ -1,41 +0,0 @@
from .transport import Transport
import struct
from zlib import crc32
class Full(Transport):
def __init__(self):
self._send_counter = 0
self._recv_counter = 0
def recreate_fresh(self):
return type(self)()
def pack(self, input: bytes) -> bytes:
# https://core.telegram.org/mtproto#tcp-transport
length = len(input) + 12
data = struct.pack('<ii', length, self._send_counter) + input
crc = struct.pack('<I', crc32(data))
self._send_counter += 1
return data + crc
def unpack(self, input: bytes) -> (int, bytes):
if len(input) < 12:
raise EOFError()
length, seq = struct.unpack('<ii', input[:8])
if len(input) < length:
raise EOFError()
if seq != self._recv_counter:
raise ValueError(f'expected sequence value {self._recv_counter!r}, got {seq!r}')
body = input[8:length - 4]
checksum = struct.unpack('<I', input[length - 4:length])[0]
valid_checksum = crc32(input[:length - 4])
if checksum != valid_checksum:
raise InvalidChecksumError(checksum, valid_checksum)
self._recv_counter += 1
return length, body

View File

@ -1,29 +0,0 @@
from .transport import Transport
import struct
class Intermediate(Transport):
def __init__(self):
self._init = False
def recreate_fresh(self):
return type(self)()
def pack(self, input: bytes) -> bytes:
if self._init:
header = b''
else:
header = b'\xee\xee\xee\xee'
self._init = True
return header + struct.pack('<i', len(data)) + data
def unpack(self, input: bytes) -> (int, bytes):
if len(input) < 4:
raise EOFError()
length = struct.unpack('<i', input[:4])[0] + 4
if len(input) < length:
raise EOFError()
return length, input[4:length]

View File

@ -1,17 +0,0 @@
import abc
class Transport(abc.ABC):
# Should return a newly-created instance of itself
@abc.abstractmethod
def recreate_fresh(self):
pass
@abc.abstractmethod
def pack(self, input: bytes) -> bytes:
pass
# Should raise EOFError if it does not have enough bytes
@abc.abstractmethod
def unpack(self, input: bytes) -> (int, bytes):
pass

View File

@ -1,92 +0,0 @@
from .types import DataCenter, ChannelState, SessionState, EntityType, Entity
from abc import ABC, abstractmethod
from typing import List, Optional
class Session(ABC):
@abstractmethod
async def insert_dc(self, dc: DataCenter):
"""
Store a new or update an existing `DataCenter` with matching ``id``.
"""
raise NotImplementedError
@abstractmethod
async def get_all_dc(self) -> List[DataCenter]:
"""
Get a list of all currently-stored `DataCenter`. Should not contain duplicate ``id``.
"""
raise NotImplementedError
@abstractmethod
async def set_state(self, state: SessionState):
"""
Set the state about the current session.
"""
raise NotImplementedError
@abstractmethod
async def get_state(self) -> Optional[SessionState]:
"""
Get the state about the current session.
"""
raise NotImplementedError
@abstractmethod
async def insert_channel_state(self, state: ChannelState):
"""
Store a new or update an existing `ChannelState` with matching ``id``.
"""
raise NotImplementedError
@abstractmethod
async def get_all_channel_states(self) -> List[ChannelState]:
"""
Get a list of all currently-stored `ChannelState`. Should not contain duplicate ``id``.
"""
raise NotImplementedError
@abstractmethod
async def insert_entities(self, entities: List[Entity]):
"""
Store new or update existing `Entity` with matching ``id``.
Entities should be saved on a best-effort. It is okay to not save them, although the
library may need to do extra work if a previously-saved entity is missing, or even be
unable to continue without the entity.
"""
raise NotImplementedError
@abstractmethod
async def get_entity(self, ty: Optional[EntityType], id: int) -> Optional[Entity]:
"""
Get the `Entity` with matching ``ty`` and ``id``.
The following groups of ``ty`` should be treated to be equivalent, that is, for a given
``ty`` and ``id``, if the ``ty`` is in a given group, a matching ``hash`` with that ``id``
from within any ``ty`` in that group should be returned.
* `EntityType.USER` and `EntityType.BOT`.
* `EntityType.GROUP`.
* `EntityType.CHANNEL`, `EntityType.MEGAGROUP` and `EntityType.GIGAGROUP`.
For example, if a ``ty`` representing a bot is stored but the asking ``ty`` is a user,
the corresponding ``hash`` should still be returned.
You may use ``EntityType.canonical`` to find out the canonical type.
A ``ty`` with the value of ``None`` should be treated as "any entity with matching ID".
"""
raise NotImplementedError
@abstractmethod
async def save(self):
"""
Save the session.
May do nothing if the other methods already saved when they were called.
May return custom data when manual saving is intended.
"""
raise NotImplementedError

View File

@ -1,47 +0,0 @@
from .types import DataCenter, ChannelState, SessionState, Entity
from .abstract import Session
from .._misc import utils, tlobject
from .. import _tl
from typing import List, Optional
class MemorySession(Session):
__slots__ = ('dcs', 'state', 'channel_states', 'entities')
def __init__(self):
self.dcs = {}
self.state = None
self.channel_states = {}
self.entities = {}
async def insert_dc(self, dc: DataCenter):
self.dcs[dc.id] = dc
async def get_all_dc(self) -> List[DataCenter]:
return list(self.dcs.values())
async def set_state(self, state: SessionState):
self.state = state
async def get_state(self) -> Optional[SessionState]:
return self.state
async def insert_channel_state(self, state: ChannelState):
self.channel_states[state.channel_id] = state
async def get_all_channel_states(self) -> List[ChannelState]:
return list(self.channel_states.values())
async def insert_entities(self, entities: List[Entity]):
self.entities.update((e.id, (e.ty, e.hash)) for e in entities)
async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]:
try:
ty, hash = self.entities[id]
return Entity(ty, id, hash)
except KeyError:
return None
async def save(self):
pass

View File

@ -1,284 +0,0 @@
import datetime
import os
import time
import ipaddress
from typing import Optional, List
from .abstract import Session
from .._misc import utils
from .. import _tl
from .types import DataCenter, ChannelState, SessionState, Entity
try:
import sqlite3
sqlite3_err = None
except ImportError as e:
sqlite3 = None
sqlite3_err = type(e)
EXTENSION = '.session'
CURRENT_VERSION = 8 # database version
class SQLiteSession(Session):
"""
This session contains the required information to login into your
Telegram account. NEVER give the saved session file to anyone, since
they would gain instant access to all your messages and contacts.
If you think the session has been compromised, close all the sessions
through an official Telegram client to revoke the authorization.
"""
def __init__(self, session_id=None):
if sqlite3 is None:
raise sqlite3_err
super().__init__()
self.filename = ':memory:'
self.save_entities = True
if session_id:
self.filename = os.fspath(session_id)
if not self.filename.endswith(EXTENSION):
self.filename += EXTENSION
self._conn = None
c = self._cursor()
c.execute("select name from sqlite_master "
"where type='table' and name='version'")
if c.fetchone():
# Tables already exist, check for the version
c.execute("select version from version")
version = c.fetchone()[0]
if version < CURRENT_VERSION:
self._upgrade_database(old=version)
c.execute("delete from version")
c.execute("insert into version values (?)", (CURRENT_VERSION,))
self._conn.commit()
else:
# Tables don't exist, create new ones
self._create_table(c, 'version (version integer primary key)')
self._mk_tables(c)
c.execute("insert into version values (?)", (CURRENT_VERSION,))
self._conn.commit()
# Must have committed or else the version will not have been updated while new tables
# exist, leading to a half-upgraded state.
c.close()
def _upgrade_database(self, old):
c = self._cursor()
if old == 1:
old += 1
# old == 1 doesn't have the old sent_files so no need to drop
if old == 2:
old += 1
# Old cache from old sent_files lasts then a day anyway, drop
c.execute('drop table sent_files')
self._create_table(c, """sent_files (
md5_digest blob,
file_size integer,
type integer,
id integer,
hash integer,
primary key(md5_digest, file_size, type)
)""")
if old == 3:
old += 1
self._create_table(c, """update_state (
id integer primary key,
pts integer,
qts integer,
date integer,
seq integer
)""")
if old == 4:
old += 1
c.execute("alter table sessions add column takeout_id integer")
if old == 5:
# Not really any schema upgrade, but potentially all access
# hashes for User and Channel are wrong, so drop them off.
old += 1
c.execute('delete from entities')
if old == 6:
old += 1
c.execute("alter table entities add column date integer")
if old == 7:
self._mk_tables(c)
c.execute('''
insert into datacenter (id, ipv4, ipv6, port, auth)
select dc_id, server_address, server_address, port, auth_key
from sessions
''')
c.execute('''
insert into session (user_id, dc_id, bot, pts, qts, date, seq, takeout_id)
select
0,
s.dc_id,
0,
coalesce(u.pts, 0),
coalesce(u.qts, 0),
coalesce(u.date, 0),
coalesce(u.seq, 0),
s.takeout_id
from sessions s
left join update_state u on u.id = 0
limit 1
''')
c.execute('''
insert into entity (id, hash, ty)
select
case
when id < -1000000000000 then -(id + 1000000000000)
when id < 0 then -id
else id
end,
hash,
case
when id < -1000000000000 then 67
when id < 0 then 71
else 85
end
from entities
''')
c.execute('drop table sessions')
c.execute('drop table entities')
c.execute('drop table sent_files')
c.execute('drop table update_state')
def _mk_tables(self, c):
self._create_table(
c,
'''datacenter (
id integer primary key,
ipv4 text not null,
ipv6 text,
port integer not null,
auth blob not null
)''',
'''session (
user_id integer primary key,
dc_id integer not null,
bot integer not null,
pts integer not null,
qts integer not null,
date integer not null,
seq integer not null,
takeout_id integer
)''',
'''channel (
channel_id integer primary key,
pts integer not null
)''',
'''entity (
id integer primary key,
hash integer not null,
ty integer not null
)''',
)
async def insert_dc(self, dc: DataCenter):
self._execute(
'insert or replace into datacenter values (?,?,?,?,?)',
dc.id,
str(ipaddress.ip_address(dc.ipv4)),
str(ipaddress.ip_address(dc.ipv6)) if dc.ipv6 else None,
dc.port,
dc.auth
)
async def get_all_dc(self) -> List[DataCenter]:
c = self._cursor()
res = []
for (id, ipv4, ipv6, port, auth) in c.execute('select * from datacenter'):
res.append(DataCenter(
id=id,
ipv4=int(ipaddress.ip_address(ipv4)),
ipv6=int(ipaddress.ip_address(ipv6)) if ipv6 else None,
port=port,
auth=auth,
))
return res
async def set_state(self, state: SessionState):
c = self._cursor()
try:
self._execute('delete from session')
self._execute(
'insert into session values (?,?,?,?,?,?,?,?)',
state.user_id,
state.dc_id,
int(state.bot),
state.pts,
state.qts,
state.date,
state.seq,
state.takeout_id,
)
finally:
c.close()
async def get_state(self) -> Optional[SessionState]:
row = self._execute('select * from session')
return SessionState(*row) if row else None
async def insert_channel_state(self, state: ChannelState):
self._execute(
'insert or replace into channel values (?,?)',
state.channel_id,
state.pts,
)
async def get_all_channel_states(self) -> List[ChannelState]:
c = self._cursor()
try:
return [
ChannelState(*row)
for row in c.execute('select * from channel')
]
finally:
c.close()
async def insert_entities(self, entities: List[Entity]):
c = self._cursor()
try:
c.executemany(
'insert or replace into entity values (?,?,?)',
[(e.id, e.hash, e.ty) for e in entities]
)
finally:
c.close()
async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]:
row = self._execute('select ty, id, hash from entity where id = ?', id)
return Entity(*row) if row else None
async def save(self):
# This is a no-op if there are no changes to commit, so there's
# no need for us to keep track of an "unsaved changes" variable.
if self._conn is not None:
self._conn.commit()
@staticmethod
def _create_table(c, *definitions):
for definition in definitions:
c.execute('create table {}'.format(definition))
def _cursor(self):
"""Asserts that the connection is open and returns a cursor"""
if self._conn is None:
self._conn = sqlite3.connect(self.filename,
check_same_thread=False)
return self._conn.cursor()
def _execute(self, stmt, *values):
"""
Gets a cursor, executes `stmt` and closes the cursor,
fetching one row afterwards and returning its result.
"""
c = self._cursor()
try:
return c.execute(stmt, values).fetchone()
finally:
c.close()

View File

@ -1,2 +1,3 @@
from .entitycache import EntityCache
from .messagebox import MessageBox
from .messagebox import MessageBox, GapError, PrematureEndReason
from .session import SessionState, ChannelState, Entity, EntityType

View File

@ -1,22 +1,25 @@
import inspect
import itertools
from dataclasses import dataclass, field
from collections import namedtuple
from .._misc import utils
from .. import _tl
from .._sessions.types import EntityType, Entity
from .session import EntityType, Entity
_sentinel = object()
@dataclass
class EntityCache:
hash_map: dict = field(default_factory=dict) # id -> (hash, ty)
self_id: int = None
self_bot: bool = False
def __init__(
self,
hash_map: dict = _sentinel,
self_id: int = None,
self_bot: bool = None
):
self.hash_map = {} if hash_map is _sentinel else hash_map
self.self_id = self_id
self.self_bot = self_bot
def set_self_user(self, id, bot):
def set_self_user(self, id, bot, hash):
self.self_id = id
self.self_bot = bot
if hash:
self.hash_map[id] = (hash, EntityType.BOT if bot else EntityType.USER)
def get(self, id):
try:
@ -46,8 +49,11 @@ class EntityCache:
if getattr(c, 'access_hash', None) and not getattr(c, 'min', None)
)
def get_all_entities(self):
return [Entity(ty, id, hash) for id, (hash, ty) in self.hash_map.items()]
def put(self, entity):
self.hash_map[entity.id] = (entity.hash, entity.ty)
def retain(self, filter):
self.hash_map = {k: v for k, v in self.hash_map.items() if filter(k)}
def __len__(self):
return len(self.hash_map)

View File

@ -19,9 +19,11 @@ to get the difference.
import asyncio
import datetime
import time
from dataclasses import dataclass, field
from .._sessions.types import SessionState, ChannelState
from .. import _tl
import logging
from enum import Enum
from .session import SessionState, ChannelState
from ..tl import types as tl, functions as fn
from ..helpers import get_running_loop
# Telegram sends `seq` equal to `0` when "it doesn't matter", so we use that value too.
@ -43,28 +45,58 @@ POSSIBLE_GAP_TIMEOUT = 0.5
# Documentation recommends 15 minutes without updates (https://core.telegram.org/api/updates).
NO_UPDATES_TIMEOUT = 15 * 60
# object() but with a tag to make it easier to debug
class Sentinel:
__slots__ = ('tag',)
def __init__(self, tag=None):
self.tag = tag or '_'
def __repr__(self):
return self.tag
# Entry "enum".
# Account-wide `pts` includes private conversations (one-to-one) and small group chats.
ENTRY_ACCOUNT = object()
ENTRY_ACCOUNT = Sentinel('ACCOUNT')
# Account-wide `qts` includes only "secret" one-to-one chats.
ENTRY_SECRET = object()
ENTRY_SECRET = Sentinel('SECRET')
# Integers will be Channel-specific `pts`, and includes "megagroup", "broadcast" and "supergroup" channels.
# Python's logging doesn't define a TRACE level. Pick halfway between DEBUG and NOTSET.
# We don't define a name for this as libraries shouldn't do that though.
LOG_LEVEL_TRACE = (logging.DEBUG - logging.NOTSET) // 2
_sentinel = Sentinel()
def next_updates_deadline():
return asyncio.get_running_loop().time() + NO_UPDATES_TIMEOUT
return get_running_loop().time() + NO_UPDATES_TIMEOUT
def epoch():
return datetime.datetime(*time.gmtime(0)[:6]).replace(tzinfo=datetime.timezone.utc)
class GapError(ValueError):
pass
def __repr__(self):
return 'GapError()'
class PrematureEndReason(Enum):
TEMPORARY_SERVER_ISSUES = 'tmp'
BANNED = 'ban'
# Represents the information needed to correctly handle a specific `tl::enums::Update`.
@dataclass
class PtsInfo:
pts: int
pts_count: int
entry: object
__slots__ = ('pts', 'pts_count', 'entry')
def __init__(
self,
pts: int,
pts_count: int,
entry: object
):
self.pts = pts
self.pts_count = pts_count
self.entry = entry
@classmethod
def from_update(cls, update):
@ -79,20 +111,30 @@ class PtsInfo:
qts = getattr(update, 'qts', None)
if qts:
pts_count = 1 if isinstance(update, _tl.UpdateNewEncryptedMessage) else 0
return cls(pts=qts, pts_count=pts_count, entry=ENTRY_SECRET)
return cls(pts=qts, pts_count=1, entry=ENTRY_SECRET)
return None
def __repr__(self):
return f'PtsInfo(pts={self.pts}, pts_count={self.pts_count}, entry={self.entry})'
# The state of a particular entry in the message box.
@dataclass
class State:
# Current local persistent timestamp.
pts: int
__slots__ = ('pts', 'deadline')
# Next instant when we would get the update difference if no updates arrived before then.
deadline: float
def __init__(
self,
# Current local persistent timestamp.
pts: int,
# Next instant when we would get the update difference if no updates arrived before then.
deadline: float
):
self.pts = pts
self.deadline = deadline
def __repr__(self):
return f'State(pts={self.pts}, deadline={self.deadline})'
# > ### Recovering gaps
@ -103,43 +145,72 @@ class State:
#
# This is really easy to trigger by spamming messages in a channel (with as little as 3 members works), because
# the updates produced by the RPC request take a while to arrive (whereas the read update comes faster alone).
@dataclass
class PossibleGap:
deadline: float
# Pending updates (those with a larger PTS, producing the gap which may later be filled).
updates: list # of updates
__slots__ = ('deadline', 'updates')
def __init__(
self,
deadline: float,
# Pending updates (those with a larger PTS, producing the gap which may later be filled).
updates: list # of updates
):
self.deadline = deadline
self.updates = updates
def __repr__(self):
return f'PossibleGap(deadline={self.deadline}, update_count={len(self.updates)})'
# Represents a "message box" (event `pts` for a specific entry).
#
# See https://core.telegram.org/api/updates#message-related-event-sequences.
@dataclass
class MessageBox:
# Map each entry to their current state.
map: dict = field(default_factory=dict) # entry -> state
__slots__ = ('_log', 'map', 'date', 'seq', 'next_deadline', 'possible_gaps', 'getting_diff_for')
# Additional fields beyond PTS needed by `ENTRY_ACCOUNT`.
date: datetime.datetime = datetime.datetime(*time.gmtime(0)[:6]).replace(tzinfo=datetime.timezone.utc)
seq: int = NO_SEQ
def __init__(
self,
log,
# Map each entry to their current state.
map: dict = _sentinel, # entry -> state
# Holds the entry with the closest deadline (optimization to avoid recalculating the minimum deadline).
next_deadline: object = None # entry
# Additional fields beyond PTS needed by `ENTRY_ACCOUNT`.
date: datetime.datetime = epoch() + datetime.timedelta(seconds=1),
seq: int = NO_SEQ,
# Which entries have a gap and may soon trigger a need to get difference.
#
# If a gap is found, stores the required information to resolve it (when should it timeout and what updates
# should be held in case the gap is resolved on its own).
#
# Not stored directly in `map` as an optimization (else we would need another way of knowing which entries have
# a gap in them).
possible_gaps: dict = field(default_factory=dict) # entry -> possiblegap
# Holds the entry with the closest deadline (optimization to avoid recalculating the minimum deadline).
next_deadline: object = None, # entry
# For which entries are we currently getting difference.
getting_diff_for: set = field(default_factory=set) # entry
# Which entries have a gap and may soon trigger a need to get difference.
#
# If a gap is found, stores the required information to resolve it (when should it timeout and what updates
# should be held in case the gap is resolved on its own).
#
# Not stored directly in `map` as an optimization (else we would need another way of knowing which entries have
# a gap in them).
possible_gaps: dict = _sentinel, # entry -> possiblegap
# Temporarily stores which entries should have their update deadline reset.
# Stored in the message box in order to reuse the allocation.
reset_deadlines_for: set = field(default_factory=set) # entry
# For which entries are we currently getting difference.
getting_diff_for: set = _sentinel, # entry
):
self._log = log
self.map = {} if map is _sentinel else map
self.date = date
self.seq = seq
self.next_deadline = next_deadline
self.possible_gaps = {} if possible_gaps is _sentinel else possible_gaps
self.getting_diff_for = set() if getting_diff_for is _sentinel else getting_diff_for
if __debug__:
self._trace('MessageBox initialized')
def _trace(self, msg, *args, **kwargs):
# Calls to trace can't really be removed beforehand without some dark magic.
# So every call to trace is prefixed with `if __debug__`` instead, to remove
# it when using `python -O`. Probably unnecessary, but it's nice to avoid
# paying the cost for something that is not used.
self._log.log(LOG_LEVEL_TRACE, 'Current MessageBox state: seq = %r, date = %s, map = %r',
self.seq, self.date.isoformat(), self.map)
self._log.log(LOG_LEVEL_TRACE, msg, *args, **kwargs)
# region Creation, querying, and setting base state.
@ -147,6 +218,9 @@ class MessageBox:
"""
Create a [`MessageBox`] from a previously known update state.
"""
if __debug__:
self._trace('Loading MessageBox with session_state = %r, channel_states = %r', session_state, channel_states)
deadline = next_updates_deadline()
self.map.clear()
@ -156,7 +230,7 @@ class MessageBox:
self.map[ENTRY_SECRET] = State(pts=session_state.qts, deadline=deadline)
self.map.update((s.channel_id, State(pts=s.pts, deadline=deadline)) for s in channel_states)
self.date = datetime.datetime.fromtimestamp(session_state.date).replace(tzinfo=datetime.timezone.utc)
self.date = datetime.datetime.fromtimestamp(session_state.date, tz=datetime.timezone.utc)
self.seq = session_state.seq
self.next_deadline = ENTRY_ACCOUNT
@ -169,7 +243,7 @@ class MessageBox:
return dict(
pts=self.map[ENTRY_ACCOUNT].pts if ENTRY_ACCOUNT in self.map else NO_SEQ,
qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ,
date=int(self.date.timestamp()),
date=self.date,
seq=self.seq,
), {id: state.pts for id, state in self.map.items() if isinstance(id, int)}
@ -186,7 +260,7 @@ class MessageBox:
If a deadline expired, the corresponding entries will be marked as needing to get its difference.
While there are entries pending of getting their difference, this method returns the current instant.
"""
now = asyncio.get_running_loop().time()
now = get_running_loop().time()
if self.getting_diff_for:
return now
@ -199,10 +273,16 @@ class MessageBox:
elif self.next_deadline in self.map:
deadline = min(deadline, self.map[self.next_deadline].deadline)
if now > deadline:
# asyncio's loop time precision only seems to be about 3 decimal places, so it's possible that
# we find the same number again on repeated calls. Without the "or equal" part we would log the
# timeout for updates several times (it also makes sense to get difference if now is the deadline).
if now >= deadline:
# Check all expired entries and add them to the list that needs getting difference.
self.getting_diff_for.update(entry for entry, gap in self.possible_gaps.items() if now > gap.deadline)
self.getting_diff_for.update(entry for entry, state in self.map.items() if now > state.deadline)
self.getting_diff_for.update(entry for entry, gap in self.possible_gaps.items() if now >= gap.deadline)
self.getting_diff_for.update(entry for entry, state in self.map.items() if now >= state.deadline)
if __debug__:
self._trace('Deadlines met, now getting diff for %r', self.getting_diff_for)
# When extending `getting_diff_for`, it's important to have the moral equivalent of
# `begin_get_diff` (that is, clear possible gaps if we're now getting difference).
@ -211,52 +291,53 @@ class MessageBox:
return deadline
# Reset the deadline for the periods without updates for a given entry.
# Reset the deadline for the periods without updates for the given entries.
#
# It also updates the next deadline time to reflect the new closest deadline.
def reset_deadline(self, entry, deadline):
if entry in self.map:
def reset_deadlines(self, entries, deadline):
if not entries:
return
for entry in entries:
if entry not in self.map:
raise RuntimeError('Called reset_deadline on an entry for which we do not have state')
self.map[entry].deadline = deadline
# TODO figure out why not in map may happen
if self.next_deadline == entry:
if self.next_deadline in entries:
# If the updated deadline was the closest one, recalculate the new minimum.
self.next_deadline = min(self.map.items(), key=lambda entry_state: entry_state[1].deadline)[0]
elif self.next_deadline in self.map and deadline < self.map[self.next_deadline].deadline:
# If the updated deadline is smaller than the next deadline, change the next deadline to be the new one.
# Any entry will do, so the one from the last iteration is fine.
self.next_deadline = entry
# else an unrelated deadline was updated, so the closest one remains unchanged.
# Convenience to reset a channel's deadline, with optional timeout.
def reset_channel_deadline(self, channel_id, timeout):
self.reset_deadline(channel_id, asyncio.get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT))
# Reset all the deadlines in `reset_deadlines_for` and then empty the set.
def apply_deadlines_reset(self):
next_deadline = next_updates_deadline()
reset_deadlines_for = self.reset_deadlines_for
self.reset_deadlines_for = set() # "move" the set to avoid self.reset_deadline() from touching it during iter
for entry in reset_deadlines_for:
self.reset_deadline(entry, next_deadline)
reset_deadlines_for.clear() # reuse allocation, the other empty set was a temporary dummy value
self.reset_deadlines_for = reset_deadlines_for
self.reset_deadlines({channel_id}, get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT))
# Sets the update state.
#
# Should be called right after login if [`MessageBox::new`] was used, otherwise undesirable
# updates will be fetched.
def set_state(self, state):
def set_state(self, state, reset=True):
if __debug__:
self._trace('Setting state %s', state)
deadline = next_updates_deadline()
if state.pts != NO_SEQ:
if state.pts != NO_SEQ or not reset:
self.map[ENTRY_ACCOUNT] = State(pts=state.pts, deadline=deadline)
else:
self.map.pop(ENTRY_ACCOUNT, None)
if state.qts != NO_SEQ:
# Telegram seems to use the `qts` for bot accounts, but while applying difference,
# it might be reset back to 0. See issue #3873 for more details.
#
# During login, a value of zero would mean the `pts` is unknown,
# so the map shouldn't contain that entry.
# But while applying difference, if the value is zero, it (probably)
# truly means that's what should be used (hence the `reset` flag).
if state.qts != NO_SEQ or not reset:
self.map[ENTRY_SECRET] = State(pts=state.qts, deadline=deadline)
else:
self.map.pop(ENTRY_SECRET, None)
@ -268,13 +349,28 @@ class MessageBox:
#
# The update state will only be updated if no entry was known previously.
def try_set_channel_state(self, id, pts):
if __debug__:
self._trace('Trying to set channel state for %r: %r', id, pts)
if id not in self.map:
self.map[id] = State(pts=pts, deadline=next_updates_deadline())
# Begin getting difference for the given entry.
# Try to begin getting difference for the given entry.
# Fails if the entry does not have a previously-known state that can be used to get its difference.
#
# Clears any previous gaps.
def begin_get_diff(self, entry):
def try_begin_get_diff(self, entry, reason):
if entry not in self.map:
# Won't actually be able to get difference for this entry if we don't have a pts to start off from.
if entry in self.possible_gaps:
raise RuntimeError('Should not have a possible_gap for an entry not in the state map')
if __debug__:
self._trace('Should get difference for %r because %s but cannot due to missing hash', entry, reason)
return
if __debug__:
self._trace('Marking %r as needing difference because %s', entry, reason)
self.getting_diff_for.add(entry)
self.possible_gaps.pop(entry, None)
@ -285,8 +381,9 @@ class MessageBox:
try:
self.getting_diff_for.remove(entry)
except KeyError:
pass
self.reset_deadline(entry, next_updates_deadline())
raise RuntimeError('Called end_get_diff on an entry which was not getting diff for')
self.reset_deadlines({entry}, next_updates_deadline())
assert entry not in self.possible_gaps, "gaps shouldn't be created while getting difference"
# endregion Creation, querying, and setting base state.
@ -311,42 +408,72 @@ class MessageBox:
chat_hashes,
result, # out list of updates; returns list of user, chat, or raise if gap
):
date = getattr(updates, 'date', None)
if date is None:
# updatesTooLong is the only one with no date (we treat it as a gap)
raise GapError
seq = getattr(updates, 'seq', None) or NO_SEQ
seq_start = getattr(updates, 'seq_start', None) or seq
# v1 has never sent updates produced by the client itself to the handlers.
# However proper update handling requires those to be processed.
# This is an ugly workaround for that.
self_outgoing = getattr(updates, '_self_outgoing', False)
real_result = result
result = []
date = getattr(updates, 'date', None)
seq = getattr(updates, 'seq', None)
seq_start = getattr(updates, 'seq_start', None)
users = getattr(updates, 'users', None) or []
chats = getattr(updates, 'chats', None) or []
updates = getattr(updates, 'updates', None) or [updates]
if __debug__:
self._trace('Processing updates with seq = %r, seq_start = %r, date = %s: %s',
seq, seq_start, date.isoformat() if date else None, updates)
if date is None:
# updatesTooLong is the only one with no date (we treat it as a gap)
self.try_begin_get_diff(ENTRY_ACCOUNT, 'received updatesTooLong')
raise GapError
if seq is None:
seq = NO_SEQ
if seq_start is None:
seq_start = seq
# updateShort is the only update which cannot be dispatched directly but doesn't have 'updates' field
updates = getattr(updates, 'updates', None) or [updates.update if isinstance(updates, tl.UpdateShort) else updates]
for u in updates:
u._self_outgoing = self_outgoing
# > For all the other [not `updates` or `updatesCombined`] `Updates` type constructors
# > there is no need to check `seq` or change a local state.
if seq_start != NO_SEQ:
if self.seq + 1 > seq_start:
# Skipping updates that were already handled
if __debug__:
self._trace('Skipping updates as they should have already been handled')
return (users, chats)
elif self.seq + 1 < seq_start:
# Gap detected
self.begin_get_diff(ENTRY_ACCOUNT)
self.try_begin_get_diff(ENTRY_ACCOUNT, 'detected gap')
raise GapError
# else apply
self.date = date
if seq != NO_SEQ:
self.seq = seq
result.extend(filter(None, (self.apply_pts_info(u, reset_deadline=True) for u in updates)))
self.apply_deadlines_reset()
def _sort_gaps(update):
pts = PtsInfo.from_update(update)
return pts.pts - pts.pts_count if pts else 0
reset_deadlines = set() # temporary buffer
result.extend(filter(None, (
self.apply_pts_info(u, reset_deadlines=reset_deadlines)
# Telegram can send updates out of order (e.g. ReadChannelInbox first
# and then NewChannelMessage, both with the same pts, but the count is
# 0 and 1 respectively), so we sort them first.
for u in sorted(updates, key=_sort_gaps))))
self.reset_deadlines(reset_deadlines, next_updates_deadline())
if self.possible_gaps:
if __debug__:
self._trace('Trying to re-apply %r possible gaps', len(self.possible_gaps))
# For each update in possible gaps, see if the gap has been resolved already.
for key in list(self.possible_gaps.keys()):
self.possible_gaps[key].updates.sort(key=_sort_gaps)
@ -356,13 +483,27 @@ class MessageBox:
# If this fails to apply, it will get re-inserted at the end.
# All should fail, so the order will be preserved (it would've cycled once).
update = self.apply_pts_info(update, reset_deadline=False)
update = self.apply_pts_info(update, reset_deadlines=None)
if update:
result.append(update)
if __debug__:
self._trace('Resolved gap with %r: %s', PtsInfo.from_update(update), update)
# Clear now-empty gaps.
self.possible_gaps = {entry: gap for entry, gap in self.possible_gaps.items() if gap.updates}
real_result.extend(u for u in result if not u._self_outgoing)
if result and not self.possible_gaps:
# > If the updates were applied, local *Updates* state must be updated
# > with `seq` (unless it's 0) and `date` from the constructor.
if __debug__:
self._trace('Updating seq as all updates were applied')
if date != epoch():
self.date = date
if seq != NO_SEQ:
self.seq = seq
return (users, chats)
# Tries to apply the input update if its `PtsInfo` follows the correct order.
@ -374,36 +515,51 @@ class MessageBox:
self,
update,
*,
reset_deadline,
reset_deadlines,
):
# This update means we need to call getChannelDifference to get the updates from the channel
if isinstance(update, tl.UpdateChannelTooLong):
self.try_begin_get_diff(update.channel_id, 'received updateChannelTooLong')
return None
pts = PtsInfo.from_update(update)
if not pts:
# No pts means that the update can be applied in any order.
if __debug__:
self._trace('No pts in update, so it can be applied in any order: %s', update)
return update
# As soon as we receive an update of any form related to messages (has `PtsInfo`),
# the "no updates" period for that entry is reset.
#
# Build the `HashSet` to avoid calling `reset_deadline` more than once for the same entry.
if reset_deadline:
self.reset_deadlines_for.add(pts.entry)
#
# By the time this method returns, self.map will have an entry for which we can reset its deadline.
if reset_deadlines:
reset_deadlines.add(pts.entry)
if pts.entry in self.getting_diff_for:
# Note: early returning here also prevents gap from being inserted (which they should
# not be while getting difference).
if __debug__:
self._trace('Skipping update with %r as its difference is being fetched', pts)
return None
if pts.entry in self.map:
local_pts = self.map[pts.entry].pts
if local_pts + pts.pts_count > pts.pts:
# Ignore
if __debug__:
self._trace('Skipping update since local pts %r > %r: %s', local_pts, pts, update)
return None
elif local_pts + pts.pts_count < pts.pts:
# Possible gap
# TODO store chats too?
if __debug__:
self._trace('Possible gap since local pts %r < %r: %s', local_pts, pts, update)
if pts.entry not in self.possible_gaps:
self.possible_gaps[pts.entry] = PossibleGap(
deadline=asyncio.get_running_loop().time() + POSSIBLE_GAP_TIMEOUT,
deadline=get_running_loop().time() + POSSIBLE_GAP_TIMEOUT,
updates=[]
)
@ -411,23 +567,36 @@ class MessageBox:
return None
else:
# Apply
pass
else:
# No previous `pts` known, and because this update has to be "right" (it's the first one) our
# `local_pts` must be one less.
local_pts = pts.pts - 1
if __debug__:
self._trace('Applying update pts since local pts %r = %r: %s', local_pts, pts, update)
# For example, when we're in a channel, we immediately receive:
# * ReadChannelInbox (pts = X)
# In a channel, we may immediately receive:
# * ReadChannelInbox (pts = X, pts_count = 0)
# * NewChannelMessage (pts = X, pts_count = 1)
#
# Notice how both `pts` are the same. If we stored the one from the first, then the second one would
# be considered "already handled" and ignored, which is not desirable. Instead, advance local `pts`
# by `pts_count` (which is 0 for updates not directly related to messages, like reading inbox).
# Notice how both `pts` are the same. If they were to be applied out of order, the first
# one however would've triggered a gap because `local_pts` + `pts_count` of 0 would be
# less than `remote_pts`. So there is no risk by setting the `local_pts` to match the
# `remote_pts` here of missing the new message.
#
# The message would however be lost if we initialized the pts with the first one, since
# the second one would appear "already handled". To prevent this we set the pts to be
# one less when the count is 0 (which might be wrong and trigger a gap later on, but is
# unlikely). This will prevent us from losing updates in the unlikely scenario where these
# two updates arrive in different packets (and therefore couldn't be sorted beforehand).
if pts.entry in self.map:
self.map[pts.entry].pts = local_pts + pts.pts_count
self.map[pts.entry].pts = pts.pts
else:
self.map[pts.entry] = State(pts=local_pts + pts.pts_count, deadline=next_updates_deadline())
# When a chat is migrated to a megagroup, the first update can be a `ReadChannelInbox`
# with `pts = 1, pts_count = 0` followed by a `NewChannelMessage` with `pts = 2, pts_count=1`.
# Note how the `pts` for the message is 2 and not 1 unlike the case described before!
# This is likely because the `pts` cannot be 0 (or it would fail with PERSISTENT_TIMESTAMP_EMPTY),
# which forces the first update to be 1. But if we got difference with 1 and the second update
# also used 1, we would miss it, so Telegram probably uses 2 to work around that.
self.map[pts.entry] = State(
pts=(pts.pts - (0 if pts.pts_count else 1)) or 1,
deadline=next_updates_deadline()
)
return update
@ -437,18 +606,20 @@ class MessageBox:
# Return the request that needs to be made to get the difference, if any.
def get_difference(self):
entry = ENTRY_ACCOUNT
if entry in self.getting_diff_for:
if entry in self.map:
return _tl.fn.updates.GetDifference(
for entry in (ENTRY_ACCOUNT, ENTRY_SECRET):
if entry in self.getting_diff_for:
if entry not in self.map:
raise RuntimeError('Should not try to get difference for an entry without known state')
gd = fn.updates.GetDifferenceRequest(
pts=self.map[ENTRY_ACCOUNT].pts,
pts_total_limit=None,
date=self.date,
qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ,
)
else:
# TODO investigate when/why/if this can happen
self.end_get_diff(entry)
if __debug__:
self._trace('Requesting account difference %s', gd)
return gd
return None
@ -458,46 +629,91 @@ class MessageBox:
diff,
chat_hashes,
):
if isinstance(diff, _tl.updates.DifferenceEmpty):
if __debug__:
self._trace('Applying account difference %s', diff)
finish = None
result = None
if isinstance(diff, tl.updates.DifferenceEmpty):
finish = True
self.date = diff.date
self.seq = diff.seq
self.end_get_diff(ENTRY_ACCOUNT)
return [], [], []
elif isinstance(diff, _tl.updates.Difference):
self.end_get_diff(ENTRY_ACCOUNT)
result = [], [], []
elif isinstance(diff, tl.updates.Difference):
finish = True
chat_hashes.extend(diff.users, diff.chats)
return self.apply_difference_type(diff)
elif isinstance(diff, _tl.updates.DifferenceSlice):
result = self.apply_difference_type(diff, chat_hashes)
elif isinstance(diff, tl.updates.DifferenceSlice):
finish = False
chat_hashes.extend(diff.users, diff.chats)
return self.apply_difference_type(diff)
elif isinstance(diff, _tl.updates.DifferenceTooLong):
# TODO when are deadlines reset if we update the map??
self.map[ENTRY_ACCOUNT].pts = diff.pts
self.end_get_diff(ENTRY_ACCOUNT)
return [], [], []
result = self.apply_difference_type(diff, chat_hashes)
elif isinstance(diff, tl.updates.DifferenceTooLong):
finish = True
self.map[ENTRY_ACCOUNT].pts = diff.pts # the deadline will be reset once the diff ends
result = [], [], []
if finish:
account = ENTRY_ACCOUNT in self.getting_diff_for
secret = ENTRY_SECRET in self.getting_diff_for
if not account and not secret:
raise RuntimeError('Should not be applying the difference when neither account or secret was diff was active')
# Both may be active if both expired at the same time.
if account:
self.end_get_diff(ENTRY_ACCOUNT)
if secret:
self.end_get_diff(ENTRY_SECRET)
return result
def apply_difference_type(
self,
diff,
chat_hashes,
):
state = getattr(diff, 'intermediate_state', None) or diff.state
self.set_state(state)
self.set_state(state, reset=False)
for u in diff.other_updates:
if isinstance(u, _tl.UpdateChannelTooLong):
self.begin_get_diff(u.channel_id)
# diff.other_updates can contain things like UpdateChannelTooLong and UpdateNewChannelMessage.
# We need to process those as if they were socket updates to discard any we have already handled.
updates = []
self.process_updates(tl.Updates(
updates=diff.other_updates,
users=diff.users,
chats=diff.chats,
date=epoch(),
seq=NO_SEQ, # this way date is not used
), chat_hashes, updates)
diff.other_updates.extend(_tl.UpdateNewMessage(
updates.extend(tl.UpdateNewMessage(
message=m,
pts=NO_SEQ,
pts_count=NO_SEQ,
) for m in diff.new_messages)
diff.other_updates.extend(_tl.UpdateNewEncryptedMessage(
updates.extend(tl.UpdateNewEncryptedMessage(
message=m,
qts=NO_SEQ,
) for m in diff.new_encrypted_messages)
return diff.other_updates, diff.users, diff.chats
return updates, diff.users, diff.chats
def end_difference(self):
if __debug__:
self._trace('Ending account difference')
account = ENTRY_ACCOUNT in self.getting_diff_for
secret = ENTRY_SECRET in self.getting_diff_for
if not account and not secret:
raise RuntimeError('Should not be ending get difference when neither account or secret was diff was active')
# Both may be active if both expired at the same time.
if account:
self.end_get_diff(ENTRY_ACCOUNT)
if secret:
self.end_get_diff(ENTRY_SECRET)
# endregion Getting and applying account difference.
@ -515,6 +731,7 @@ class MessageBox:
packed = chat_hashes.get(entry)
if not packed:
# Cannot get channel difference as we're missing its hash
# TODO we should probably log this
self.end_get_diff(entry)
# Remove the outdated `pts` entry from the map so that the next update can correct
# it. Otherwise, it will spam that the access hash is missing.
@ -523,18 +740,18 @@ class MessageBox:
state = self.map.get(entry)
if not state:
# TODO investigate when/why/if this can happen
# Cannot get channel difference as we're missing its pts
self.end_get_diff(entry)
return None
raise RuntimeError('Should not try to get difference for an entry without known state')
return _tl.fn.updates.GetChannelDifference(
gd = fn.updates.GetChannelDifferenceRequest(
force=False,
channel=_tl.InputChannel(packed.id, packed.hash),
filter=_tl.ChannelMessagesFilterEmpty(),
channel=tl.InputChannel(packed.id, packed.hash),
filter=tl.ChannelMessagesFilterEmpty(),
pts=state.pts,
limit=BOT_CHANNEL_DIFF_LIMIT if chat_hashes.self_bot else USER_CHANNEL_DIFF_LIMIT
)
if __debug__:
self._trace('Requesting channel difference %s', gd)
return gd
# Similar to [`MessageBox::process_updates`], but using the result from getting difference.
def apply_channel_difference(
@ -544,14 +761,17 @@ class MessageBox:
chat_hashes,
):
entry = request.channel.channel_id
if __debug__:
self._trace('Applying channel difference for %r: %s', entry, diff)
self.possible_gaps.pop(entry, None)
if isinstance(diff, _tl.updates.ChannelDifferenceEmpty):
if isinstance(diff, tl.updates.ChannelDifferenceEmpty):
assert diff.final
self.end_get_diff(entry)
self.map[entry].pts = diff.pts
return [], [], []
elif isinstance(diff, _tl.updates.ChannelDifferenceTooLong):
elif isinstance(diff, tl.updates.ChannelDifferenceTooLong):
assert diff.final
self.map[entry].pts = diff.dialog.pts
chat_hashes.extend(diff.users, diff.chats)
@ -560,19 +780,46 @@ class MessageBox:
# be strange to give the user only partial changes of these when they would
# expect all updates to be fetched. Instead, nothing is returned.
return [], [], []
elif isinstance(diff, _tl.updates.ChannelDifference):
elif isinstance(diff, tl.updates.ChannelDifference):
if diff.final:
self.end_get_diff(entry)
self.map[entry].pts = diff.pts
diff.other_updates.extend(_tl.UpdateNewMessage(
chat_hashes.extend(diff.users, diff.chats)
updates = []
self.process_updates(tl.Updates(
updates=diff.other_updates,
users=diff.users,
chats=diff.chats,
date=epoch(),
seq=NO_SEQ, # this way date is not used
), chat_hashes, updates)
updates.extend(tl.UpdateNewChannelMessage(
message=m,
pts=NO_SEQ,
pts_count=NO_SEQ,
) for m in diff.new_messages)
chat_hashes.extend(diff.users, diff.chats)
self.reset_channel_deadline(entry, None)
return diff.other_updates, diff.users, diff.chats
return updates, diff.users, diff.chats
def end_channel_difference(self, request, reason: PrematureEndReason, chat_hashes):
entry = request.channel.channel_id
if __debug__:
self._trace('Ending channel difference for %r because %s', entry, reason)
if reason == PrematureEndReason.TEMPORARY_SERVER_ISSUES:
# Temporary issues. End getting difference without updating the pts so we can retry later.
self.possible_gaps.pop(entry, None)
self.end_get_diff(entry)
elif reason == PrematureEndReason.BANNED:
# Banned in the channel. Forget its state since we can no longer fetch updates from it.
self.possible_gaps.pop(entry, None)
self.end_get_diff(entry)
del self.map[entry]
else:
raise RuntimeError('Unknown reason to end channel difference')
# endregion Getting and applying channel difference.

View File

@ -1,28 +1,8 @@
from typing import Optional, Tuple
from dataclasses import dataclass
from enum import IntEnum
from ..tl.types import InputPeerUser, InputPeerChat, InputPeerChannel
import struct
@dataclass(frozen=True)
class DataCenter:
"""
Stores the information needed to connect to a datacenter.
* id: 32-bit number representing the datacenter identifier as given by Telegram.
* ipv4 and ipv6: 32-bit or 128-bit number storing the IP address of the datacenter.
* port: 16-bit number storing the port number needed to connect to the datacenter.
* bytes: arbitrary binary payload needed to authenticate to the datacenter.
"""
__slots__ = ('id', 'ipv4', 'ipv6', 'port', 'auth')
id: int
ipv4: int
ipv6: Optional[int]
port: int
auth: bytes
@dataclass(frozen=True)
class SessionState:
"""
Stores the information needed to fetch updates and about the current user.
@ -41,17 +21,30 @@ class SessionState:
"""
__slots__ = ('user_id', 'dc_id', 'bot', 'pts', 'qts', 'date', 'seq', 'takeout_id')
user_id: int
dc_id: int
bot: bool
pts: int
qts: int
date: int
seq: int
takeout_id: Optional[int]
def __init__(
self,
user_id: int,
dc_id: int,
bot: bool,
pts: int,
qts: int,
date: int,
seq: int,
takeout_id: Optional[int]
):
self.user_id = user_id
self.dc_id = dc_id
self.bot = bot
self.pts = pts
self.qts = qts
self.date = date
self.seq = seq
self.takeout_id = takeout_id
def __repr__(self):
return repr({k: getattr(self, k) for k in self.__slots__})
@dataclass(frozen=True)
class ChannelState:
"""
Stores the information needed to fetch updates from a channel.
@ -61,8 +54,16 @@ class ChannelState:
"""
__slots__ = ('channel_id', 'pts')
channel_id: int
pts: int
def __init__(
self,
channel_id: int,
pts: int,
):
self.channel_id = channel_id
self.pts = pts
def __repr__(self):
return repr({k: getattr(self, k) for k in self.__slots__})
class EntityType(IntEnum):
@ -100,7 +101,6 @@ _canon_entity_types = {
}
@dataclass(frozen=True)
class Entity:
"""
Stores the information needed to use a certain user, chat or channel with the API.
@ -115,9 +115,15 @@ class Entity:
"""
__slots__ = ('ty', 'id', 'hash')
ty: EntityType
id: int
hash: int
def __init__(
self,
ty: EntityType,
id: int,
hash: int
):
self.ty = ty
self.id = id
self.hash = hash
@property
def is_user(self):
@ -167,7 +173,7 @@ class Entity:
try:
ty, id, hash = struct.unpack('<Bqq', blob)
except struct.error:
raise ValueError(f'malformed entity data, got {string!r}') from None
raise ValueError(f'malformed entity data, got {blob!r}') from None
return cls(EntityType(ty), id, hash)
@ -176,3 +182,14 @@ class Entity:
def __bytes__(self):
return struct.pack('<Bqq', self.ty, self.id, self.hash)
def _as_input_peer(self):
if self.is_user:
return InputPeerUser(self.id, self.hash)
elif self.ty == EntityType.GROUP:
return InputPeerChat(self.id)
else:
return InputPeerChannel(self.id, self.hash)
def __repr__(self):
return repr({k: getattr(self, k) for k in self.__slots__})

View File

@ -0,0 +1,25 @@
"""
This package defines clients as subclasses of others, and then a single
`telethon.client.telegramclient.TelegramClient` which is subclass of them
all to provide the final unified interface while the methods can live in
different subclasses to be more maintainable.
The ABC is `telethon.client.telegrambaseclient.TelegramBaseClient` and the
first implementor is `telethon.client.users.UserMethods`, since calling
requests require them to be resolved first, and that requires accessing
entities (users).
"""
from .telegrambaseclient import TelegramBaseClient
from .users import UserMethods # Required for everything
from .messageparse import MessageParseMethods # Required for messages
from .uploads import UploadMethods # Required for messages to send files
from .updates import UpdateMethods # Required for buttons (register callbacks)
from .buttons import ButtonMethods # Required for messages to use buttons
from .messages import MessageMethods
from .chats import ChatMethods
from .dialogs import DialogMethods
from .downloads import DownloadMethods
from .account import AccountMethods
from .auth import AuthMethods
from .bots import BotMethods
from .telegramclient import TelegramClient

243
telethon/client/account.py Normal file
View File

@ -0,0 +1,243 @@
import functools
import inspect
import typing
from .users import _NOT_A_REQUEST
from .. import helpers, utils
from ..tl import functions, TLRequest
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
# TODO Make use of :tl:`InvokeWithMessagesRange` somehow
# For that, we need to use :tl:`GetSplitRanges` first.
class _TakeoutClient:
"""
Proxy object over the client.
"""
__PROXY_INTERFACE = ('__enter__', '__exit__', '__aenter__', '__aexit__')
def __init__(self, finalize, client, request):
# We use the name mangling for attributes to make them inaccessible
# from within the shadowed client object and to distinguish them from
# its own attributes where needed.
self.__finalize = finalize
self.__client = client
self.__request = request
self.__success = None
@property
def success(self):
return self.__success
@success.setter
def success(self, value):
self.__success = value
async def __aenter__(self):
# Enter/Exit behaviour is "overrode", we don't want to call start.
client = self.__client
if client.session.takeout_id is None:
client.session.takeout_id = (await client(self.__request)).id
elif self.__request is not None:
raise ValueError("Can't send a takeout request while another "
"takeout for the current session still not been finished yet.")
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self.__success is None and self.__finalize:
self.__success = exc_type is None
if self.__success is not None:
result = await self(functions.account.FinishTakeoutSessionRequest(
self.__success))
if not result:
raise ValueError("Failed to finish the takeout.")
self.session.takeout_id = None
__enter__ = helpers._sync_enter
__exit__ = helpers._sync_exit
async def __call__(self, request, ordered=False):
takeout_id = self.__client.session.takeout_id
if takeout_id is None:
raise ValueError('Takeout mode has not been initialized '
'(are you calling outside of "with"?)')
single = not utils.is_list_like(request)
requests = ((request,) if single else request)
wrapped = []
for r in requests:
if not isinstance(r, TLRequest):
raise _NOT_A_REQUEST()
await r.resolve(self, utils)
wrapped.append(functions.InvokeWithTakeoutRequest(takeout_id, r))
return await self.__client(
wrapped[0] if single else wrapped, ordered=ordered)
def __getattribute__(self, name):
# We access class via type() because __class__ will recurse infinitely.
# Also note that since we've name-mangled our own class attributes,
# they'll be passed to __getattribute__() as already decorated. For
# example, 'self.__client' will be passed as '_TakeoutClient__client'.
# https://docs.python.org/3/tutorial/classes.html#private-variables
if name.startswith('__') and name not in type(self).__PROXY_INTERFACE:
raise AttributeError # force call of __getattr__
# Try to access attribute in the proxy object and check for the same
# attribute in the shadowed object (through our __getattr__) if failed.
return super().__getattribute__(name)
def __getattr__(self, name):
value = getattr(self.__client, name)
if inspect.ismethod(value):
# Emulate bound methods behavior by partially applying our proxy
# class as the self parameter instead of the client.
return functools.partial(
getattr(self.__client.__class__, name), self)
return value
def __setattr__(self, name, value):
if name.startswith('_{}__'.format(type(self).__name__.lstrip('_'))):
# This is our own name-mangled attribute, keep calm.
return super().__setattr__(name, value)
return setattr(self.__client, name, value)
class AccountMethods:
def takeout(
self: 'TelegramClient',
finalize: bool = True,
*,
contacts: bool = None,
users: bool = None,
chats: bool = None,
megagroups: bool = None,
channels: bool = None,
files: bool = None,
max_file_size: bool = None) -> 'TelegramClient':
"""
Returns a :ref:`telethon-client` which calls methods behind a takeout session.
It does so by creating a proxy object over the current client through
which making requests will use :tl:`InvokeWithTakeoutRequest` to wrap
them. In other words, returns the current client modified so that
requests are done as a takeout:
Some of the calls made through the takeout session will have lower
flood limits. This is useful if you want to export the data from
conversations or mass-download media, since the rate limits will
be lower. Only some requests will be affected, and you will need
to adjust the `wait_time` of methods like `client.iter_messages
<telethon.client.messages.MessageMethods.iter_messages>`.
By default, all parameters are `None`, and you need to enable those
you plan to use by setting them to either `True` or `False`.
You should ``except errors.TakeoutInitDelayError as e``, since this
exception will raise depending on the condition of the session. You
can then access ``e.seconds`` to know how long you should wait for
before calling the method again.
There's also a `success` property available in the takeout proxy
object, so from the `with` body you can set the boolean result that
will be sent back to Telegram. But if it's left `None` as by
default, then the action is based on the `finalize` parameter. If
it's `True` then the takeout will be finished, and if no exception
occurred during it, then `True` will be considered as a result.
Otherwise, the takeout will not be finished and its ID will be
preserved for future usage as `client.session.takeout_id
<telethon.sessions.abstract.Session.takeout_id>`.
Arguments
finalize (`bool`):
Whether the takeout session should be finalized upon
exit or not.
contacts (`bool`):
Set to `True` if you plan on downloading contacts.
users (`bool`):
Set to `True` if you plan on downloading information
from users and their private conversations with you.
chats (`bool`):
Set to `True` if you plan on downloading information
from small group chats, such as messages and media.
megagroups (`bool`):
Set to `True` if you plan on downloading information
from megagroups (channels), such as messages and media.
channels (`bool`):
Set to `True` if you plan on downloading information
from broadcast channels, such as messages and media.
files (`bool`):
Set to `True` if you plan on downloading media and
you don't only wish to export messages.
max_file_size (`int`):
The maximum file size, in bytes, that you plan
to download for each message with media.
Example
.. code-block:: python
from telethon import errors
try:
async with client.takeout() as takeout:
await client.get_messages('me') # normal call
await takeout.get_messages('me') # wrapped through takeout (less limits)
async for message in takeout.iter_messages(chat, wait_time=0):
... # Do something with the message
except errors.TakeoutInitDelayError as e:
print('Must wait', e.seconds, 'before takeout')
"""
request_kwargs = dict(
contacts=contacts,
message_users=users,
message_chats=chats,
message_megagroups=megagroups,
message_channels=channels,
files=files,
file_max_size=max_file_size
)
arg_specified = (arg is not None for arg in request_kwargs.values())
if self.session.takeout_id is None or any(arg_specified):
request = functions.account.InitTakeoutSessionRequest(
**request_kwargs)
else:
request = None
return _TakeoutClient(finalize, self, request)
async def end_takeout(self: 'TelegramClient', success: bool) -> bool:
"""
Finishes the current takeout session.
Arguments
success (`bool`):
Whether the takeout completed successfully or not.
Returns
`True` if the operation was successful, `False` otherwise.
Example
.. code-block:: python
await client.end_takeout(success=False)
"""
try:
async with _TakeoutClient(True, self, None) as takeout:
takeout.success = success
except ValueError:
return False
return True

677
telethon/client/auth.py Normal file
View File

@ -0,0 +1,677 @@
import getpass
import inspect
import os
import sys
import typing
import warnings
from .. import utils, helpers, errors, password as pwd_mod
from ..tl import types, functions, custom
from .._updates import SessionState
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
class AuthMethods:
# region Public methods
def start(
self: 'TelegramClient',
phone: typing.Union[typing.Callable[[], str], str] = lambda: input('Please enter your phone (or bot token): '),
password: typing.Union[typing.Callable[[], str], str] = lambda: getpass.getpass('Please enter your password: '),
*,
bot_token: str = None,
force_sms: bool = False,
code_callback: typing.Callable[[], typing.Union[str, int]] = None,
first_name: str = 'New User',
last_name: str = '',
max_attempts: int = 3) -> 'TelegramClient':
"""
Starts the client (connects and logs in if necessary).
By default, this method will be interactive (asking for
user input if needed), and will handle 2FA if enabled too.
If the event loop is already running, this method returns a
coroutine that you should await on your own code; otherwise
the loop is ran until said coroutine completes.
Arguments
phone (`str` | `int` | `callable`):
The phone (or callable without arguments to get it)
to which the code will be sent. If a bot-token-like
string is given, it will be used as such instead.
The argument may be a coroutine.
password (`str`, `callable`, optional):
The password for 2 Factor Authentication (2FA).
This is only required if it is enabled in your account.
The argument may be a coroutine.
bot_token (`str`):
Bot Token obtained by `@BotFather <https://t.me/BotFather>`_
to log in as a bot. Cannot be specified with ``phone`` (only
one of either allowed).
force_sms (`bool`, optional):
Whether to force sending the code request as SMS.
This only makes sense when signing in with a `phone`.
code_callback (`callable`, optional):
A callable that will be used to retrieve the Telegram
login code. Defaults to `input()`.
The argument may be a coroutine.
first_name (`str`, optional):
The first name to be used if signing up. This has no
effect if the account already exists and you sign in.
last_name (`str`, optional):
Similar to the first name, but for the last. Optional.
max_attempts (`int`, optional):
How many times the code/password callback should be
retried or switching between signing in and signing up.
Returns
This `TelegramClient`, so initialization
can be chained with ``.start()``.
Example
.. code-block:: python
client = TelegramClient('anon', api_id, api_hash)
# Starting as a bot account
await client.start(bot_token=bot_token)
# Starting as a user account
await client.start(phone)
# Please enter the code you received: 12345
# Please enter your password: *******
# (You are now logged in)
# Starting using a context manager (this calls start()):
with client:
pass
"""
if code_callback is None:
def code_callback():
return input('Please enter the code you received: ')
elif not callable(code_callback):
raise ValueError(
'The code_callback parameter needs to be a callable '
'function that returns the code you received by Telegram.'
)
if not phone and not bot_token:
raise ValueError('No phone number or bot token provided.')
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')
coro = self._start(
phone=phone,
password=password,
bot_token=bot_token,
force_sms=force_sms,
code_callback=code_callback,
first_name=first_name,
last_name=last_name,
max_attempts=max_attempts
)
return (
coro if self.loop.is_running()
else self.loop.run_until_complete(coro)
)
async def _start(
self: 'TelegramClient', phone, password, bot_token, force_sms,
code_callback, first_name, last_name, max_attempts):
if not self.is_connected():
await self.connect()
# Rather than using `is_user_authorized`, use `get_me`. While this is
# more expensive and needs to retrieve more data from the server, it
# enables the library to warn users trying to login to a different
# account. See #1172.
me = await self.get_me()
if me is not None:
# The warnings here are on a best-effort and may fail.
if bot_token:
# bot_token's first part has the bot ID, but it may be invalid
# so don't try to parse as int (instead cast our ID to string).
if bot_token[:bot_token.find(':')] != str(me.id):
warnings.warn(
'the session already had an authorized user so it did '
'not login to the bot account using the provided bot_token; '
'if you were expecting a different user, check whether '
'you are accidentally reusing an existing session'
)
elif phone and not callable(phone) and utils.parse_phone(phone) != me.phone:
warnings.warn(
'the session already had an authorized user so it did '
'not login to the user account using the provided phone; '
'if you were expecting a different user, check whether '
'you are accidentally reusing an existing session'
)
return self
if not bot_token:
# Turn the callable into a valid phone number (or bot token)
while callable(phone):
value = phone()
if inspect.isawaitable(value):
value = await value
if ':' in value:
# Bot tokens have 'user_id:access_hash' format
bot_token = value
break
phone = utils.parse_phone(value) or phone
if bot_token:
await self.sign_in(bot_token=bot_token)
return self
me = None
attempts = 0
two_step_detected = False
await self.send_code_request(phone, force_sms=force_sms)
while attempts < max_attempts:
try:
value = code_callback()
if inspect.isawaitable(value):
value = await value
# Since sign-in with no code works (it sends the code)
# we must double-check that here. Else we'll assume we
# logged in, and it will return None as the User.
if not value:
raise errors.PhoneCodeEmptyError(request=None)
# Raises SessionPasswordNeededError if 2FA enabled
me = await self.sign_in(phone, code=value)
break
except errors.SessionPasswordNeededError:
two_step_detected = True
break
except (errors.PhoneCodeEmptyError,
errors.PhoneCodeExpiredError,
errors.PhoneCodeHashEmptyError,
errors.PhoneCodeInvalidError):
print('Invalid code. Please try again.', file=sys.stderr)
attempts += 1
else:
raise RuntimeError(
'{} consecutive sign-in attempts failed. Aborting'
.format(max_attempts)
)
if two_step_detected:
if not password:
raise ValueError(
"Two-step verification is enabled for this account. "
"Please provide the 'password' argument to 'start()'."
)
if callable(password):
for _ in range(max_attempts):
try:
value = password()
if inspect.isawaitable(value):
value = await value
me = await self.sign_in(phone=phone, password=value)
break
except errors.PasswordHashInvalidError:
print('Invalid password. Please try again',
file=sys.stderr)
else:
raise errors.PasswordHashInvalidError(request=None)
else:
me = await self.sign_in(phone=phone, password=password)
# We won't reach here if any step failed (exit by exception)
signed, name = 'Signed in successfully as ', utils.get_display_name(me)
tos = '; remember to not break the ToS or you will risk an account ban!'
try:
print(signed, name, tos, sep='')
except UnicodeEncodeError:
# Some terminals don't support certain characters
print(signed, name.encode('utf-8', errors='ignore')
.decode('ascii', errors='ignore'), tos, sep='')
return self
def _parse_phone_and_hash(self, phone, phone_hash):
"""
Helper method to both parse and validate phone and its hash.
"""
phone = utils.parse_phone(phone) or self._phone
if not phone:
raise ValueError(
'Please make sure to call send_code_request first.'
)
phone_hash = phone_hash or self._phone_code_hash.get(phone, None)
if not phone_hash:
raise ValueError('You also need to provide a phone_code_hash.')
return phone, phone_hash
async def sign_in(
self: 'TelegramClient',
phone: str = None,
code: typing.Union[str, int] = None,
*,
password: str = None,
bot_token: str = None,
phone_code_hash: str = None) -> 'typing.Union[types.User, types.auth.SentCode]':
"""
Logs in to Telegram to an existing user or bot account.
You should only use this if you are not authorized yet.
This method will send the code if it's not provided.
.. note::
In most cases, you should simply use `start()` and not this method.
Arguments
phone (`str` | `int`):
The phone to send the code to if no code was provided,
or to override the phone that was previously used with
these requests.
code (`str` | `int`):
The code that Telegram sent. Note that if you have sent this
code through the application itself it will immediately
expire. If you want to send the code, obfuscate it somehow.
If you're not doing any of this you can ignore this note.
password (`str`):
2FA password, should be used if a previous call raised
``SessionPasswordNeededError``.
bot_token (`str`):
Used to sign in as a bot. Not all requests will be available.
This should be the hash the `@BotFather <https://t.me/BotFather>`_
gave you.
phone_code_hash (`str`, optional):
The hash returned by `send_code_request`. This can be left as
`None` to use the last hash known for the phone to be used.
Returns
The signed in user, or the information about
:meth:`send_code_request`.
Example
.. code-block:: python
phone = '+34 123 123 123'
await client.sign_in(phone) # send code
code = input('enter code: ')
await client.sign_in(phone, code)
"""
me = await self.get_me()
if me:
return me
if phone and not code and not password:
return await self.send_code_request(phone)
elif code:
phone, phone_code_hash = \
self._parse_phone_and_hash(phone, phone_code_hash)
# May raise PhoneCodeEmptyError, PhoneCodeExpiredError,
# PhoneCodeHashEmptyError or PhoneCodeInvalidError.
request = functions.auth.SignInRequest(
phone, phone_code_hash, str(code)
)
elif password:
pwd = await self(functions.account.GetPasswordRequest())
request = functions.auth.CheckPasswordRequest(
pwd_mod.compute_check(pwd, password)
)
elif bot_token:
request = functions.auth.ImportBotAuthorizationRequest(
flags=0, bot_auth_token=bot_token,
api_id=self.api_id, api_hash=self.api_hash
)
else:
raise ValueError(
'You must provide a phone and a code the first time, '
'and a password only if an RPCError was raised before.'
)
try:
result = await self(request)
except errors.PhoneCodeExpiredError:
self._phone_code_hash.pop(phone, None)
raise
if isinstance(result, types.auth.AuthorizationSignUpRequired):
# Emulate pre-layer 104 behaviour
self._tos = result.terms_of_service
raise errors.PhoneNumberUnoccupiedError(request=request)
return await self._on_login(result.user)
async def sign_up(
self: 'TelegramClient',
code: typing.Union[str, int],
first_name: str,
last_name: str = '',
*,
phone: str = None,
phone_code_hash: str = None) -> 'types.User':
"""
This method can no longer be used, and will immediately raise a ``ValueError``.
See `issue #4050 <https://github.com/LonamiWebs/Telethon/issues/4050>`_ for context.
"""
raise ValueError('Third-party applications cannot sign up for Telegram. See https://github.com/LonamiWebs/Telethon/issues/4050 for details')
async def _on_login(self, user):
"""
Callback called whenever the login or sign up process completes.
Returns the input user parameter.
"""
self._mb_entity_cache.set_self_user(user.id, user.bot, user.access_hash)
self._authorized = True
state = await self(functions.updates.GetStateRequest())
# the server may send an old qts in getState
difference = await self(functions.updates.GetDifferenceRequest(pts=state.pts, date=state.date, qts=state.qts))
if isinstance(difference, types.updates.Difference):
state = difference.state
elif isinstance(difference, types.updates.DifferenceSlice):
state = difference.intermediate_state
elif isinstance(difference, types.updates.DifferenceTooLong):
state.pts = difference.pts
self._message_box.load(SessionState(0, 0, 0, state.pts, state.qts, int(state.date.timestamp()), state.seq, 0), [])
return user
async def send_code_request(
self: 'TelegramClient',
phone: str,
*,
force_sms: bool = False,
_retry_count: int = 0) -> 'types.auth.SentCode':
"""
Sends the Telegram code needed to login to the given phone number.
Arguments
phone (`str` | `int`):
The phone to which the code will be sent.
force_sms (`bool`, optional):
Whether to force sending as SMS. This has been deprecated.
See `issue #4050 <https://github.com/LonamiWebs/Telethon/issues/4050>`_ for context.
Returns
An instance of :tl:`SentCode`.
Example
.. code-block:: python
phone = '+34 123 123 123'
sent = await client.send_code_request(phone)
print(sent)
"""
if force_sms:
warnings.warn('force_sms has been deprecated and no longer works')
force_sms = False
result = None
phone = utils.parse_phone(phone) or self._phone
phone_hash = self._phone_code_hash.get(phone)
if not phone_hash:
try:
result = await self(functions.auth.SendCodeRequest(
phone, self.api_id, self.api_hash, types.CodeSettings()))
except errors.AuthRestartError:
if _retry_count > 2:
raise
return await self.send_code_request(
phone, force_sms=force_sms, _retry_count=_retry_count+1)
# TODO figure out when/if/how this can happen
if isinstance(result, types.auth.SentCodeSuccess):
raise RuntimeError('logged in right after sending the code')
# If we already sent a SMS, do not resend the code (hash may be empty)
if isinstance(result.type, types.auth.SentCodeTypeSms):
force_sms = False
# phone_code_hash may be empty, if it is, do not save it (#1283)
if result.phone_code_hash:
self._phone_code_hash[phone] = phone_hash = result.phone_code_hash
else:
force_sms = True
self._phone = phone
if force_sms:
try:
result = await self(
functions.auth.ResendCodeRequest(phone, phone_hash))
except errors.PhoneCodeExpiredError:
if _retry_count > 2:
raise
self._phone_code_hash.pop(phone, None)
self._log[__name__].info(
"Phone code expired in ResendCodeRequest, requesting a new code"
)
return await self.send_code_request(
phone, force_sms=False, _retry_count=_retry_count+1)
if isinstance(result, types.auth.SentCodeSuccess):
raise RuntimeError('logged in right after resending the code')
self._phone_code_hash[phone] = result.phone_code_hash
return result
async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> custom.QRLogin:
"""
Initiates the QR login procedure.
Note that you must be connected before invoking this, as with any
other request.
It is up to the caller to decide how to present the code to the user,
whether it's the URL, using the token bytes directly, or generating
a QR code and displaying it by other means.
See the documentation for `QRLogin` to see how to proceed after this.
Arguments
ignored_ids (List[`int`]):
List of already logged-in user IDs, to prevent logging in
twice with the same user.
Returns
An instance of `QRLogin`.
Example
.. code-block:: python
def display_url_as_qr(url):
pass # do whatever to show url as a qr to the user
qr_login = await client.qr_login()
display_url_as_qr(qr_login.url)
# Important! You need to wait for the login to complete!
await qr_login.wait()
# If you have 2FA enabled, `wait` will raise `telethon.errors.SessionPasswordNeededError`.
# You should except that error and call `sign_in` with the password if this happens.
"""
qr_login = custom.QRLogin(self, ignored_ids or [])
await qr_login.recreate()
return qr_login
async def log_out(self: 'TelegramClient') -> bool:
"""
Logs out Telegram and deletes the current ``*.session`` file.
The client is unusable after logging out and a new instance should be created.
Returns
`True` if the operation was successful.
Example
.. code-block:: python
# Note: you will need to login again!
await client.log_out()
"""
try:
await self(functions.auth.LogOutRequest())
except errors.RPCError:
return False
self._mb_entity_cache.set_self_user(None, None, None)
self._authorized = False
await self.disconnect()
await utils.maybe_async(self.session.delete())
self.session = None
return True
async def edit_2fa(
self: 'TelegramClient',
current_password: str = None,
new_password: str = None,
*,
hint: str = '',
email: str = None,
email_code_callback: typing.Callable[[int], str] = None) -> bool:
"""
Changes the 2FA settings of the logged in user.
Review carefully the parameter explanations before using this method.
Note that this method may be *incredibly* slow depending on the
prime numbers that must be used during the process to make sure
that everything is safe.
Has no effect if both current and new password are omitted.
Arguments
current_password (`str`, optional):
The current password, to authorize changing to ``new_password``.
Must be set if changing existing 2FA settings.
Must **not** be set if 2FA is currently disabled.
Passing this by itself will remove 2FA (if correct).
new_password (`str`, optional):
The password to set as 2FA.
If 2FA was already enabled, ``current_password`` **must** be set.
Leaving this blank or `None` will remove the password.
hint (`str`, optional):
Hint to be displayed by Telegram when it asks for 2FA.
Leaving unspecified is highly discouraged.
Has no effect if ``new_password`` is not set.
email (`str`, optional):
Recovery and verification email. If present, you must also
set `email_code_callback`, else it raises ``ValueError``.
email_code_callback (`callable`, optional):
If an email is provided, a callback that returns the code sent
to it must also be set. This callback may be asynchronous.
It should return a string with the code. The length of the
code will be passed to the callback as an input parameter.
If the callback returns an invalid code, it will raise
``CodeInvalidError``.
Returns
`True` if successful, `False` otherwise.
Example
.. code-block:: python
# Setting a password for your account which didn't have
await client.edit_2fa(new_password='I_<3_Telethon')
# Removing the password
await client.edit_2fa(current_password='I_<3_Telethon')
"""
if new_password is None and current_password is None:
return False
if email and not callable(email_code_callback):
raise ValueError('email present without email_code_callback')
pwd = await self(functions.account.GetPasswordRequest())
pwd.new_algo.salt1 += os.urandom(32)
assert isinstance(pwd, types.account.Password)
if not pwd.has_password and current_password:
current_password = None
if current_password:
password = pwd_mod.compute_check(pwd, current_password)
else:
password = types.InputCheckPasswordEmpty()
if new_password:
new_password_hash = pwd_mod.compute_digest(
pwd.new_algo, new_password)
else:
new_password_hash = b''
try:
await self(functions.account.UpdatePasswordSettingsRequest(
password=password,
new_settings=types.account.PasswordInputSettings(
new_algo=pwd.new_algo,
new_password_hash=new_password_hash,
hint=hint,
email=email,
new_secure_settings=None
)
))
except errors.EmailUnconfirmedError as e:
code = email_code_callback(e.code_length)
if inspect.isawaitable(code):
code = await code
code = str(code)
await self(functions.account.ConfirmPasswordEmailRequest(code))
return True
# endregion
# region with blocks
async def __aenter__(self):
return await self.start()
async def __aexit__(self, *args):
await self.disconnect()
__enter__ = helpers._sync_enter
__exit__ = helpers._sync_exit
# endregion

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

@ -0,0 +1,72 @@
import typing
from .. import hints
from ..tl import types, functions, custom
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
class BotMethods:
async def inline_query(
self: 'TelegramClient',
bot: 'hints.EntityLike',
query: str,
*,
entity: 'hints.EntityLike' = None,
offset: str = None,
geo_point: 'types.GeoPoint' = None) -> custom.InlineResults:
"""
Makes an inline query to the specified bot (``@vote New Poll``).
Arguments
bot (`entity`):
The bot entity to which the inline query should be made.
query (`str`):
The query that should be made to the bot.
entity (`entity`, optional):
The entity where the inline query is being made from. Certain
bots use this to display different results depending on where
it's used, such as private chats, groups or channels.
If specified, it will also be the default entity where the
message will be sent after clicked. Otherwise, the "empty
peer" will be used, which some bots may not handle correctly.
offset (`str`, optional):
The string offset to use for the bot.
geo_point (:tl:`GeoPoint`, optional)
The geo point location information to send to the bot
for localised results. Available under some bots.
Returns
A list of `custom.InlineResult
<telethon.tl.custom.inlineresult.InlineResult>`.
Example
.. code-block:: python
# Make an inline query to @like
results = await client.inline_query('like', 'Do you like Telethon?')
# Send the first result to some chat
message = await results[0].click('TelethonOffTopic')
"""
bot = await self.get_input_entity(bot)
if entity:
peer = await self.get_input_entity(entity)
else:
peer = types.InputPeerEmpty()
result = await self(functions.messages.GetInlineBotResultsRequest(
bot=bot,
peer=peer,
query=query,
offset=offset or '',
geo_point=geo_point
))
return custom.InlineResults(self, result, entity=peer if entity else None)

101
telethon/client/buttons.py Normal file
View File

@ -0,0 +1,101 @@
import typing
from .. import utils, hints
from ..tl import types, custom
class ButtonMethods:
@staticmethod
def build_reply_markup(
buttons: 'typing.Optional[hints.MarkupLike]'
) -> 'typing.Optional[types.TypeReplyMarkup]':
"""
Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for
the given buttons.
Does nothing if either no buttons are provided or the provided
argument is already a reply markup.
You should consider using this method if you are going to reuse
the markup very often. Otherwise, it is not necessary.
This method is **not** asynchronous (don't use ``await`` on it).
Arguments
buttons (`hints.MarkupLike`):
The button, list of buttons, array of buttons or markup
to convert into a markup.
Example
.. code-block:: python
from telethon import Button
markup = client.build_reply_markup(Button.inline('hi'))
# later
await client.send_message(chat, 'click me', buttons=markup)
"""
if buttons is None:
return None
try:
if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: # crc32(b'ReplyMarkup'):
return buttons
except AttributeError:
pass
if not utils.is_list_like(buttons):
buttons = [[buttons]]
elif not buttons or not utils.is_list_like(buttons[0]):
buttons = [buttons]
is_inline = False
is_normal = False
resize = None
single_use = None
selective = None
persistent = None
placeholder = None
rows = []
for row in buttons:
current = []
for button in row:
if isinstance(button, custom.Button):
if button.resize is not None:
resize = button.resize
if button.single_use is not None:
single_use = button.single_use
if button.selective is not None:
selective = button.selective
if button.persistent is not None:
persistent = button.persistent
if button.placeholder is not None:
placeholder = button.placeholder
button = button.button
elif isinstance(button, custom.MessageButton):
button = button.button
inline = custom.Button._is_inline(button)
is_inline |= inline
is_normal |= not inline
if button.SUBCLASS_OF_ID == 0xbad74a3: # crc32(b'KeyboardButton')
current.append(button)
if current:
rows.append(types.KeyboardButtonRow(current))
if is_inline and is_normal:
raise ValueError('You cannot mix inline with normal buttons')
elif is_inline:
return types.ReplyInlineMarkup(rows)
return types.ReplyKeyboardMarkup(
rows=rows,
resize=resize,
single_use=single_use,
selective=selective,
persistent=persistent,
placeholder=placeholder
)

1336
telethon/client/chats.py Normal file

File diff suppressed because it is too large Load Diff

610
telethon/client/dialogs.py Normal file
View File

@ -0,0 +1,610 @@
import asyncio
import inspect
import itertools
import typing
from .. import helpers, utils, hints, errors
from ..requestiter import RequestIter
from ..tl import types, functions, custom
_MAX_CHUNK_SIZE = 100
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
def _dialog_message_key(peer, message_id):
"""
Get the key to get messages from a dialog.
We cannot just use the message ID because channels share message IDs,
and the peer ID is required to distinguish between them. But it is not
necessary in small group chats and private chats.
"""
return (peer.channel_id if isinstance(peer, types.PeerChannel) else None), message_id
class _DialogsIter(RequestIter):
async def _init(
self, offset_date, offset_id, offset_peer, ignore_pinned, ignore_migrated, folder
):
self.request = functions.messages.GetDialogsRequest(
offset_date=offset_date,
offset_id=offset_id,
offset_peer=offset_peer,
limit=1,
hash=0,
exclude_pinned=ignore_pinned,
folder_id=folder
)
if self.limit <= 0:
# Special case, get a single dialog and determine count
dialogs = await self.client(self.request)
self.total = getattr(dialogs, 'count', len(dialogs.dialogs))
raise StopAsyncIteration
self.seen = set()
self.offset_date = offset_date
self.ignore_migrated = ignore_migrated
async def _load_next_chunk(self):
self.request.limit = min(self.left, _MAX_CHUNK_SIZE)
r = await self.client(self.request)
self.total = getattr(r, 'count', len(r.dialogs))
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)
if not isinstance(x, (types.UserEmpty, types.ChatEmpty))}
self.client._mb_entity_cache.extend(r.users, r.chats)
messages = {}
for m in r.messages:
m._finish_init(self.client, entities, None)
messages[_dialog_message_key(m.peer_id, m.id)] = m
for d in r.dialogs:
# We check the offset date here because Telegram may ignore it
message = messages.get(_dialog_message_key(d.peer, d.top_message))
if self.offset_date:
date = getattr(message, 'date', None)
if not date or date.timestamp() > self.offset_date.timestamp():
continue
peer_id = utils.get_peer_id(d.peer)
if peer_id not in self.seen:
self.seen.add(peer_id)
if peer_id not in entities:
# > In which case can a UserEmpty appear in the list of banned members?
# > In a very rare cases. This is possible but isn't an expected behavior.
# Real world example: https://t.me/TelethonChat/271471
continue
cd = custom.Dialog(self.client, d, entities, message)
if cd.dialog.pts:
self.client._message_box.try_set_channel_state(
utils.get_peer_id(d.peer, add_mark=False), cd.dialog.pts)
if not self.ignore_migrated or getattr(
cd.entity, 'migrated_to', None) is None:
self.buffer.append(cd)
if not self.buffer or len(r.dialogs) < self.request.limit\
or not isinstance(r, types.messages.DialogsSlice):
# Buffer being empty means all returned dialogs were skipped (due to offsets).
# Less than we requested means we reached the end, or
# we didn't get a DialogsSlice which means we got all.
return True
# We can't use `messages[-1]` as the offset ID / date.
# Why? Because pinned dialogs will mess with the order
# in this list. Instead, we find the last dialog which
# has a message, and use it as an offset.
last_message = next(filter(None, (
messages.get(_dialog_message_key(d.peer, d.top_message))
for d in reversed(r.dialogs)
)), None)
self.request.exclude_pinned = True
self.request.offset_id = last_message.id if last_message else 0
self.request.offset_date = last_message.date if last_message else None
self.request.offset_peer = self.buffer[-1].input_entity
class _DraftsIter(RequestIter):
async def _init(self, entities, **kwargs):
if not entities:
r = await self.client(functions.messages.GetAllDraftsRequest())
items = r.updates
else:
peers = []
for entity in entities:
peers.append(types.InputDialogPeer(
await self.client.get_input_entity(entity)))
r = await self.client(functions.messages.GetPeerDialogsRequest(peers))
items = r.dialogs
# TODO Maybe there should be a helper method for this?
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)}
self.buffer.extend(
custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft)
for d in items
)
async def _load_next_chunk(self):
return []
class DialogMethods:
# region Public methods
def iter_dialogs(
self: 'TelegramClient',
limit: float = None,
*,
offset_date: 'hints.DateLike' = None,
offset_id: int = 0,
offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(),
ignore_pinned: bool = False,
ignore_migrated: bool = False,
folder: int = None,
archived: bool = None
) -> _DialogsIter:
"""
Iterator over the dialogs (open conversations/subscribed channels).
The order is the same as the one seen in official applications
(first pinned, them from those with the most recent message to
those with the oldest message).
Arguments
limit (`int` | `None`):
How many dialogs to be retrieved as maximum. Can be set to
`None` to retrieve all dialogs. Note that this may take
whole minutes if you have hundreds of dialogs, as Telegram
will tell the library to slow down through a
``FloodWaitError``.
offset_date (`datetime`, optional):
The offset date to be used.
offset_id (`int`, optional):
The message ID to be used as an offset.
offset_peer (:tl:`InputPeer`, optional):
The peer to be used as an offset.
ignore_pinned (`bool`, optional):
Whether pinned dialogs should be ignored or not.
When set to `True`, these won't be yielded at all.
ignore_migrated (`bool`, optional):
Whether :tl:`Chat` that have ``migrated_to`` a :tl:`Channel`
should be included or not. By default all the chats in your
dialogs are returned, but setting this to `True` will ignore
(i.e. skip) them in the same way official applications do.
folder (`int`, optional):
The folder from which the dialogs should be retrieved.
If left unspecified, all dialogs (including those from
folders) will be returned.
If set to ``0``, all dialogs that don't belong to any
folder will be returned.
If set to a folder number like ``1``, only those from
said folder will be returned.
By default Telegram assigns the folder ID ``1`` to
archived chats, so you should use that if you need
to fetch the archived dialogs.
archived (`bool`, optional):
Alias for `folder`. If unspecified, all will be returned,
`False` implies ``folder=0`` and `True` implies ``folder=1``.
Yields
Instances of `Dialog <telethon.tl.custom.dialog.Dialog>`.
Example
.. code-block:: python
# Print all dialog IDs and the title, nicely formatted
async for dialog in client.iter_dialogs():
print('{:>14}: {}'.format(dialog.id, dialog.title))
"""
if archived is not None:
folder = 1 if archived else 0
return _DialogsIter(
self,
limit,
offset_date=offset_date,
offset_id=offset_id,
offset_peer=offset_peer,
ignore_pinned=ignore_pinned,
ignore_migrated=ignore_migrated,
folder=folder
)
async def get_dialogs(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList':
"""
Same as `iter_dialogs()`, but returns a
`TotalList <telethon.helpers.TotalList>` instead.
Example
.. code-block:: python
# Get all open conversation, print the title of the first
dialogs = await client.get_dialogs()
first = dialogs[0]
print(first.title)
# Use the dialog somewhere else
await client.send_message(first, 'hi')
# Getting only non-archived dialogs (both equivalent)
non_archived = await client.get_dialogs(folder=0)
non_archived = await client.get_dialogs(archived=False)
# Getting only archived dialogs (both equivalent)
archived = await client.get_dialogs(folder=1)
archived = await client.get_dialogs(archived=True)
"""
return await self.iter_dialogs(*args, **kwargs).collect()
get_dialogs.__signature__ = inspect.signature(iter_dialogs)
def iter_drafts(
self: 'TelegramClient',
entity: 'hints.EntitiesLike' = None
) -> _DraftsIter:
"""
Iterator over draft messages.
The order is unspecified.
Arguments
entity (`hints.EntitiesLike`, optional):
The entity or entities for which to fetch the draft messages.
If left unspecified, all draft messages will be returned.
Yields
Instances of `Draft <telethon.tl.custom.draft.Draft>`.
Example
.. code-block:: python
# Clear all drafts
async for draft in client.get_drafts():
await draft.delete()
# Getting the drafts with 'bot1' and 'bot2'
async for draft in client.iter_drafts(['bot1', 'bot2']):
print(draft.text)
"""
if entity and not utils.is_list_like(entity):
entity = (entity,)
# TODO Passing a limit here makes no sense
return _DraftsIter(self, None, entities=entity)
async def get_drafts(
self: 'TelegramClient',
entity: 'hints.EntitiesLike' = None
) -> 'hints.TotalList':
"""
Same as `iter_drafts()`, but returns a list instead.
Example
.. code-block:: python
# Get drafts, print the text of the first
drafts = await client.get_drafts()
print(drafts[0].text)
# Get the draft in your chat
draft = await client.get_drafts('me')
print(drafts.text)
"""
items = await self.iter_drafts(entity).collect()
if not entity or utils.is_list_like(entity):
return items
else:
return items[0]
async def edit_folder(
self: 'TelegramClient',
entity: 'hints.EntitiesLike' = None,
folder: typing.Union[int, typing.Sequence[int]] = None,
*,
unpack=None
) -> types.Updates:
"""
Edits the folder used by one or more dialogs to archive them.
Arguments
entity (entities):
The entity or list of entities to move to the desired
archive folder.
folder (`int`):
The folder to which the dialog should be archived to.
If you want to "archive" a dialog, use ``folder=1``.
If you want to "un-archive" it, use ``folder=0``.
You may also pass a list with the same length as
`entities` if you want to control where each entity
will go.
unpack (`int`, optional):
If you want to unpack an archived folder, set this
parameter to the folder number that you want to
delete.
When you unpack a folder, all the dialogs inside are
moved to the folder number 0.
You can only use this parameter if the other two
are not set.
Returns
The :tl:`Updates` object that the request produces.
Example
.. code-block:: python
# Archiving the first 5 dialogs
dialogs = await client.get_dialogs(5)
await client.edit_folder(dialogs, 1)
# Un-archiving the third dialog (archiving to folder 0)
await client.edit_folder(dialog[2], 0)
# Moving the first dialog to folder 0 and the second to 1
dialogs = await client.get_dialogs(2)
await client.edit_folder(dialogs, [0, 1])
# Un-archiving all dialogs
await client.edit_folder(unpack=1)
"""
if (entity is None) == (unpack is None):
raise ValueError('You can only set either entities or unpack, not both')
if unpack is not None:
return await self(functions.folders.DeleteFolderRequest(
folder_id=unpack
))
if not utils.is_list_like(entity):
entities = [await self.get_input_entity(entity)]
else:
entities = await asyncio.gather(
*(self.get_input_entity(x) for x in entity))
if folder is None:
raise ValueError('You must specify a folder')
elif not utils.is_list_like(folder):
folder = [folder] * len(entities)
elif len(entities) != len(folder):
raise ValueError('Number of folders does not match number of entities')
return await self(functions.folders.EditPeerFoldersRequest([
types.InputFolderPeer(x, folder_id=y)
for x, y in zip(entities, folder)
]))
async def delete_dialog(
self: 'TelegramClient',
entity: 'hints.EntityLike',
*,
revoke: bool = False
):
"""
Deletes a dialog (leaves a chat or channel).
This method can be used as a user and as a bot. However,
bots will only be able to use it to leave groups and channels
(trying to delete a private conversation will do nothing).
See also `Dialog.delete() <telethon.tl.custom.dialog.Dialog.delete>`.
Arguments
entity (entities):
The entity of the dialog to delete. If it's a chat or
channel, you will leave it. Note that the chat itself
is not deleted, only the dialog, because you left it.
revoke (`bool`, optional):
On private chats, you may revoke the messages from
the other peer too. By default, it's `False`. Set
it to `True` to delete the history for both.
This makes no difference for bot accounts, who can
only leave groups and channels.
Returns
The :tl:`Updates` object that the request produces,
or nothing for private conversations.
Example
.. code-block:: python
# Deleting the first dialog
dialogs = await client.get_dialogs(5)
await client.delete_dialog(dialogs[0])
# Leaving a channel by username
await client.delete_dialog('username')
"""
# If we have enough information (`Dialog.delete` gives it to us),
# then we know we don't have to kick ourselves in deactivated chats.
if isinstance(entity, types.Chat):
deactivated = entity.deactivated
else:
deactivated = False
entity = await self.get_input_entity(entity)
ty = helpers._entity_type(entity)
if ty == helpers._EntityType.CHANNEL:
return await self(functions.channels.LeaveChannelRequest(entity))
if ty == helpers._EntityType.CHAT and not deactivated:
try:
result = await self(functions.messages.DeleteChatUserRequest(
entity.chat_id, types.InputUserSelf(), revoke_history=revoke
))
except errors.PeerIdInvalidError:
# Happens if we didn't have the deactivated information
result = None
else:
result = None
if not await self.is_bot():
await self(functions.messages.DeleteHistoryRequest(entity, 0, revoke=revoke))
return result
def conversation(
self: 'TelegramClient',
entity: 'hints.EntityLike',
*,
timeout: float = 60,
total_timeout: float = None,
max_messages: int = 100,
exclusive: bool = True,
replies_are_responses: bool = True) -> custom.Conversation:
"""
Creates a `Conversation <telethon.tl.custom.conversation.Conversation>`
with the given entity.
.. note::
This Conversation API has certain shortcomings, such as lacking
persistence, poor interaction with other event handlers, and
overcomplicated usage for anything beyond the simplest case.
If you plan to interact with a bot without handlers, this works
fine, but when running a bot yourself, you may instead prefer
to follow the advice from https://stackoverflow.com/a/62246569/.
This is not the same as just sending a message to create a "dialog"
with them, but rather a way to easily send messages and await for
responses or other reactions. Refer to its documentation for more.
Arguments
entity (`entity`):
The entity with which a new conversation should be opened.
timeout (`int` | `float`, optional):
The default timeout (in seconds) *per action* to be used. You
may also override this timeout on a per-method basis. By
default each action can take up to 60 seconds (the value of
this timeout).
total_timeout (`int` | `float`, optional):
The total timeout (in seconds) to use for the whole
conversation. This takes priority over per-action
timeouts. After these many seconds pass, subsequent
actions will result in ``asyncio.TimeoutError``.
max_messages (`int`, optional):
The maximum amount of messages this conversation will
remember. After these many messages arrive in the
specified chat, subsequent actions will result in
``ValueError``.
exclusive (`bool`, optional):
By default, conversations are exclusive within a single
chat. That means that while a conversation is open in a
chat, you can't open another one in the same chat, unless
you disable this flag.
If you try opening an exclusive conversation for
a chat where it's already open, it will raise
``AlreadyInConversationError``.
replies_are_responses (`bool`, optional):
Whether replies should be treated as responses or not.
If the setting is enabled, calls to `conv.get_response
<telethon.tl.custom.conversation.Conversation.get_response>`
and a subsequent call to `conv.get_reply
<telethon.tl.custom.conversation.Conversation.get_reply>`
will return different messages, otherwise they may return
the same message.
Consider the following scenario with one outgoing message,
1, and two incoming messages, the second one replying::
Hello! <1
2> (reply to 1) Hi!
3> (reply to 1) How are you?
And the following code:
.. code-block:: python
async with client.conversation(chat) as conv:
msg1 = await conv.send_message('Hello!')
msg2 = await conv.get_response()
msg3 = await conv.get_reply()
With the setting enabled, ``msg2`` will be ``'Hi!'`` and
``msg3`` be ``'How are you?'`` since replies are also
responses, and a response was already returned.
With the setting disabled, both ``msg2`` and ``msg3`` will
be ``'Hi!'`` since one is a response and also a reply.
Returns
A `Conversation <telethon.tl.custom.conversation.Conversation>`.
Example
.. code-block:: python
# <you> denotes outgoing messages you sent
# <usr> denotes incoming response messages
with bot.conversation(chat) as conv:
# <you> Hi!
conv.send_message('Hi!')
# <usr> Hello!
hello = conv.get_response()
# <you> Please tell me your name
conv.send_message('Please tell me your name')
# <usr> ?
name = conv.get_response().raw_text
while not any(x.isalpha() for x in name):
# <you> Your name didn't have any letters! Try again
conv.send_message("Your name didn't have any letters! Try again")
# <usr> Human
name = conv.get_response().raw_text
# <you> Thanks Human!
conv.send_message('Thanks {}!'.format(name))
"""
return custom.Conversation(
self,
entity,
timeout=timeout,
total_timeout=total_timeout,
max_messages=max_messages,
exclusive=exclusive,
replies_are_responses=replies_are_responses
)
# endregion

1089
telethon/client/downloads.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,233 @@
import itertools
import re
import typing
from .. import helpers, utils
from ..tl import types
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
class MessageParseMethods:
# region Public properties
@property
def parse_mode(self: 'TelegramClient'):
"""
This property is the default parse mode used when sending messages.
Defaults to `telethon.extensions.markdown`. It will always
be either `None` or an object with ``parse`` and ``unparse``
methods.
When setting a different value it should be one of:
* Object with ``parse`` and ``unparse`` methods.
* A ``callable`` to act as the parse method.
* A `str` indicating the ``parse_mode``. For Markdown ``'md'``
or ``'markdown'`` may be used. For HTML, ``'htm'`` or ``'html'``
may be used.
The ``parse`` method should be a function accepting a single
parameter, the text to parse, and returning a tuple consisting
of ``(parsed message str, [MessageEntity instances])``.
The ``unparse`` method should be the inverse of ``parse`` such
that ``assert text == unparse(*parse(text))``.
See :tl:`MessageEntity` for allowed message entities.
Example
.. code-block:: python
# Disabling default formatting
client.parse_mode = None
# Enabling HTML as the default format
client.parse_mode = 'html'
"""
return self._parse_mode
@parse_mode.setter
def parse_mode(self: 'TelegramClient', mode: str):
self._parse_mode = utils.sanitize_parse_mode(mode)
# endregion
# region Private methods
async def _replace_with_mention(self: 'TelegramClient', entities, i, user):
"""
Helper method to replace ``entities[i]`` to mention ``user``,
or do nothing if it can't be found.
"""
try:
entities[i] = types.InputMessageEntityMentionName(
entities[i].offset, entities[i].length,
await self.get_input_entity(user)
)
return True
except (ValueError, TypeError):
return False
async def _parse_message_text(self: 'TelegramClient', message, parse_mode):
"""
Returns a (parsed message, entities) tuple depending on ``parse_mode``.
"""
if parse_mode == ():
parse_mode = self._parse_mode
else:
parse_mode = utils.sanitize_parse_mode(parse_mode)
if not parse_mode:
return message, []
original_message = message
message, msg_entities = parse_mode.parse(message)
if original_message and not message and not msg_entities:
raise ValueError("Failed to parse message")
for i in reversed(range(len(msg_entities))):
e = msg_entities[i]
if not e.length:
# 0-length MessageEntity is no longer valid #3884.
# Because the user can provide their own parser (with reasonable 0-length
# entities), strip them here rather than fixing the built-in parsers.
del msg_entities[i]
elif isinstance(e, types.MessageEntityTextUrl):
m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url)
if m:
user = int(m.group(1)) if m.group(1) else e.url
is_mention = await self._replace_with_mention(msg_entities, i, user)
if not is_mention:
del msg_entities[i]
elif isinstance(e, (types.MessageEntityMentionName,
types.InputMessageEntityMentionName)):
is_mention = await self._replace_with_mention(msg_entities, i, e.user_id)
if not is_mention:
del msg_entities[i]
return message, msg_entities
def _get_response_message(self: 'TelegramClient', request, result, input_chat):
"""
Extracts the response message known a request and Update result.
The request may also be the ID of the message to match.
If ``request is None`` this method returns ``{id: message}``.
If ``request.random_id`` is a list, this method returns a list too.
"""
if isinstance(result, types.UpdateShort):
updates = [result.update]
entities = {}
elif isinstance(result, (types.Updates, types.UpdatesCombined)):
updates = result.updates
entities = {utils.get_peer_id(x): x
for x in
itertools.chain(result.users, result.chats)}
else:
return None
random_to_id = {}
id_to_message = {}
for update in updates:
if isinstance(update, types.UpdateMessageID):
random_to_id[update.random_id] = update.id
elif isinstance(update, (
types.UpdateNewChannelMessage, types.UpdateNewMessage)):
update.message._finish_init(self, entities, input_chat)
# Pinning a message with `updatePinnedMessage` seems to
# always produce a service message we can't map so return
# it directly. The same happens for kicking users.
#
# It could also be a list (e.g. when sending albums).
#
# TODO this method is getting messier and messier as time goes on
if hasattr(request, 'random_id') or utils.is_list_like(request):
id_to_message[update.message.id] = update.message
else:
return update.message
elif (isinstance(update, types.UpdateEditMessage)
and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL):
update.message._finish_init(self, entities, input_chat)
# Live locations use `sendMedia` but Telegram responds with
# `updateEditMessage`, which means we won't have `id` field.
if hasattr(request, 'random_id'):
id_to_message[update.message.id] = update.message
elif request.id == update.message.id:
return update.message
elif (isinstance(update, types.UpdateEditChannelMessage)
and utils.get_peer_id(request.peer) ==
utils.get_peer_id(update.message.peer_id)):
if request.id == update.message.id:
update.message._finish_init(self, entities, input_chat)
return update.message
elif isinstance(update, types.UpdateNewScheduledMessage):
update.message._finish_init(self, entities, input_chat)
# Scheduled IDs may collide with normal IDs. However, for a
# single request there *shouldn't* be a mix between "some
# scheduled and some not".
id_to_message[update.message.id] = update.message
elif isinstance(update, types.UpdateMessagePoll):
if request.media.poll.id == update.poll_id:
m = types.Message(
id=request.id,
peer_id=utils.get_peer(request.peer),
media=types.MessageMediaPoll(
poll=update.poll,
results=update.results
)
)
m._finish_init(self, entities, input_chat)
return m
if request is None:
return id_to_message
random_id = request if isinstance(request, (int, list)) else getattr(request, 'random_id', None)
if random_id is None:
# Can happen when pinning a message does not actually produce a service message.
self._log[__name__].warning(
'No random_id in %s to map to, returning None message for %s', request, result)
return None
if not utils.is_list_like(random_id):
msg = id_to_message.get(random_to_id.get(random_id))
if not msg:
self._log[__name__].warning(
'Request %s had missing message mapping %s', request, result)
return msg
try:
return [id_to_message[random_to_id[rnd]] for rnd in random_id]
except KeyError:
# Sometimes forwards fail (`MESSAGE_ID_INVALID` if a message gets
# deleted or `WORKER_BUSY_TOO_LONG_RETRY` if there are issues at
# Telegram), in which case we get some "missing" message mappings.
# Log them with the hope that we can better work around them.
#
# This also happens when trying to forward messages that can't
# be forwarded because they don't exist (0, service, deleted)
# among others which could be (like deleted or existing).
self._log[__name__].warning(
'Request %s had missing message mappings %s', request, result)
return [
id_to_message.get(random_to_id[rnd])
if rnd in random_to_id
else None
for rnd in random_id
]
# endregion

1525
telethon/client/messages.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,971 @@
import abc
import inspect
import re
import asyncio
import collections
import logging
import platform
import time
import typing
import datetime
import pathlib
from .. import utils, version, helpers, __name__ as __base_name__
from ..crypto import rsa
from ..extensions import markdown
from ..network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy
from ..sessions import Session, SQLiteSession, MemorySession
from ..tl import functions, types
from ..tl.alltlobjects import LAYER
from .._updates import MessageBox, EntityCache as MbEntityCache, SessionState, ChannelState, Entity, EntityType
DEFAULT_DC_ID = 2
DEFAULT_IPV4_IP = '149.154.167.51'
DEFAULT_IPV6_IP = '2001:67c:4e8:f002::a'
DEFAULT_PORT = 443
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
_base_log = logging.getLogger(__base_name__)
# In seconds, how long to wait before disconnecting a exported sender.
_DISCONNECT_EXPORTED_AFTER = 60
class _ExportState:
def __init__(self):
# ``n`` is the amount of borrows a given sender has;
# once ``n`` reaches ``0``, disconnect the sender after a while.
self._n = 0
self._zero_ts = 0
self._connected = False
def add_borrow(self):
self._n += 1
self._connected = True
def add_return(self):
self._n -= 1
assert self._n >= 0, 'returned sender more than it was borrowed'
if self._n == 0:
self._zero_ts = time.time()
def should_disconnect(self):
return (self._n == 0
and self._connected
and (time.time() - self._zero_ts) > _DISCONNECT_EXPORTED_AFTER)
def need_connect(self):
return not self._connected
def mark_disconnected(self):
assert self.should_disconnect(), 'marked as disconnected when it was borrowed'
self._connected = False
# TODO How hard would it be to support both `trio` and `asyncio`?
class TelegramBaseClient(abc.ABC):
"""
This is the abstract base class for the client. It defines some
basic stuff like connecting, switching data center, etc, and
leaves the `__call__` unimplemented.
Arguments
session (`str` | `telethon.sessions.abstract.Session`, `None`):
The file name of the session file to be used if a string is
given (it may be a full path), or the Session instance to be
used otherwise. If it's `None`, the session will not be saved,
and you should call :meth:`.log_out()` when you're done.
Note that if you pass a string it will be a file in the current
working directory, although you can also pass absolute paths.
The session file contains enough information for you to login
without re-sending the code, so if you have to enter the code
more than once, maybe you're changing the working directory,
renaming or removing the file, or using random names.
api_id (`int` | `str`):
The API ID you obtained from https://my.telegram.org.
api_hash (`str`):
The API hash you obtained from https://my.telegram.org.
connection (`telethon.network.connection.common.Connection`, optional):
The connection instance to be used when creating a new connection
to the servers. It **must** be a type.
Defaults to `telethon.network.connection.tcpfull.ConnectionTcpFull`.
use_ipv6 (`bool`, optional):
Whether to connect to the servers through IPv6 or not.
By default this is `False` as IPv6 support is not
too widespread yet.
proxy (`tuple` | `list` | `dict`, optional):
An iterable consisting of the proxy info. If `connection` is
one of `MTProxy`, then it should contain MTProxy credentials:
``('hostname', port, 'secret')``. Otherwise, it's meant to store
function parameters for PySocks, like ``(type, 'hostname', port)``.
See https://github.com/Anorov/PySocks#usage-1 for more.
local_addr (`str` | `tuple`, optional):
Local host address (and port, optionally) used to bind the socket to locally.
You only need to use this if you have multiple network cards and
want to use a specific one.
timeout (`int` | `float`, optional):
The timeout in seconds to be used when connecting.
This is **not** the timeout to be used when ``await``'ing for
invoked requests, and you should use ``asyncio.wait`` or
``asyncio.wait_for`` for that.
request_retries (`int` | `None`, optional):
How many times a request should be retried. Request are retried
when Telegram is having internal issues (due to either
``errors.ServerError`` or ``errors.RpcCallFailError``),
when there is a ``errors.FloodWaitError`` less than
`flood_sleep_threshold`, or when there's a migrate error.
May take a negative or `None` value for infinite retries, but
this is not recommended, since some requests can always trigger
a call fail (such as searching for messages).
connection_retries (`int` | `None`, optional):
How many times the reconnection should retry, either on the
initial connection or when Telegram disconnects us. May be
set to a negative or `None` value for infinite retries, but
this is not recommended, since the program can get stuck in an
infinite loop.
retry_delay (`int` | `float`, optional):
The delay in seconds to sleep between automatic reconnections.
auto_reconnect (`bool`, optional):
Whether reconnection should be retried `connection_retries`
times automatically if Telegram disconnects us or not.
sequential_updates (`bool`, optional):
By default every incoming update will create a new task, so
you can handle several updates in parallel. Some scripts need
the order in which updates are processed to be sequential, and
this setting allows them to do so.
If set to `True`, incoming updates will be put in a queue
and processed sequentially. This means your event handlers
should *not* perform long-running operations since new
updates are put inside of an unbounded queue.
flood_sleep_threshold (`int` | `float`, optional):
The threshold below which the library should automatically
sleep on flood wait and slow mode wait errors (inclusive). For instance, if a
``FloodWaitError`` for 17s occurs and `flood_sleep_threshold`
is 20s, the library will ``sleep`` automatically. If the error
was for 21s, it would ``raise FloodWaitError`` instead. Values
larger than a day (like ``float('inf')``) will be changed to a day.
raise_last_call_error (`bool`, optional):
When API calls fail in a way that causes Telethon to retry
automatically, should the RPC error of the last attempt be raised
instead of a generic ValueError. This is mostly useful for
detecting when Telegram has internal issues.
device_model (`str`, optional):
"Device model" to be sent when creating the initial connection.
Defaults to 'PC (n)bit' derived from ``platform.uname().machine``, or its direct value if unknown.
system_version (`str`, optional):
"System version" to be sent when creating the initial connection.
Defaults to ``platform.uname().release`` stripped of everything ahead of -.
app_version (`str`, optional):
"App version" to be sent when creating the initial connection.
Defaults to `telethon.version.__version__`.
lang_code (`str`, optional):
"Language code" to be sent when creating the initial connection.
Defaults to ``'en'``.
system_lang_code (`str`, optional):
"System lang code" to be sent when creating the initial connection.
Defaults to `lang_code`.
loop (`asyncio.AbstractEventLoop`, optional):
Asyncio event loop to use. Defaults to `asyncio.get_running_loop()`.
This argument is ignored.
base_logger (`str` | `logging.Logger`, optional):
Base logger name or instance to use.
If a `str` is given, it'll be passed to `logging.getLogger()`. If a
`logging.Logger` is given, it'll be used directly. If something
else or nothing is given, the default logger will be used.
receive_updates (`bool`, optional):
Whether the client will receive updates or not. By default, updates
will be received from Telegram as they occur.
Turning this off means that Telegram will not send updates at all
so event handlers, conversations, and QR login will not work.
However, certain scripts don't need updates, so this will reduce
the amount of bandwidth used.
entity_cache_limit (`int`, optional):
How many users, chats and channels to keep in the in-memory cache
at most. This limit is checked against when processing updates.
When this limit is reached or exceeded, all entities that are not
required for update handling will be flushed to the session file.
Note that this implies that there is a lower bound to the amount
of entities that must be kept in memory.
Setting this limit too low will cause the library to attempt to
flush entities to the session file even if no entities can be
removed from the in-memory cache, which will degrade performance.
"""
# Current TelegramClient version
__version__ = version.__version__
# Cached server configuration (with .dc_options), can be "global"
_config = None
_cdn_config = None
# region Initialization
def __init__(
self: 'TelegramClient',
session: 'typing.Union[str, pathlib.Path, Session]',
api_id: int,
api_hash: str,
*,
connection: 'typing.Type[Connection]' = ConnectionTcpFull,
use_ipv6: bool = False,
proxy: typing.Union[tuple, dict] = None,
local_addr: typing.Union[str, tuple] = None,
timeout: int = 10,
request_retries: int = 5,
connection_retries: int = 5,
retry_delay: int = 1,
auto_reconnect: bool = True,
sequential_updates: bool = False,
flood_sleep_threshold: int = 60,
raise_last_call_error: bool = False,
device_model: str = None,
system_version: str = None,
app_version: str = None,
lang_code: str = 'en',
system_lang_code: str = 'en',
loop: asyncio.AbstractEventLoop = None,
base_logger: typing.Union[str, logging.Logger] = None,
receive_updates: bool = True,
catch_up: bool = False,
entity_cache_limit: int = 5000
):
if not api_id or not api_hash:
raise ValueError(
"Your API ID or Hash cannot be empty or None. "
"Refer to telethon.rtfd.io for more information.")
self._use_ipv6 = use_ipv6
if isinstance(base_logger, str):
base_logger = logging.getLogger(base_logger)
elif not isinstance(base_logger, logging.Logger):
base_logger = _base_log
class _Loggers(dict):
def __missing__(self, key):
if key.startswith("telethon."):
key = key.split('.', maxsplit=1)[1]
return base_logger.getChild(key)
self._log = _Loggers()
# Determine what session object we have
if isinstance(session, (str, pathlib.Path)):
try:
session = SQLiteSession(str(session))
except ImportError:
import warnings
warnings.warn(
'The sqlite3 module is not available under this '
'Python installation and no custom session '
'instance was given; using MemorySession.\n'
'You will need to re-login every time unless '
'you use another session storage'
)
session = MemorySession()
elif session is None:
session = MemorySession()
elif not isinstance(session, Session):
raise TypeError(
'The given session must be a str or a Session instance.'
)
self.flood_sleep_threshold = flood_sleep_threshold
# TODO Use AsyncClassWrapper(session)
# ChatGetter and SenderGetter can use the in-memory _mb_entity_cache
# to avoid network access and the need for await in session files.
#
# The session files only wants the entities to persist
# them to disk, and to save additional useful information.
# TODO Session should probably return all cached
# info of entities, not just the input versions
self.session = session
self.api_id = int(api_id)
self.api_hash = api_hash
# Current proxy implementation requires `sock_connect`, and some
# event loops lack this method. If the current loop is missing it,
# bail out early and suggest an alternative.
#
# TODO A better fix is obviously avoiding the use of `sock_connect`
#
# See https://github.com/LonamiWebs/Telethon/issues/1337 for details.
if not callable(getattr(self.loop, 'sock_connect', None)):
raise TypeError(
'Event loop of type {} lacks `sock_connect`, which is needed to use proxies.\n\n'
'Change the event loop in use to use proxies:\n'
'# https://github.com/LonamiWebs/Telethon/issues/1337\n'
'import asyncio\n'
'asyncio.set_event_loop(asyncio.SelectorEventLoop())'.format(
self.loop.__class__.__name__
)
)
if local_addr is not None:
if use_ipv6 is False and ':' in local_addr:
raise TypeError(
'A local IPv6 address must only be used with `use_ipv6=True`.'
)
elif use_ipv6 is True and ':' not in local_addr:
raise TypeError(
'`use_ipv6=True` must only be used with a local IPv6 address.'
)
self._raise_last_call_error = raise_last_call_error
self._request_retries = request_retries
self._connection_retries = connection_retries
self._retry_delay = retry_delay or 0
self._proxy = proxy
self._local_addr = local_addr
self._timeout = timeout
self._auto_reconnect = auto_reconnect
assert isinstance(connection, type)
self._connection = connection
init_proxy = None if not issubclass(connection, TcpMTProxy) else \
types.InputClientProxy(*connection.address_info(proxy))
# Used on connection. Capture the variables in a lambda since
# exporting clients need to create this InvokeWithLayerRequest.
system = platform.uname()
if system.machine in ('x86_64', 'AMD64'):
default_device_model = 'PC 64bit'
elif system.machine in ('i386','i686','x86'):
default_device_model = 'PC 32bit'
else:
default_device_model = system.machine
default_system_version = re.sub(r'-.+','',system.release)
self._init_request = functions.InitConnectionRequest(
api_id=self.api_id,
device_model=device_model or default_device_model or 'Unknown',
system_version=system_version or default_system_version or '1.0',
app_version=app_version or self.__version__,
lang_code=lang_code,
system_lang_code=system_lang_code,
lang_pack='', # "langPacks are for official apps only"
query=None,
proxy=init_proxy
)
# Remember flood-waited requests to avoid making them again
self._flood_waited_requests = {}
# Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders
self._borrowed_senders = {}
self._borrow_sender_lock = asyncio.Lock()
self._exported_sessions = {}
self._loop = None # only used as a sanity check
self._updates_error = None
self._updates_handle = None
self._keepalive_handle = None
self._last_request = time.time()
self._no_updates = not receive_updates
# Used for non-sequential updates, in order to terminate all pending tasks on disconnect.
self._sequential_updates = sequential_updates
self._event_handler_tasks = set()
self._authorized = None # None = unknown, False = no, True = yes
# Some further state for subclasses
self._event_builders = []
# {chat_id: {Conversation}}
self._conversations = collections.defaultdict(set)
# Hack to workaround the fact Telegram may send album updates as
# different Updates when being sent from a different data center.
# {grouped_id: AlbumHack}
#
# FIXME: We don't bother cleaning this up because it's not really
# worth it, albums are pretty rare and this only holds them
# for a second at most.
self._albums = {}
# Default parse mode
self._parse_mode = markdown
# Some fields to easy signing in. Let {phone: hash} be
# a dictionary because the user may change their mind.
self._phone_code_hash = {}
self._phone = None
self._tos = None
# A place to store if channels are a megagroup or not (see `edit_admin`)
self._megagroup_cache = {}
# This is backported from v2 in a very ad-hoc way just to get proper update handling
self._catch_up = catch_up
self._updates_queue = asyncio.Queue()
self._message_box = MessageBox(self._log['messagebox'])
self._mb_entity_cache = MbEntityCache() # required for proper update handling (to know when to getDifference)
self._entity_cache_limit = entity_cache_limit
self._sender = MTProtoSender(
self.session.auth_key,
loggers=self._log,
retries=self._connection_retries,
delay=self._retry_delay,
auto_reconnect=self._auto_reconnect,
connect_timeout=self._timeout,
auth_key_callback=self._auth_key_callback,
updates_queue=self._updates_queue,
auto_reconnect_callback=self._handle_auto_reconnect
)
# endregion
# region Properties
@property
def loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop:
"""
Property with the ``asyncio`` event loop used by this client.
Example
.. code-block:: python
# Download media in the background
task = client.loop.create_task(message.download_media())
# Do some work
...
# Join the task (wait for it to complete)
await task
"""
return helpers.get_running_loop()
@property
def disconnected(self: 'TelegramClient') -> asyncio.Future:
"""
Property with a ``Future`` that resolves upon disconnection.
Example
.. code-block:: python
# Wait for a disconnection to occur
try:
await client.disconnected
except OSError:
print('Error on disconnect')
"""
return self._sender.disconnected
@property
def flood_sleep_threshold(self):
return self._flood_sleep_threshold
@flood_sleep_threshold.setter
def flood_sleep_threshold(self, value):
# None -> 0, negative values don't really matter
self._flood_sleep_threshold = min(value or 0, 24 * 60 * 60)
# endregion
# region Connecting
async def connect(self: 'TelegramClient') -> None:
"""
Connects to Telegram.
.. note::
Connect means connect and nothing else, and only one low-level
request is made to notify Telegram about which layer we will be
using.
Before Telegram sends you updates, you need to make a high-level
request, like `client.get_me() <telethon.client.users.UserMethods.get_me>`,
as described in https://core.telegram.org/api/updates.
Example
.. code-block:: python
try:
await client.connect()
except OSError:
print('Failed to connect')
"""
if self.session is None:
raise ValueError('TelegramClient instance cannot be reused after logging out')
if self._loop is None:
self._loop = helpers.get_running_loop()
elif self._loop != helpers.get_running_loop():
raise RuntimeError('The asyncio event loop must not change after connection (see the FAQ for details)')
# ':' in session.server_address is True if it's an IPv6 address
if (not self.session.server_address or
(':' in self.session.server_address) != self._use_ipv6):
await utils.maybe_async(
self.session.set_dc(
DEFAULT_DC_ID,
DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP,
DEFAULT_PORT
)
)
await utils.maybe_async(self.session.save())
if not await self._sender.connect(self._connection(
self.session.server_address,
self.session.port,
self.session.dc_id,
loggers=self._log,
proxy=self._proxy,
local_addr=self._local_addr
)):
# We don't want to init or modify anything if we were already connected
return
self.session.auth_key = self._sender.auth_key
await utils.maybe_async(self.session.save())
try:
# See comment when saving entities to understand this hack
self_entity = await utils.maybe_async(self.session.get_input_entity(0))
self_id = self_entity.access_hash
self_user = await utils.maybe_async(self.session.get_input_entity(self_id))
self._mb_entity_cache.set_self_user(self_id, None, self_user.access_hash)
except ValueError:
pass
if self._catch_up:
ss = SessionState(0, 0, False, 0, 0, 0, 0, None)
cs = []
update_states = await utils.maybe_async(self.session.get_update_states())
for entity_id, state in update_states:
if entity_id == 0:
# TODO current session doesn't store self-user info but adding that is breaking on downstream session impls
ss = SessionState(0, 0, False, state.pts, state.qts, int(state.date.timestamp()), state.seq, None)
else:
cs.append(ChannelState(entity_id, state.pts))
self._message_box.load(ss, cs)
for state in cs:
try:
entity = await utils.maybe_async(self.session.get_input_entity(state.channel_id))
except ValueError:
self._log[__name__].warning(
'No access_hash in cache for channel %s, will not catch up', state.channel_id)
else:
self._mb_entity_cache.put(Entity(EntityType.CHANNEL, entity.channel_id, entity.access_hash))
self._init_request.query = functions.help.GetConfigRequest()
req = self._init_request
if self._no_updates:
req = functions.InvokeWithoutUpdatesRequest(req)
await self._sender.send(functions.InvokeWithLayerRequest(LAYER, req))
if self._message_box.is_empty():
me = await self.get_me()
if me:
await self._on_login(me) # also calls GetState to initialize the MessageBox
self._updates_handle = self.loop.create_task(self._update_loop())
self._keepalive_handle = self.loop.create_task(self._keepalive_loop())
def is_connected(self: 'TelegramClient') -> bool:
"""
Returns `True` if the user has connected.
This method is **not** asynchronous (don't use ``await`` on it).
Example
.. code-block:: python
while client.is_connected():
await asyncio.sleep(1)
"""
sender = getattr(self, '_sender', None)
return sender and sender.is_connected()
def disconnect(self: 'TelegramClient'):
"""
Disconnects from Telegram.
If the event loop is already running, this method returns a
coroutine that you should await on your own code; otherwise
the loop is ran until said coroutine completes.
Event handlers which are currently running will be cancelled before
this function returns (in order to properly clean-up their tasks).
In particular, this means that using ``disconnect`` in a handler
will cause code after the ``disconnect`` to never run. If this is
needed, consider spawning a separate task to do the remaining work.
Example
.. code-block:: python
# You don't need to use this if you used "with client"
await client.disconnect()
"""
if self.loop.is_running():
# Disconnect may be called from an event handler, which would
# cancel itself during itself and never actually complete the
# disconnection. Shield the task to prevent disconnect itself
# from being cancelled. See issue #3942 for more details.
return asyncio.shield(self.loop.create_task(self._disconnect_coro()))
else:
try:
self.loop.run_until_complete(self._disconnect_coro())
except RuntimeError:
# Python 3.5.x complains when called from
# `__aexit__` and there were pending updates with:
# "Event loop stopped before Future completed."
#
# However, it doesn't really make a lot of sense.
pass
def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]):
"""
Changes the proxy which will be used on next (re)connection.
Method has no immediate effects if the client is currently connected.
The new proxy will take it's effect on the next reconnection attempt:
- on a call `await client.connect()` (after complete disconnect)
- on auto-reconnect attempt (e.g, after previous connection was lost)
"""
init_proxy = None if not issubclass(self._connection, TcpMTProxy) else \
types.InputClientProxy(*self._connection.address_info(proxy))
self._init_request.proxy = init_proxy
self._proxy = proxy
# While `await client.connect()` passes new proxy on each new call,
# auto-reconnect attempts use already set up `_connection` inside
# the `_sender`, so the only way to change proxy between those
# is to directly inject parameters.
connection = getattr(self._sender, "_connection", None)
if connection:
if isinstance(connection, TcpMTProxy):
connection._ip = proxy[0]
connection._port = proxy[1]
else:
connection._proxy = proxy
async def _save_states_and_entities(self: 'TelegramClient'):
# As a hack to not need to change the session files, save ourselves with ``id=0`` and ``access_hash`` of our ``id``.
# This way it is possible to determine our own ID by querying for 0. However, whether we're a bot is not saved.
# Piggy-back on an arbitrary TL type with users and chats so the session can understand to read the entities.
# It doesn't matter if we put users in the list of chats.
if self._mb_entity_cache.self_id:
await utils.maybe_async(
self.session.process_entities(
types.contacts.ResolvedPeer(None, [types.InputPeerUser(0, self._mb_entity_cache.self_id)], [])
)
)
ss, cs = self._message_box.session_state()
await utils.maybe_async(self.session.set_update_state(0, types.updates.State(**ss, unread_count=0)))
now = datetime.datetime.now() # any datetime works; channels don't need it
for channel_id, pts in cs.items():
await utils.maybe_async(
self.session.set_update_state(
channel_id, types.updates.State(pts, 0, now, 0, unread_count=0)
)
)
async def _disconnect_coro(self: 'TelegramClient'):
if self.session is None:
return # already logged out and disconnected
await self._disconnect()
# Also clean-up all exported senders because we're done with them
async with self._borrow_sender_lock:
for state, sender in self._borrowed_senders.values():
# Note that we're not checking for `state.should_disconnect()`.
# If the user wants to disconnect the client, ALL connections
# to Telegram (including exported senders) should be closed.
#
# Disconnect should never raise, so there's no try/except.
await sender.disconnect()
# Can't use `mark_disconnected` because it may be borrowed.
state._connected = False
# If any was borrowed
self._borrowed_senders.clear()
# trio's nurseries would handle this for us, but this is asyncio.
# All tasks spawned in the background should properly be terminated.
if self._event_handler_tasks:
for task in self._event_handler_tasks:
task.cancel()
await asyncio.wait(self._event_handler_tasks)
self._event_handler_tasks.clear()
await self._save_states_and_entities()
await utils.maybe_async(self.session.close())
async def _disconnect(self: 'TelegramClient'):
"""
Disconnect only, without closing the session. Used in reconnections
to different data centers, where we don't want to close the session
file; user disconnects however should close it since it means that
their job with the client is complete and we should clean it up all.
"""
await self._sender.disconnect()
await helpers._cancel(self._log[__name__],
updates_handle=self._updates_handle,
keepalive_handle=self._keepalive_handle)
async def _switch_dc(self: 'TelegramClient', new_dc):
"""
Permanently switches the current connection to the new data center.
"""
self._log[__name__].info('Reconnecting to new data center %s', new_dc)
dc = await self._get_dc(new_dc)
await utils.maybe_async(self.session.set_dc(dc.id, dc.ip_address, dc.port))
# auth_key's are associated with a server, which has now changed
# so it's not valid anymore. Set to None to force recreating it.
self._sender.auth_key.key = None
self.session.auth_key = None
await utils.maybe_async(self.session.save())
await self._disconnect()
return await self.connect()
async def _auth_key_callback(self: 'TelegramClient', auth_key):
"""
Callback from the sender whenever it needed to generate a
new authorization key. This means we are not authorized.
"""
self.session.auth_key = auth_key
await utils.maybe_async(self.session.save())
# endregion
# region Working with different connections/Data Centers
async def _get_dc(self: 'TelegramClient', dc_id, cdn=False):
"""Gets the Data Center (DC) associated to 'dc_id'"""
cls = self.__class__
if not cls._config:
cls._config = await self(functions.help.GetConfigRequest())
if cdn and not self._cdn_config:
cls._cdn_config = await self(functions.help.GetCdnConfigRequest())
for pk in cls._cdn_config.public_keys:
if pk.dc_id == dc_id:
rsa.add_key(pk.public_key, old=False)
try:
return next(
dc for dc in cls._config.dc_options
if dc.id == dc_id
and bool(dc.ipv6) == self._use_ipv6 and bool(dc.cdn) == cdn
)
except StopIteration:
self._log[__name__].warning(
'Failed to get DC %s (cdn = %s) with use_ipv6 = %s; retrying ignoring IPv6 check',
dc_id, cdn, self._use_ipv6
)
try:
return next(
dc for dc in cls._config.dc_options
if dc.id == dc_id and bool(dc.cdn) == cdn
)
except StopIteration:
raise ValueError(f'Failed to get DC {dc_id} (cdn = {cdn})')
async def _create_exported_sender(self: 'TelegramClient', dc_id):
"""
Creates a new exported `MTProtoSender` for the given `dc_id` and
returns it. This method should be used by `_borrow_exported_sender`.
"""
# Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt
# for clearly showing how to export the authorization
dc = await self._get_dc(dc_id)
# Can't reuse self._sender._connection as it has its own seqno.
#
# If one were to do that, Telegram would reset the connection
# with no further clues.
sender = MTProtoSender(None, loggers=self._log)
await sender.connect(self._connection(
dc.ip_address,
dc.port,
dc.id,
loggers=self._log,
proxy=self._proxy,
local_addr=self._local_addr
))
self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc)
auth = await self(functions.auth.ExportAuthorizationRequest(dc_id))
self._init_request.query = functions.auth.ImportAuthorizationRequest(id=auth.id, bytes=auth.bytes)
req = functions.InvokeWithLayerRequest(LAYER, self._init_request)
await sender.send(req)
return sender
async def _borrow_exported_sender(self: 'TelegramClient', dc_id):
"""
Borrows a connected `MTProtoSender` for the given `dc_id`.
If it's not cached, creates a new one if it doesn't exist yet,
and imports a freshly exported authorization key for it to be usable.
Once its job is over it should be `_return_exported_sender`.
"""
async with self._borrow_sender_lock:
self._log[__name__].debug('Borrowing sender for dc_id %d', dc_id)
state, sender = self._borrowed_senders.get(dc_id, (None, None))
if state is None:
state = _ExportState()
sender = await self._create_exported_sender(dc_id)
sender.dc_id = dc_id
self._borrowed_senders[dc_id] = (state, sender)
elif state.need_connect():
dc = await self._get_dc(dc_id)
await sender.connect(self._connection(
dc.ip_address,
dc.port,
dc.id,
loggers=self._log,
proxy=self._proxy,
local_addr=self._local_addr
))
state.add_borrow()
return sender
async def _return_exported_sender(self: 'TelegramClient', sender):
"""
Returns a borrowed exported sender. If all borrows have
been returned, the sender is cleanly disconnected.
"""
async with self._borrow_sender_lock:
self._log[__name__].debug('Returning borrowed sender for dc_id %d', sender.dc_id)
state, _ = self._borrowed_senders[sender.dc_id]
state.add_return()
async def _clean_exported_senders(self: 'TelegramClient'):
"""
Cleans-up all unused exported senders by disconnecting them.
"""
async with self._borrow_sender_lock:
for dc_id, (state, sender) in self._borrowed_senders.items():
if state.should_disconnect():
self._log[__name__].info(
'Disconnecting borrowed sender for DC %d', dc_id)
# Disconnect should never raise
await sender.disconnect()
state.mark_disconnected()
async def _get_cdn_client(self: 'TelegramClient', cdn_redirect):
"""Similar to ._borrow_exported_client, but for CDNs"""
session = self._exported_sessions.get(cdn_redirect.dc_id)
if not session:
dc = await self._get_dc(cdn_redirect.dc_id, cdn=True)
session = await utils.maybe_async(self.session.clone())
await utils.maybe_async(session.set_dc(dc.id, dc.ip_address, dc.port))
self._exported_sessions[cdn_redirect.dc_id] = session
self._log[__name__].info('Creating new CDN client')
client = self.__class__(
session, self.api_id, self.api_hash,
proxy=self._proxy,
timeout=self._timeout,
loop=self.loop
)
session.auth_key = self._sender.auth_key
await client._sender.connect(self._connection(
session.server_address,
session.port,
session.dc_id,
loggers=self._log,
proxy=self._proxy,
local_addr=self._local_addr
))
return client
# endregion
# region Invoking Telegram requests
@abc.abstractmethod
def __call__(self: 'TelegramClient', request, ordered=False):
"""
Invokes (sends) one or more MTProtoRequests and returns (receives)
their result.
Args:
request (`TLObject` | `list`):
The request or requests to be invoked.
ordered (`bool`, optional):
Whether the requests (if more than one was given) should be
executed sequentially on the server. They run in arbitrary
order by default.
flood_sleep_threshold (`int` | `None`, optional):
The flood sleep threshold to use for this request. This overrides
the default value stored in
`client.flood_sleep_threshold <telethon.client.telegrambaseclient.TelegramBaseClient.flood_sleep_threshold>`
Returns:
The result of the request (often a `TLObject`) or a list of
results if more than one request was given.
"""
raise NotImplementedError
@abc.abstractmethod
def _update_loop(self: 'TelegramClient'):
raise NotImplementedError
@abc.abstractmethod
async def _handle_auto_reconnect(self: 'TelegramClient'):
raise NotImplementedError
# endregion

Some files were not shown because too many files have changed in this diff Show More