Compare commits

...

775 Commits
v1.10.10 ... v1

Author SHA1 Message Date
Steve Kowalik
72f4ac0de5
Stop using the event_loop fixture (#4670) 2025-09-21 17:43:53 +02:00
marfer
82a24d6b53
Correct handling of trailing zeros in _rle_encode (#4702) 2025-09-21 17:43:34 +02:00
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
Reinier Romero Mir
f9643bf737
Add missing async when downloading from URL (#3222) 2021-12-01 20:28:55 +01:00
Lonami Exo
bda4259815 Bump to v1.24 2021-12-01 19:08:47 +01:00
Lonami Exo
c9ecd61f7e Fix peer ID check to work with higher IDs
This commit is taken from
5f4bfe6b9b
2021-12-01 19:03:59 +01:00
Shrimadhav U K
9c796e8d73
Fix typo, add errors, and update to 133 again (#3157) 2021-12-01 19:00:29 +01:00
Lonami Exo
2e1be01ad4 Add ttl parameter to send_file 2021-09-11 11:02:19 +02:00
Lonami Exo
3f5f5dbe48 Update documentation and list of known errors
Closes #3151.
2021-09-11 11:02:10 +02:00
Shrimadhav U K
28d3d4b122
Update to layer 133 (#3155) 2021-09-11 10:54:11 +02:00
Devesh Pal
391fbab674
Implement Sticker Choosing Action (#3144) 2021-09-01 15:08:58 +02:00
Shrimadhav U K
2182e7f6f1
Update to layer 132 (#3142) 2021-08-31 15:22:56 +02:00
Yusuf_M_Thon_iD
022c1db33f
Update ChatAction to include MessageActionGameScore (#1651) 2021-08-30 17:33:46 +02:00
Lonami Exo
8c56f95252 Include full request on the cause of RPC errors
Closes #3110, fixes #3109.
2021-08-29 12:14:03 +02:00
Lonami Exo
2cb6cd5dad Change the way no_updates mode is enabled
See discussion on https://github.com/LonamiWebs/Telethon/commit/49713b2.

The problem with the automatic approach is that some scripts may do
some "fancier" things with the way they register updates, so it was
prone to failure (a handler could be added but since the last request
was without updates, nothing would be received).

This new approach is a bit more annoying to opt-into but also more
explicit.
2021-08-29 12:14:03 +02:00
Devesh Pal
befba11657
Add support for scheduled messages in iter_messages (#3127) 2021-08-29 11:53:06 +02:00
painor
828cf2dcad
Include "chat" attribute in processing entities (#3133)
Requests like checkChatInvite return a chatInviteAlready, which has
a "chat" attribute similar to the "user" attribute other requests have.
2021-08-29 11:50:48 +02:00
Devesh Pal
9830c4e02b
Add Button.buy and Button.game (#3141) 2021-08-29 11:49:52 +02:00
Shrimadhav U K
0a4b827d8e
Document new known RPC errors (#3137) 2021-08-28 00:18:37 +02:00
Newbyte
2ea3153cd5
Update docs to reflect current length of login codes in test servers (#3140) 2021-08-28 00:18:22 +02:00
Lonami Exo
1e6be28e4b Fix pin_message not returning Message on PMs 2021-08-22 13:46:26 +02:00
Lonami Exo
49713b2784 Wrap requests in InvokeWithoutUpdatesRequest if no event handlers
Closes #1270.
2021-08-22 13:38:54 +02:00
Lonami Exo
9285e50c63 Handle non-user bans when iterating banned participants
Closes #3105.
2021-08-22 13:24:02 +02:00
Lonami Exo
bc6bcd31ad Fix InlineQuery.event.geo returning None
Closes #3136.
2021-08-22 13:09:57 +02:00
Shrimadhav U K
6a1f29d953
Add new known RPC errors (#3134) 2021-08-20 11:49:12 +02:00
ZubAnt
45ed6658fe
Fix add_admins property of custom.ParticipantPermissions (#3132)
Closes #3131.
2021-08-15 08:01:25 +02:00
penn5
e546ae2f85
Allow per-request flood sleep threshold selection (#3123) 2021-08-06 08:13:34 +02:00
Devesh Pal
e5599c178b
Expose more raw API params in friendly methods (#3104) 2021-08-05 10:54:07 +02:00
Devesh Pal
ad55b945c1
Add comment_to to InlineResult-click method (#3118) 2021-08-03 18:34:10 +02:00
alexkoay
196cef66fd
Fix typehint for callback in UpdateMethods (#3119) 2021-08-03 18:33:46 +02:00
Joshua Coales
e2d97b44c5
Update docs to have fewer grammatical mistakes (#3121) 2021-08-03 18:33:17 +02:00
Devesh Pal
79866750d2
Add new known RPCErrors (#3114) 2021-07-20 23:04:09 +02:00
Shrimadhav U K
3570953d14
Update to layer 131 (#3112) 2021-07-16 21:01:08 +02:00
Lonami Exo
06afd04b07 Update to version 1.23 2021-07-09 20:18:22 +02:00
Lonami Exo
2df1dd7215 Don't call getFullChannel during iter_participants unless necessary
This should reduce the floodwaits of this request by a lot.
2021-07-09 20:11:21 +02:00
Lonami Exo
1e09e133e3 Document new known RPC errors
Courtesy of #3097.
2021-07-09 20:11:21 +02:00
Shrimadhav U K
ecfc6ae87d
Add pm_oneside parameter in pin_message (#3095) 2021-07-09 19:50:47 +02:00
Shrimadhav U K
7763939e7d
Update to layer 130 (#3098) 2021-07-09 19:47:13 +02:00
MiyukiKun
249670827c
Change manage_call permission to default to None (#3093) 2021-07-02 21:42:57 +02:00
Devesh Pal
42bfc7bb3f
Fix inline force_document and new known RPC errors (#3084)
This should fix inline video notes.
2021-06-24 14:19:01 +02:00
Ivanzzzc
417bfcd36e
Fix encoding of QR login URL (#3082)
Closes #3081.
2021-06-20 15:57:05 +02:00
Lonami Exo
2052b502c8 Update to v1.22 2021-06-19 19:08:02 +02:00
Lonami Exo
7c1ad0cadb Document need to AcceptTermsOfService in apps
Closes #3040.
2021-06-19 18:32:23 +02:00
Lonami Exo
9d899e3dab Add EntityCache.clear
Closes #3073.
2021-06-19 18:32:23 +02:00
Lonami Exo
3f185aada2 Ignore IPv6 setting if there's no matching DC
May close #3075 (assuming this is what was happening).
It's better to return some DC rather than crashing.
2021-06-19 18:32:23 +02:00
BelgenOp
37b81c6418
Support retracting poll votes on message click without option (#3080) 2021-06-19 18:32:11 +02:00
Anonymous
7c5efee1de
Update to layer 129 and other additions/enhancements (#3074)
* Apply code corrections for the new layer types.
* Support not passing `user` to `get_permissions`.
* `download_profile_photo` now supports `MessageService`.
* `thumb` in send and edit message.
* Document new known errors.
2021-06-15 22:57:32 +02:00
BelgenOp
6b53d45ce2
Add attributes, supports_streaming to send_message and edit_message (#3066)
Closes #3047.
2021-05-31 15:36:40 +02:00
Lonami Exo
63f24d2282 Add new known RPC errors and update docs 2021-05-30 18:00:27 +02:00
Lonami
3d350c6087
Don't check if offset is divisible by limit if limit is None
Should fix #3058.
2021-05-16 22:27:29 +02:00
Anonymous
85381713b2
Document new known RPC errors (#3057) 2021-05-15 10:49:55 +02:00
blank X
f6a0f5f979
Make offset divisible by limit (#3042) 2021-05-14 08:11:54 +02:00
Anonymous
d44928c27b
Change outdated reference to archive with edit_folder (#3052) 2021-05-09 15:33:28 +02:00
Julian Haupt
08a11eeacf
Fix get_sender when using it on a ChannelForbidden (#3053)
Closes #3051.
2021-05-09 15:33:01 +02:00
Alisa Sireneva
b2c26a53ef
Document new known RPC errors (#3044) 2021-05-05 20:38:43 +02:00
Lonami Exo
319b6283a9 Update install/test server docs and add new known RPC error 2021-04-21 19:56:57 +02:00
FujiApple
5f16434346
Fix Message._needed_markup_bot not returning bot in some cases (#3030) 2021-04-19 08:20:03 +02:00
Joshua Coales
3001b620ec
Improve exception clarity for message parsing failure (#3029) 2021-04-17 19:10:33 +02:00
Non
a376faa3a8
Fix MD5_CHECKSUM_INVALID for small files with custom key/iv (#3024)
Closes #3023.
2021-04-10 21:46:14 +02:00
Lonami Exo
4b16183d2b Audio metadata may have performer under artist
Closes  #3008.
2021-03-31 10:57:20 +02:00
Lonami Exo
5b91adf62d Update documentation
Closes #1733.
2021-03-31 10:54:18 +02:00
Lonami Exo
2fbf850841 Update to layer 126
May help with #3010
2021-03-31 10:52:35 +02:00
Lonami Exo
e5a5ac5943 Remove sched_to_message special-case when mapping msgs
May fix #3012.
2021-03-31 10:30:24 +02:00
Lonami Exo
f326769fa8 Add support for messages to get_stats 2021-03-20 20:20:36 +01:00
Lonami Exo
4d3ff0e175 Revert "Use tgcrypto if available (#1715)"
This reverts commit 42cc9e61fb.

tgcrypto was made for Pyrogram, and seeing it used elsewhere
without much credit "hurts" the author. I personally do not endorse
its use, hence the lack of attention or notes in the documentation.

People who still want to benefit from the speed boost should go
out of their way to discover, install and patch Telethon's aes.py
module instead, all while complying with the respective license
(another reason to avoid said code in Telethon, which is under the
much more permissive MIT license).

People using tgcrypto for anything other than Pyrogram will do so
knowing full-well that this was not the library's intended usage.
2021-03-20 17:20:33 +01:00
Lonami Exo
1cef9173a0 Update to version 1.21.1 2021-03-16 08:24:00 +01:00
Lonami Exo
b06f496a27 Don't treat False field as flag omission
8724949b54 was only half the story.
2021-03-16 08:21:04 +01:00
Lonami Exo
58013f4f44 Fix file.width and .height not working on Photo 2021-03-15 22:36:46 +01:00
painor
ad0307fda6
Add password support for quart example (#1732) 2021-03-15 22:25:06 +01:00
Lonami Exo
3d6a2bb945 Update to version 1.21 2021-03-14 11:31:03 +01:00
Lonami Exo
bdc324760d Move message.out patching in self-chat to Message
May fix #1684.
2021-03-14 11:16:59 +01:00
Lonami Exo
eba95ebd07 Fix delete_dialog on chats
Closes #1727.
2021-03-14 11:05:47 +01:00
Lonami Exo
6f2f8ae69f Remove chat hack from events.UserUpdate
Turns out there was a specific update for channels.
2021-03-14 11:03:03 +01:00
Lonami Exo
8f46f704b1 Update to layer 125
Closes #1728, should close #1724.
2021-03-14 10:58:33 +01:00
Tulir Asokan
0ad9b1375e
Make input entity errors less useless (#1726) 2021-03-14 01:32:01 +01:00
Lonami
52ae9f09ce
Fix _get_input_notify on TLObjects
Closes #1725.
2021-03-13 19:06:19 +01:00
Lonami Exo
a1f91d6eb8 (De)serialize user_id as u32
https://t.me/BotNews/57.
2021-03-09 20:10:31 +01:00
Lonami Exo
bfa7e4ca37 Support clicking buttons that require password
Should close #1716.
2021-03-07 16:36:26 +01:00
Lonami Exo
3ee94bdc5e Update known errors and error message
Closes #1713
2021-03-07 16:09:47 +01:00
Lonami Exo
8724949b54 Don't omit False flag values from serialization 2021-03-07 16:05:09 +01:00
igerzog
42cc9e61fb
Use tgcrypto if available (#1715) 2021-03-02 21:38:02 +01:00
Lonami Exo
d9691c9342 Update to version 1.20 2021-02-27 16:24:39 +01:00
Lonami Exo
4c771bf2af Fix setting logout result was not checking for future cancellation 2021-02-27 15:14:44 +01:00
Lonami Exo
292a36f760 Handle DestroySessionRes
Should close #1706.
2021-02-27 15:13:53 +01:00
Lonami Exo
a955138021 Fix invoking requests ordered
Closes #1709.
2021-02-27 15:03:05 +01:00
Lonami Exo
b475a2ecc6 Add a new docs page for Chats vs Channels 2021-02-23 20:58:36 +01:00
Lonami Exo
175b30faf8 Add new event types to AdminLogEvent 2021-02-23 20:10:51 +01:00
Lonami Exo
0d05d0d8f5 Update message to include ttl_period 2021-02-23 19:42:09 +01:00
Andrew Lane
2c2a07d02f
Update to layer 124 (#1708) 2021-02-23 13:04:50 +01:00
Lonami Exo
0e8bd8248c Fix patched module was never automatically imported
Closes #1701. It has to be imported late in the process of
`import telethon` for its side-effects.
2021-02-14 00:26:04 +01:00
Lonami Exo
ff3c21c805 Update file.size to reflect the size of the largest thumbnail
This way we avoid relying on the order of the thumbnails, and just
pick the largest.
2021-02-13 22:52:27 +01:00
Lonami Exo
b102f1f345 Handle progressive size in _photo_size_byte_count 2021-02-13 22:49:03 +01:00
Lonami Exo
73b9de2085 Correctly sort PhotoSizeProgressive thumb size 2021-02-13 22:47:34 +01:00
Lonami Exo
b0158b3f65 Fix download of PhotoSizeProgressive
Closes #1700.
2021-02-13 22:45:12 +01:00
Lonami Exo
75db9f70df Update proxy docs and license year 2021-02-12 17:56:46 +01:00
Lonami Exo
8f0de3d285 Fix TypeNotFoundError was not being propagated
Closes #1697. This would cause deadlocks, as the request future
would never be resolved, so await would wait forever.
2021-02-11 19:27:57 +01:00
Lonami Exo
845fe88451 Fix definition typo in patched module 2021-02-10 20:21:16 +01:00
Lonami Exo
9a47fdc1ee Move Message redefinitions back to patched
Fixes #1695. This matches the older behaviour better, although the
patched module is now written manually.
2021-02-10 20:18:29 +01:00
Lonami Exo
23041f398b Fix messages.search accidentally being used over getHistory
Introduced by 668dcd52ca (this commit
did change a lot more than it should have); the condition for search
was never updated to account for the non-None value.

Closes #1693.
2021-02-08 22:56:27 +01:00
Lonami Exo
acb066ad2e Fix patched import 2021-02-06 12:51:01 +01:00
Lonami Exo
b85f50e314 Try to fix new custom.Message again 2021-02-06 12:41:33 +01:00
Lonami Exo
79f6da2dac Update to layer 123 again 2021-02-06 12:35:53 +01:00
Lonami Exo
abe4b8d5b0 Fix docs and imports for custom.Message 2021-02-05 20:52:08 +01:00
Lonami Exo
0997e3fa9f Remove _log_exc workaround and NullHandler
It was added back in bfc408b probably due to a misunderstanding of
https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library.

The default behaviour of logging WARNING and above is good and
desirable (hiding unhandled exceptions in update handlers by default
was a big, accidental mistake). NullHandler is used to *prevent*
this good default, so it shouldn't be used in the first place.
2021-02-02 20:47:02 +01:00
Lonami Exo
9a0e030db8 Add patched module back for compatibility 2021-02-02 19:12:22 +01:00
Lonami Exo
b88ec4b814 Print unhandled errors to stderr if logging is not configured
This should mitigate "the code doesn't work but there are no errors"
situations. Users not wanting this behaviour can configure logging
with a high-enough level that won't print anything, or set a filter.
2021-01-30 13:47:28 +01:00
Lonami Exo
4cc9645d76 Fix send_message not forwarding comment_to to send_file 2021-01-30 10:39:45 +01:00
Lonami Exo
8c38d7fb0e Add missing parenthesis 2021-01-30 10:32:42 +01:00
Lonami Exo
a12b49fd40 Change error mapping to be case insensitive 2021-01-29 20:19:07 +01:00
Lonami Exo
584e2b3743 Update list of RPC errors 2021-01-28 21:18:14 +01:00
Lonami Exo
ea57db7aad Add comment_to parameter to more easily post comments 2021-01-28 21:05:20 +01:00
Lonami Exo
6f7640af18 Fix utils.resolve_id
…assuming get_peer_id is correct, as changed by 0d8497b.
2021-01-28 20:01:46 +01:00
Lonami Exo
055643bd01 Fix type hinting for custom.Message 2021-01-28 19:58:03 +01:00
Lonami Exo
4e73577d59 Update to layer 123 2021-01-28 19:48:01 +01:00
Lonami Exo
2117f8f54b Update to v1.19.1 2021-01-26 21:46:42 +01:00
Lonami Exo
320ab75818 Respect exif metadata when resizing photos
Closes #1663.
2021-01-26 21:44:11 +01:00
Lonami Exo
9a6bc5ae72 Update defaults and docs for video attributes
Closes #1679.
2021-01-26 21:10:21 +01:00
Lonami Exo
ad4c49aa18 Fix global message search 2021-01-26 21:07:44 +01:00
Googleplex
a886d609d9
Support sending album with URL photos (#1681)
Fixes #1408.
2021-01-25 08:31:25 +01:00
Lonami Exo
65bf0e4c45 Add missing importç 2021-01-24 01:38:44 +01:00
Lonami Exo
fa99f6a1af Try to handle TimeoutError during file download 2021-01-24 01:36:10 +01:00
Lonami Exo
b1d6bd564e Fix several typos
Closes #1674.
2021-01-20 18:50:45 +01:00
Lonami Exo
de7cf03ba7 Stop storing asyncio loop in TelegramClient
The loop parameter was ignored because it shouldn't be used, but
the fact it still stored the current loop on creation messes up
with asyncio.run.
2021-01-18 22:59:19 +01:00
Shrimadhav U K
3ddb0a3903
Fix thumbnail for TDesktop and Telegram X users (#1673) 2021-01-17 17:31:26 +01:00
Lonami Exo
00aa0a4bf1 Avoid duplicate del in Conversation._on_read 2021-01-14 23:05:49 +01:00
Lonami Exo
cd51c9e47c Get rid of the patched/ module
This may fix #1669.
2021-01-14 22:56:55 +01:00
Lonami
c0738a7ae1
Trigger GitHub Actions workflow on PRs too 2021-01-12 20:30:31 +01:00
Lonami
4bf1d67eba
Fix resolve_invite_link in Python 3.6 2021-01-12 20:04:34 +01:00
painor
c0ed709adf
Add new format to resolve invite link (#1670) 2021-01-12 19:50:27 +01:00
Lonami Exo
82d25a7e52 Fix payment example using the wrong line endings 2021-01-05 20:05:42 +01:00
Lonami Exo
3150726f32 Fix tiny documentation nits
Closes #1659.
2021-01-05 20:03:16 +01:00
Lonami Exo
b192c3e6a3 Update to v1.19 2021-01-05 19:58:41 +01:00
Lonami Exo
3df4807fb9 Fix ChatAction.user_left was considered as user_kicked
Closes #1660.
2021-01-02 12:24:32 +01:00
Lonami Exo
d0ee3c3a56 Return the service message produced when kicking somebody
Helps with #1664.
2021-01-02 12:13:53 +01:00
Lonami Exo
acd4c8648e Update layer and known errors again 2020-12-23 20:11:16 +01:00
Lonami Exo
4b6c69ac1e Update docs and list of errors
Includes the changes of #1654.
2020-12-19 20:33:20 +01:00
Lonami Exo
dd00829f1e Ignore PhotoPathSize thumbnail sizes
Closes #1655.
2020-12-19 19:42:07 +01:00
Andrew Lane
5011747f1f
Update to layer 122 (again) (#1650) 2020-12-14 18:50:55 +01:00
Lonami Exo
ee0fc5cc29 Fix AttributeError on reconnect with no retries
Closes #1646.
2020-12-11 21:43:39 +01:00
Lonami Exo
becfe2ce7a Start reconnect if a second ping is sent without a pong for the first
May help with #1564.
2020-12-11 17:18:25 +01:00
Lonami Exo
0a4d54fca4 Update to layer 122
Closes #1645.
2020-12-11 16:55:49 +01:00
Lonami Exo
1cd11391c4 Unconditionally disconnect exported senders on user disconnect
Borrowed senders are not disconnected immediately, but after a while.
If a borrow was used recently but the user requested the main client
to disconnect, those borrows "shouldn't" disconnect because they were
used recently. However, if the user requests a disconnect, they mean
that EVERYTHING should be disconnected, even if the borrows are recent.

This actually gets rid of warnings about send/recv tasks being destroyed,
which partially addresses #1634. That issue may still have more causes
though.
2020-12-11 16:30:12 +01:00
penn5
44aca29057
Favour text parameter when editing inline messages (#1643) 2020-12-08 10:06:19 +01:00
Lonami Exo
0b0a1dc6a1 Add new known errors to the error list 2020-12-04 20:43:47 +01:00
x0x8x
12380207ba
Add admin log event.has_left (#1642) 2020-12-04 14:51:15 +01:00
Lonami Exo
2439404ad1 Include service messages for new pins in ChatAction again 2020-11-28 16:49:39 +01:00
Ali Alaee
23fc38f7c9
Fix crypto imports on macOS Big Sur (#1639) 2020-11-28 15:54:52 +01:00
Lonami Exo
e3a194acb4 Add set_proxy to the method summary 2020-11-28 13:07:36 +01:00
Lonami Exo
66a508a174 Update to v1.18 2020-11-28 12:17:25 +01:00
Lonami Exo
f2f43336c6 Always use python-socks when available
Relying on Python 3.6 or above to be installed to unconditionally
use this library would break user's code, because this is a new
optional dependency that users may not have installed.

Instead, always use the new library when available, which should
work better than pysocks because it natively supports asyncio.
2020-11-28 12:09:46 +01:00
vegeta1k95
ab3c5acf9a
Add client.set_proxy() (#1632) 2020-11-14 14:01:59 +01:00
Lonami Exo
9c87598950 Don't include *Empty entities in returned dialogs 2020-11-13 10:59:53 +01:00
Lonami Exo
c924365e24 Handle ChatEmpty in utils.get_peer 2020-11-13 10:59:53 +01:00
Lonami
46ee8e86c6
Fix conv.wait_event no longer raising Timeout
Should properly fix #1618.
2020-11-13 09:53:00 +01:00
Lonami Exo
ab9035acd2 Make large dates wrap around
Closes #1629.
2020-11-12 15:25:08 +01:00
Lonami
96a535fe4a
Merge replace PySocks with python-socks for Python >= 3.6 (#1627) 2020-11-10 11:18:00 +01:00
Serhii Dylda
59da53ec48 Fix typing once again 2020-11-09 20:22:22 +01:00
Serhii Dylda
a68800b3f0 Remove unnecessary if clause 2020-11-09 20:09:08 +01:00
Serhii Dylda
38d8a54cc1 Fix protocol typing for python-socks. 2020-11-09 20:05:09 +01:00
Serhii Dylda
633986cfa6 Replace PySocks with python-socks for Python >= 3.6
See discussion at (https://github.com/LonamiWebs/Telethon/pull/1623)

Small fixes for `local_addr` argument.
2020-11-09 19:59:54 +01:00
Alekseev Svyatoslav
c4cbead25b
Remove using deprecated as_album kwarg (#1621) 2020-11-07 22:19:50 +01:00
Richard
ba3a090a80
Update send_file to support grouping any file type (#1620) 2020-11-07 17:32:00 +01:00
Lonami Exo
e1d2c81dca Parse part of newer bot file IDs for photos
Helps with #1613.
2020-11-07 12:46:46 +01:00
Lonami Exo
0d8497bf3b Correct and simplify the way channel IDs are marked
Closes #1359.
2020-11-07 12:18:55 +01:00
Lonami Exo
a6781c8e34 Don't cache SLOW_MODE_WAIT in _flood_waited_requests
Closes #1600.
2020-11-07 12:09:00 +01:00
Lonami Exo
08d5bfcbd0 Fix conv.wait_event not clearing timed out events
Closes #1618.
2020-11-07 12:06:10 +01:00
Lonami Exo
b02a22eaa3 Fix .get_buttons failing for some messages sent by the bot
Closes #1619.
2020-11-07 11:59:56 +01:00
Lonami Exo
e4a6ec40cd Update error documentation, summary and license year 2020-11-05 10:49:34 +01:00
x0x8x
78514110de
Update errors.csv (#1609) 2020-11-05 10:40:44 +01:00
hematogender
b6fe4b8fec
Fix get_display_name not handling ChatForbidden (#1617)
Closes #1616.
2020-11-04 20:28:04 +01:00
vegeta1k95
39e899294f
Fix unhandled ValueError inside _dispatch_update() task (#1615) 2020-11-04 09:58:20 +01:00
Lonami Exo
64d751a397 messages.search from_user may now be a non-User 2020-10-31 11:41:37 +01:00
Lonami Exo
935ee2242d Add method to unpin messages 2020-10-31 11:31:09 +01:00
Lonami Exo
9e3cb8180b Update ChatAction to handle new pin updates 2020-10-31 11:21:38 +01:00
Lonami Exo
d83c154f8d Partial update to layer 120 2020-10-30 20:06:59 +01:00
Xiretza
353b88ea5a
Actually exclude tests from setup.py installation (#1612) 2020-10-27 12:31:31 +01:00
Lonami Exo
4ce2c0017a Somewhat improve packaging situation (#1605) 2020-10-25 10:50:12 +01:00
Lonami Exo
e7f174cdc8 Fix search with offset_date causing infinite recursion
Bug introduced by 668dcd5. Closes #1606.
2020-10-25 10:33:36 +01:00
Lonami Exo
aac4d03a70 Cleanup .gitignore to contain only what's needed 2020-10-25 10:26:06 +01:00
Lonami Exo
7790307595 Remove the (out of date) .nix files from the repo 2020-10-24 12:04:36 +02:00
Lonami Exo
62467b6318 Fix yet another typo
Never make commits in a rush from your phone.
2020-10-23 21:27:39 +02:00
Lonami Exo
60c5d0d8f4 Fix up typo from last commit 2020-10-23 21:24:51 +02:00
Lonami Exo
1a2e09487c Fix utils.get_peer not handling Self in get_messages 2020-10-23 19:02:43 +02:00
Lonami Exo
44e2ef6c79 Don't error when failing to extract response messages 2020-10-23 11:02:30 +02:00
Lonami Exo
e5476e6fef Add utils.split_text to split very large messages 2020-10-23 10:57:45 +02:00
Lonami Exo
d9ddf8858e Add missing local_addr to proxy connection, bump version
Bug introduced by #1587.
2020-10-22 10:13:29 +02:00
Lonami Exo
f450682a22 Document BOT_DOMAIN_INVALID 2020-10-21 09:32:29 +02:00
Lonami Exo
7ed5b4dfbe Explain what happens when a button is pressed in the docs 2020-10-19 10:49:50 +02:00
Qwerty-Space
d56b27e570
Fix several minor typos (#1603) 2020-10-18 21:11:59 +02:00
Lonami Exo
4db51dff8a Update to v1.17 2020-10-18 14:11:52 +02:00
Lonami Exo
94ce3b06eb Add missing raw API fields to Message and re-order them
Keeping them in order is important to easily change them when new
things are added so that we don't miss them again on another update.
2020-10-18 13:10:37 +02:00
Lonami Exo
1311b9393c Move alternative libraries to the wiki
It doesn't make sense to track what happens to Telegram's ecosystem
in the repository of a specific library. The wiki is better suited
for this and can be trivially updated by anyone, allowing it to better
evolve.
2020-10-16 11:00:14 +02:00
Lonami Exo
5952a40c6d Update iter_messages to support fetching channel comments
Closes #1598.
2020-10-16 10:39:02 +02:00
Lonami Exo
4e1f582b17 Call sign_in during sign_up if needed to send the code 2020-10-15 11:43:35 +02:00
Lonami Exo
3ff09f7b91 Use inline result mime to infer the result type 2020-10-15 11:04:54 +02:00
Lonami Exo
312dac90a3 Improve inline result documentation with more examples 2020-10-15 10:42:40 +02:00
Lonami Exo
9c5b9abb93 Fix sending of documents in inline results 2020-10-15 10:40:41 +02:00
Lonami Exo
7c3bbaca2a Support not including the media from inline results in the msg 2020-10-15 10:40:19 +02:00
Lonami Exo
15f7c27bce Fix .photo()/.document() inline results excluding media from msg 2020-10-15 09:29:19 +02:00
Andrew Lane
7de1c0e237
Document two new RPC errors (#1591) 2020-10-13 10:50:05 +02:00
Lonami Exo
adf52a1b74 Expose entity parameter in client.inline_query
Some bots, such as @gamee, use this to determine the type of results
to return (and "disable" themselves in channels).
2020-10-11 16:59:48 +02:00
vegeta1k95
d0faaa2ead
Fix internal get_me() was not expecting network errors (#1594) 2020-10-11 09:33:05 +02:00
Lonami Exo
61b0f09e1d Fix iter_messages(from_users='me') 2020-10-09 21:14:31 +02:00
Lonami Exo
e28fbc6678 Fix ChatAction check for self-user joining a chat 2020-10-07 10:40:34 +02:00
Lonami Exo
026c992395 Don't try to reconnect when authkey is invalid 2020-10-07 10:40:34 +02:00
Lonami Exo
5722ba8306 Revert add_admins property logic since it differs from the rest 2020-10-07 10:40:34 +02:00
Stefan
d2756cf68f
Add support for local_ip address binding (#1587) 2020-10-07 10:03:19 +02:00
khoben
ce71b3293b
Support Message.click() for polls (#1583) 2020-10-07 09:21:33 +02:00
Lonami Exo
05af5d0d74 Avoid redundant code in ParticipantPermissions 2020-10-06 11:14:16 +02:00
Lonami Exo
cf1645b598 Improve documentation for ParticipantPermissions 2020-10-06 11:14:16 +02:00
Lonami Exo
7f61b92f81 Add anonymous permission to edit_admin and get_permissions 2020-10-06 11:14:16 +02:00
Nick80835
ce120cba13
Fix get_permissions in chats and when using self user (#1584) 2020-10-05 19:21:07 +02:00
Lonami Exo
09f4c5c708 Only reset auth_key on error -404
This error is "auth key not found", and the authorization key should
probably not be reset on other error codes. This might address #1457.
2020-10-05 14:08:21 +02:00
Lonami Exo
185a93a105 Expect BufferError during automatic reconnect
This seems to occur whe the Telegram servers are dying and logging
everyone out.
2020-10-05 14:07:11 +02:00
Lonami Exo
20606b3a71 Fix from_users filter not accounting for None from_id 2020-10-05 14:01:50 +02:00
Lonami Exo
cb92a40156 Add additional asserts to debug issue with peer empty channels 2020-10-05 13:58:04 +02:00
Lonami Exo
52a247c156 Update documentation to include the new friendly method 2020-10-05 10:52:42 +02:00
Lonami Exo
bb3ccca333 Fix Python 3.5 compatibility issue 2020-10-05 10:50:47 +02:00
Lonami Exo
180105a965 Follow PEP 518 2020-10-05 10:47:46 +02:00
apepenkov
3e188d0344
Add missing check for permissions.is_creator (#1578) 2020-10-03 17:16:10 +02:00
kolay
fc765f6014
Add new get_permissions method (#1575)
Closes #1574.
2020-10-03 16:59:54 +02:00
Tulir Asokan
bf29cddbc9
Add parameter to pass raw entities when sending message (#1577) 2020-10-02 22:06:48 +02:00
Lonami Exo
4321153b06 Correctly emulate old to_id behaviour 2020-10-02 10:23:04 +02:00
Lonami Exo
e24c49f5be Fix patching of message.out for self-chat 2020-10-02 10:22:38 +02:00
Lonami Exo
53920a1568 Remove handling chat peer discrepancy in NewMessage 2020-10-02 10:04:51 +02:00
Lonami Exo
18f70b3bac Improve PEER_ID_INVALID description 2020-10-01 21:27:03 +02:00
Lonami Exo
5c93ea8019 Fix from_id/sender_id value on message updates 2020-10-01 21:22:27 +02:00
Lonami Exo
572229e536 Add aliases to access new msg fields with old names 2020-10-01 20:37:07 +02:00
Lonami Exo
522681f463 Handle UserEmpty in utils.get_peer
Closes #1552.
2020-10-01 14:02:54 +02:00
Lonami Exo
5c5cee16d9 Lower log severity when receiving empty messages 2020-10-01 13:22:38 +02:00
Lonami Exo
67b87a0ea0 Evict old cached usernames in case of collision 2020-10-01 13:20:29 +02:00
Lonami Exo
233daafd96 Fix global search would fail if last message had no peer 2020-10-01 13:18:54 +02:00
Lonami Exo
4683e83287 Add set -e to update-docs.sh
This should prevent accidentally comitting docs in master
2020-10-01 12:23:38 +02:00
Lonami Exo
668dcd52ca Update global search to properly use offset_rate 2020-10-01 12:23:34 +02:00
Tulir Asokan
8ce7e776c1 Add option to raise last error instead of generic ValueError (#1571) 2020-10-01 12:23:10 +02:00
Lonami Exo
d5e4398ace Adapt the rest of the library to layer 119 2020-10-01 12:22:55 +02:00
Lonami Exo
62737c1caf Partially upgrade to layer 119 2020-10-01 09:17:18 +02:00
Lonami Exo
10b2b60415 Fix requests were not re-enqueued if sending failed 2020-09-29 21:07:38 +02:00
Lonami Exo
c864ef7e16 Refetch msg if fileref expires while downloading docs
Closes #1301.
2020-09-24 10:03:28 +02:00
Lonami Exo
75fbd28d3e Add a workaround for sometimes-missing photos from channels 2020-09-22 11:08:17 +02:00
Lonami Exo
2c9d43d600 Move most of the code in assistant to the plugins repo
The assistant example will now simply be the "core" that initializes
the rest of plugins, allowing for more updates to the *bot* without
cluttering Telethon's git history.
2020-09-17 11:27:50 +02:00
Lonami Exo
219b4ecb77 Abstract away treating a file as a stream
Makes upload_file cleaner (context manager instead of try-finally)
and helps keep the logic "we may own this stream and need to close
it or not" separated.

It can be overengineered to allow nesting the same instance in
context managers and also provide implementations for synchronous
context managers but it adds quite a bit of unnecessary complexity
for the time being. YAGNI.
2020-09-14 16:20:44 +02:00
Daniil
9ec5707c32
Add more info on invalid sticker error (#1558) 2020-09-13 16:29:48 +02:00
yash-dk
1d6fd7898a
Consider all reconnect attempts as retrying (#1557)
This means that a value of 0 retries will no longer try to reconnect.
2020-09-13 09:43:01 +02:00
apepenkov
2a114917f1
Fix AlbumHack in combination with events.Raw (#1555) 2020-09-10 16:25:44 +02:00
Tanya Degurechaff
1afb5b95e3
Update init params to match those of tdesktop (#1549) 2020-09-10 14:52:25 +02:00
daaawx
8cbaacabdb
Add missing word in docs (#1538) 2020-09-10 14:51:31 +02:00
Allerter
1ed0f75c49
Support extracting metadata from bytes and stream objects (#1547)
This should enable more accurate uploads of in-memory files.
2020-09-08 00:20:37 +02:00
Daniil
02b8f1d007
Document STICKER_DOCUMENT_INVALID error (#1537) 2020-08-29 11:41:50 +02:00
Lonami Exo
daec282cdf Update to layer 117 2020-08-24 12:56:20 +02:00
Lonami Exo
0c9d0db730 Update to v1.16.4
v1.16.3 was accidentally released without the intended bug-fixes.
2020-08-24 12:54:56 +02:00
Lonami Exo
06f3dc3053 Revert "Update to layer 117"
This reverts commit 26ff92caa9.

Layer changes only go in minor releases, and the commit history is
linear, so temporarily revert to release a new patch version.
2020-08-24 12:53:43 +02:00
penn5
1a9accbe5d
Fix warning when using formatted phones in start (#1532) 2020-08-24 10:53:29 +02:00
Pascal Jürgens
bde38fb748
Improve description for FILE_ID_INVALID (#1531) 2020-08-23 19:39:29 +02:00
Lonami Exo
bc799fd82c Remove usage of the main group username in the docs 2020-08-23 11:45:45 +02:00
Lonami Exo
accb2142e7 Document new known RPC error 2020-08-23 11:37:59 +02:00
Lonami Exo
26ff92caa9 Update to layer 117 2020-08-15 12:54:05 +02:00
Lonami Exo
73109eb819 Add a workaround for channels that claim have no photos but do 2020-08-13 15:13:29 +02:00
Lonami Exo
e00496aa63 Update to v1.16.2 2020-08-11 23:16:09 +02:00
Lonami Exo
e19aa44d5c Sort thumbs to ensure -1 is largest
Closes #1519.
2020-08-11 23:14:31 +02:00
Lonami Exo
0cefc73448 Support both str and VideoSize as thumb on download_media 2020-08-11 22:31:12 +02:00
Lonami Exo
3c56a6db4d Update to v1.16.1 2020-08-10 16:19:31 +02:00
Lonami Exo
9a0d6b9931 Don't set force_file on force_document with images
Otherwise, Telegram won't analyze the image and won't add it the
DocumentAttributeImageSize, causing some bots like t.me/Stickers
to break.

Closes #1507.
2020-08-10 16:09:39 +02:00
Lonami Exo
ddeefff431 Add a warning when trying to connect to a different account
Closes #1172, and also fixed a typo.
2020-08-08 17:49:00 +02:00
conetra
958698bba7
Remove square bracket around IPv6 addresses (#1517) 2020-08-08 13:16:01 +02:00
Lonami Exo
1d71cdc9e0 Support autocast of polls into input media when possible
Closes #1516.
2020-08-07 16:03:50 +02:00
Lonami Exo
241c6c4ac8 Auto-retry on interdc call (rich) error 2020-08-03 12:35:25 +02:00
Lonami Exo
cb6ffeaabd Slightly improve docs heuristics for video files 2020-07-28 19:46:50 +02:00
Lonami Exo
34861ad1bc Update to v1.16 2020-07-28 18:12:24 +02:00
Lonami Exo
012cae051b Add a script to update online documentation 2020-07-26 13:50:48 +02:00
Lonami Exo
f18ab08334 Add new friendly method to get channel stats 2020-07-26 13:45:30 +02:00
Lonami Exo
b1ea7572dd Document new rpc errors in layer 116 2020-07-26 13:45:19 +02:00
Lonami Exo
e12f6c747f Extend use of force_document to work on files
This allows .webp files to be sent as documents and not stickers.
2020-07-26 13:03:59 +02:00
Lonami Exo
95ea2fb40c Remove uses of gif external
Since it has been removed in layer 116.
2020-07-26 12:59:10 +02:00
Lonami Exo
57b38b24dd Update to layer 116 2020-07-26 12:55:29 +02:00
Lonami Exo
ec8bb8a06a Document PRIVACY_TOO_LONG 2020-07-25 18:42:12 +02:00
Lonami Exo
1c3e7dda01 Avoid explicitly passing the loop to asyncio
This behaviour is deprecated and will be removed in future versions
of Python. Technically, it could be considered a bug (invalid usage
causing different behaviour from the expected one), and in practice
it should not break much code (because .get_event_loop() would likely
be the same event loop anyway).
2020-07-25 18:39:35 +02:00
Lonami Exo
de17a19168 Improve upload_file by properly supporting streaming files 2020-07-15 14:35:42 +02:00
Shrimadhav U K
bfb8de2736
Update upload file size limit to 2GB (#1499)
Source: https://t.me/tginfo/2656
Closes #1498.
2020-07-06 20:11:40 +02:00
Lonami Exo
e44926114a Bump to v1.15 2020-07-04 13:29:43 +02:00
Lonami Exo
326f70b678 Support clicking on buttons asking for phone/location
Closes #1492.
2020-07-04 13:29:43 +02:00
Lonami Exo
7b852206f1 Fix click timeout error is now different 2020-07-04 13:29:43 +02:00
Lonami Exo
ab594ed0cb Remove unused imports and variables 2020-07-04 13:29:43 +02:00
KnorpelSenf
0f8119c400
Fix typo in docs (#1493) 2020-06-24 14:30:41 +02:00
Tulir Asokan
ba4f4c1f78
Fix url property in QRLogin (#1494) 2020-06-24 14:11:54 +02:00
Lonami Exo
e0c3143763 Update documentation with new errors and further clarifications 2020-06-22 13:21:45 +02:00
Lonami Exo
fc07e6bba7 Document some RPC errors for channels.editCreator 2020-06-07 09:50:33 +02:00
Lonami Exo
3e511484c7 Support pathlib.Path on download_file
Fixes #1379.
2020-06-06 21:07:22 +02:00
Lonami Exo
4b933069f1 Add hacks to properly handle events.Album from other DCs
Fixes #1479.
2020-06-06 21:01:02 +02:00
Lonami Exo
faf7263d8f Handle RPC errors on auto-get_difference
Closes #1428.
2020-06-06 14:04:14 +02:00
Lonami Exo
db3e7656e0 Handle AssertionError when cancelling tasks
Fixes #1478.
2020-06-06 13:54:19 +02:00
Lonami Exo
20a6d7b26b Document several new RPC errors 2020-06-06 13:47:56 +02:00
Lonami Exo
3f74f83964 Move qrlogin with the rest of custom types 2020-06-06 13:47:56 +02:00
Lonami Exo
bc03419902 Move doc page of projects using Telethon to the wiki
New URL: https://github.com/LonamiWebs/Telethon/wiki/Projects-using-Telethon
2020-06-06 13:47:56 +02:00
penn5
8557effe13
Fix docs in InlineQuery (#1425) 2020-06-06 13:47:46 +02:00
Lonami Exo
c904b7ccd8 Add a friendly method for QR login
Closes #1471.
2020-06-05 21:58:59 +02:00
Lonami Exo
bfa995d52b Don't crash when receiving updates prior to login
Fixes #1467, and enables #1471.
2020-06-05 21:17:09 +02:00
Lonami Exo
493f69f195 Update to layer 114 2020-06-05 09:29:15 +02:00
Antoni Mur
6c7cfd79b9
Add Taas to the list of alternatives in other languages (#1474) 2020-05-27 19:34:27 +02:00
Lonami Exo
8330635a72 Bump to v1.14.0 2020-05-26 09:31:36 +02:00
Lonami Exo
a46ce053f1 Fix another crash for return value when sending albums 2020-05-24 19:01:05 +02:00
Lonami Exo
02d0cbcfab Document FILE_REFERENCE_EMPTY 2020-05-24 18:41:30 +02:00
Lonami Exo
88e7f0da65 Fix return value when fwding msgs if some are missing
It was supposed to return None for the spots were it failed to fwd
a message, but instead only those that were present were returned,
because we were iterating over the wrong object (dict and not list).
2020-05-24 18:41:30 +02:00
apepenkov
165950169f
Add payment example (#1470) 2020-05-21 13:22:37 +02:00
Lonami Exo
29eb90e503 Fix get_pinned_message in Chat
Closes #1458.
2020-05-17 09:35:44 +02:00
Lonami Exo
856538635d Document FRESH_CHANGE_PHONE_FORBIDDEN
Closes #1464.
2020-05-17 09:28:30 +02:00
JuniorJPDJ
634bc3a8bd
Allow event's func to be async (#1461)
Fixes #1344.
2020-05-16 09:58:37 +02:00
penn5
c45f2e7c39
Handle flood waits of 0 seconds more gracefully (#1460) 2020-05-13 18:50:56 +02:00
Lonami Exo
393da7e57a Expose missing embed_links param in edit_permissions 2020-05-09 17:35:26 +02:00
Lonami Exo
4393ec0b83 Support dice autocast and update docs on send_file for dice 2020-05-05 09:28:37 +02:00
Lonami Exo
db16cf5548 Update to layer 113 2020-05-01 14:10:58 +02:00
Komron Aripov
0f1f655e5d
Fix some missing things in the docs listing other libraries (#1445) 2020-04-29 15:42:59 +02:00
Lonami Exo
c43e2a0a3a Return produced service message with pin_message
Fixes #1394.
2020-04-29 10:29:14 +02:00
Lonami Exo
74bced75b4 Check if the conversation was cancelled on send methods
Fixes #1411.
2020-04-28 21:02:27 +02:00
Lonami Exo
7ea4686d6c Handle FloodWaitError in client.download_media
Fixes #1426. Not entirely happy with the new indirection layer,
but the original __call__ method is already a mess anyway and
the additional cost of an extra call should be neglible compared
to the actual IO.
2020-04-28 20:49:57 +02:00
Lonami Exo
7f3aa43ad4 Rely on types.UpdateChatPinnedMessage for chat unpins
Fixes #1405, probably.
2020-04-27 21:16:45 +02:00
Lonami Exo
71ed1564cb Add a new .dice property to Message 2020-04-27 20:43:09 +02:00
Ali Gasymov
202a8a171b
Update links to other MTProto libraries (#1442) 2020-04-27 20:12:49 +02:00
Lonami Exo
bfa46f47ed Register application/x-tgsticker to mimetypes 2020-04-26 13:42:16 +02:00
Lonami Exo
eb58e60dd1 Fix string formatting on events.Raw with bad input param 2020-04-26 13:42:16 +02:00
TishSerg
a16c60c886
Fix action 'song' should alias 'audio' (#1444) 2020-04-26 11:00:00 +02:00
Lonami Exo
c487340f8e Bump to v1.13.0 2020-04-25 16:28:13 +02:00
Lonami Exo
dcc450267f Document new EMOTICON_INVALID error 2020-04-24 12:38:51 +02:00
Lonami Exo
a353ae3b65 Update to layer 112 2020-04-24 12:17:33 +02:00
Lonami Exo
c37dc69592 Fix downloading thumb was using name inferred for video 2020-04-23 21:01:29 +02:00
Lonami Exo
0c8a90f2a3 Fix delete_messages(None, ...) not working 2020-04-23 20:40:23 +02:00
Lonami Exo
67a9718f9e Bump to v1.12.0 2020-04-20 15:12:00 +02:00
Lonami Exo
01cf4967a5 Clarify send_read_acknowledge behaviour and add new error 2020-04-13 15:03:13 +02:00
ov7a
79fb1a54cb
Switch to blocking connect when using proxy (#1432)
Until a better fix is found, this should help proxy users.
2020-04-12 14:28:40 +02:00
Lonami Exo
c0e523508b Update raw API method usability mapping 2020-04-06 17:44:22 +02:00
Lonami Exo
8ea5fae61b Add some missing layer 111 raw API methods 2020-04-06 10:09:11 +02:00
Lonami Exo
d0f937bcb6 Don't disconnect borrowed senders immediately (#1364) 2020-04-05 12:34:33 +02:00
Arne Beer
3729fde572
Fix editing of inline messages in some cases (#1427) 2020-04-03 18:37:46 +02:00
Lonami Exo
15f30ed942 Update documentation with some fixes and MongoDB sessions
Closes #1403 and #1406.
2020-04-01 19:56:17 +02:00
Dmitry D. Chernov
0ec612d71a utils: Style fix and simplify a bit the VALID_USERNAME_RE 2020-03-31 19:18:57 +10:00
YouTwitFace
1669d80082
Remove call to _cache_media (#1419)
Fixes #1418
2020-03-29 10:15:53 +02:00
Lonami Exo
65d8205eef Update to layer 111 2020-03-28 10:01:31 +01:00
Lonami Exo
3ab9986fc7 Slightly better flow in _file_to_media 2020-03-14 12:16:52 +01:00
Lonami Exo
ccfd7a1015 Don't ignore thumb in send_file(input file)
Fixes #1404
2020-03-14 12:12:40 +01:00
Lonami Exo
68438f4621 Don't store refs to files in cache
File cache has been unused since file_reference were introduced,
there's no point saving them to cache if they're never queried.

Fixes #1400.
2020-03-11 10:07:21 +01:00
Lonami Exo
e3d8109110 Fix a doc typo and update projects using the lib 2020-03-11 10:02:19 +01:00
painor
0e0052888f
Expose key and iv parameter in downloads/uploads (#1397) 2020-03-04 16:12:34 +01:00
Lonami Exo
1ec38aa5b2 Update and clarify some docs
cc #1388, #1396
2020-02-28 11:50:16 +01:00
Lonami Exo
e451abbf20 Avoid another MemoryError 2020-02-28 10:42:23 +01:00
Lonami Exo
673a2ecd5d Document two new errors 2020-02-25 15:52:22 +01:00
Lonami Exo
e9c5e719f1 Minor docs update, bump v1.11.3 2020-02-24 13:15:56 +01:00
Lonami Exo
9a86447b6e Fix get(_input)_users in ChatAction with no service msg 2020-02-24 13:07:13 +01:00
Pascal Jürgens
0814a20ec4
Fix macOS version parsing (again), bump v1.11.2
#1393
2020-02-21 20:37:24 +01:00
Lonami Exo
8aa15174ab Fix check in macOS (#1369), bump v1.11.1 2020-02-21 12:48:43 +01:00
Lonami Exo
64752d89fc Fix 1.11 changelog 2020-02-21 11:41:18 +01:00
Lonami Exo
f21abcd529 Update to v1.11 2020-02-20 20:57:17 +01:00
Lonami Exo
1e94fe25fa Log requests that trigger struct.error
The exception hardly provides any valuable information.
This will hopefully help troubleshooting why the error happens.
2020-02-20 13:40:08 +01:00
Lonami Exo
7ffb87170b Update some URLs
Some were out of date, some were examples pointing to a personal
link, which were replaced with generic examples.
2020-02-20 11:50:15 +01:00
Lonami Exo
3d32e16235 Fix within surrogate detection 2020-02-20 10:53:28 +01:00
Lonami Exo
3a6c955c90 Add examples to all events 2020-02-20 10:18:26 +01:00
Lonami Exo
9f73c35621 Fix unparsing of entities that are together 2020-02-20 09:43:37 +01:00
Lonami Exo
7c6fe5c4e9 Update to layer 110 2020-02-16 14:03:16 +01:00
Tulir Asokan
95dc775344
Fix errors found by new tests (#1389) 2020-02-14 18:35:42 +01:00
Tulir Asokan
c6bd620555
Make RPCError subclasses unpicklable again (#1387) 2020-02-14 18:22:17 +01:00
Lonami Exo
8bd60f7cde Update out-of-date docs 2020-02-11 16:44:25 +01:00
Lonami
ac8009af4a
Fix default DC ID value 2020-02-02 10:01:15 +01:00
Lonami Exo
5f8032584b Fix _get_response_message for sendMedia(live location) 2020-02-01 15:32:52 +01:00
Lonami Exo
22e645e22f Update to layer 109 2020-01-23 13:43:20 +01:00
Lonami Exo
dd4c22d02d Document two new known errors 2020-01-22 14:21:09 +01:00
Lonami Exo
acb8518911 Fix send_message not forwarding some args to send_file 2020-01-22 14:21:09 +01:00
Qwerty-Space
82943bd464 Replace hastbin with deldog (#1377)
Hopefully it's dead less often.
2020-01-22 12:29:04 +01:00
Lonami Exo
02bdf7d27c Improve question templates with contact links 2020-01-21 16:17:03 +01:00
Lonami Exo
a2fc7dca79 Handle users=None properly in ChatAction 2020-01-21 10:39:51 +01:00
Lonami Exo
54c8771885 Properly handle #ot command in assistant.py for PMs 2020-01-19 13:33:30 +01:00
Lonami Exo
da9505fa3c Add some missing words in the docs 2020-01-19 13:25:58 +01:00
Pascal Jürgens
72dc8052b3 Fix crypto imports on macOS Catalina (#1369) 2020-01-17 12:24:59 +01:00
Alexhol
76cc076d61 Fix send_file not considering videos for albums (#1371) 2020-01-17 11:12:20 +01:00
Lonami Exo
78ee787310 Fix utils._get_extension not working in pathlib objects
This was found while testing #1371.
2020-01-17 11:11:10 +01:00
Lonami Exo
d09f6a50b0 Add extra security checks during authkey gen 2020-01-14 12:12:55 +01:00
Lonami
76cf208619
Document where factorization.py comes from 2020-01-09 12:51:41 +01:00
Lonami Exo
76fa7918a5 Fix get_entity(chat) (#1367) 2020-01-08 12:07:58 +01:00
Lonami Exo
3c253734ac Clear old docs and fix formatting in ConnectionError messages 2020-01-07 12:20:01 +01:00
Lonami Exo
d68d70362b Handle PeerIdInvalidError in delete_dialog 2020-01-07 12:14:19 +01:00
Lonami Exo
582a61192a Fix MemoryError on get_input_media(game)
Because an integer was being passed where a TLObject was expected,
so the serialization with bytes() was actually requesting that many
bytes as opposed to properly converting the expected object.
2020-01-04 17:52:31 +01:00
Lonami Exo
364afd61e1 Execute get_me() on reconnect
This should let Telegram know we still want updates.
Ideally, we would catch up, but that requires more work.
2020-01-04 17:22:53 +01:00
Lonami Exo
0683d9771a Update to layer 108 2019-12-31 10:43:05 +01:00
Lonami Exo
d196c89825 Fix unparsing malformed entities, bump v1.10.10 2019-12-30 10:19:29 +01:00
183 changed files with 12118 additions and 5544 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.sync 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

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Ask questions in StackOverflow
url: https://stackoverflow.com/questions/ask?tags=telethon
about: Questions are not bugs. Please ask them in StackOverflow instead. Questions in the bug tracker will be closed
- name: Find about updates and our Telegram groups
url: https://t.me/s/TelethonUpdates
about: Be notified of updates, chat with other people about the library or ask questions in these groups

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

View File

@ -1,20 +0,0 @@
---
name: Question about Usage
about: QUESTIONS DON'T BELONG HERE. Ask in StackOverflow or in @TelethonUpdates
title: ''
labels: RTFM
assignees: ''
---
QUESTIONS ARE NEITHER BUGS NOR ENHANCEMENTS AND DON'T BELONG HERE.
If you DO have a question, ask in:
* https://stackoverflow.com or
* https://t.me/TelethonUpdates (@TelethonUpdates channel in Telegram)
If you do post a question, it will be labelled "RTFM" and closed as soon as possible without any answer.
If you DON'T have a question, use the right template for bugs/issues with the library or to propose an improvement/enhancement to either the code or documentation.
We are not being harsh. Only clear. The issues section is not for questions, and people keep asking things over and over, which is a waste of everyone's time.

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!
-->

View File

@ -1,6 +1,6 @@
name: Python Library
on: [push]
on: [push, pull_request]
jobs:
build:

111
.gitignore vendored
View File

@ -1,112 +1,23 @@
# Docs
/_build/
/docs/
# Generated code
/telethon/tl/functions/
/telethon/tl/types/
/telethon/tl/patched/
/telethon/tl/alltlobjects.py
/telethon/errors/rpcerrorlist.py
# User session
*.session
usermedia/
/usermedia/
# Quick tests should live in this file
example.py
# Byte-compiled / optimized / DLL files
# Builds and testing
__pycache__/
*.py[cod]
*$py.class
/dist/
/build/
/*.egg-info/
/readthedocs/_build/
/.tox/
# C extensions
*.so
# API reference docs
/docs/
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
/docs/_build/
# PyBuilder
target/
# IPython Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
.venv/
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
# Nix build results
result
result-*
# File used to manually test new changes, contains sensitive data
/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

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2016-2019 LonamiWebs
Copyright (c) 2016-Present LonamiWebs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,4 +0,0 @@
include LICENSE
include README.rst
recursive-include telethon *

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?
-------------
@ -75,7 +77,9 @@ useful information.
.. _asyncio: https://docs.python.org/3/library/asyncio.html
.. _MTProto: https://core.telegram.org/mtproto
.. _Telegram: https://telegram.org
.. _Compatibility and Convenience: https://docs.telethon.dev/en/latest/misc/compatibility-and-convenience.html
.. _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

@ -1,126 +0,0 @@
# A NUR-compatible package specification.
{ pkgs ? import <nixpkgs> {}, useRelease ? true }:
rec {
# The `lib`, `modules`, and `overlay` names are special
lib = ({ pkgs }: { }) { inherit pkgs; }; # functions
modules = { }; # NixOS modules
overlays = { }; # nixpkgs overlays
# # development
# ## development.python-modules
# use in a shell like
# ```nix
# ((pkgs.python3.override {
# packageOverrides = pythonPackageOverrides;
# }).withPackages (ps: [ ps.telethon ])).env
# ```
pythonPackageOverrides = self: super: let
defaultTelethonArgs = { inherit useRelease; };
telethonPkg = v: args: self.callPackage (./nix/telethon + "/${v}.nix")
(defaultTelethonArgs // args);
in rec {
telethon = telethon_1;
telethon-devel = self.callPackage ./nix/telethon/devel.nix { };
telethon_1 = telethon_1_10;
telethon_1_10 = telethon_1_10_1;
telethon_1_10_1 = telethonPkg "1.10" { version = "1.10.1"; };
telethon_1_10_0 = telethonPkg "1.10" { version = "1.10.0"; };
telethon_1_9 = telethon_1_9_0;
telethon_1_9_0 = telethonPkg "1.9" { version = "1.9.0"; };
telethon_1_8 = telethon_1_8_0;
telethon_1_8_0 = telethonPkg "1.8" { version = "1.8.0"; };
telethon_1_7 = telethon_1_7_7;
telethon_1_7_7 = telethonPkg "1.7" { version = "1.7.7"; };
telethon_1_7_6 = telethonPkg "1.7" { version = "1.7.6"; };
telethon_1_7_5 = telethonPkg "1.7" { version = "1.7.5"; };
telethon_1_7_4 = telethonPkg "1.7" { version = "1.7.4"; };
telethon_1_7_3 = telethonPkg "1.7" { version = "1.7.3"; };
telethon_1_7_2 = telethonPkg "1.7" { version = "1.7.2"; };
telethon_1_7_1 = telethonPkg "1.7" { version = "1.7.1"; };
telethon_1_7_0 = telethonPkg "1.7" { version = "1.7.0"; };
telethon_1_6 = telethon_1_6_2;
telethon_1_6_2 = telethonPkg "1.6" { version = "1.6.2"; };
# 1.6.1.post1: hotpatch that fixed Telethon.egg-info dir perms
telethon_1_6_1 = telethonPkg "1.6" { version = "1.6.1"; };
telethon_1_6_0 = telethonPkg "1.6" { version = "1.6.0"; };
telethon_1_5 = telethon_1_5_5;
telethon_1_5_5 = telethonPkg "1.5" { version = "1.5.5"; };
telethon_1_5_4 = telethonPkg "1.5" { version = "1.5.4"; };
telethon_1_5_3 = telethonPkg "1.5" { version = "1.5.3"; };
telethon_1_5_2 = telethonPkg "1.5" { version = "1.5.2"; };
telethon_1_5_1 = telethonPkg "1.5" { version = "1.5.1"; };
telethon_1_5_0 = telethonPkg "1.5" { version = "1.5.0"; };
telethon_1_4 = telethon_1_4_3;
telethon_1_4_3 = telethonPkg "1.4" { version = "1.4.3"; };
telethon_1_4_2 = telethonPkg "1.4" { version = "1.4.2"; };
telethon_1_4_1 = telethonPkg "1.4" { version = "1.4.1"; };
telethon_1_4_0 = telethonPkg "1.4" { version = "1.4.0"; };
#telethon_1_3_0
#telethon_1_2_0
#telethon_1_1_1
#telethon_1_1_0
#telethon_1_0_4
#telethon_1_0_3
#telethon_1_0_2
#telethon_1_0_1
#telethon_1_0_0-rc1
#telethon_1_0_0
#telethon_0_19_1
#telethon_0_19_0
#telethon_0_18_3
#telethon_0_18_2
#telethon_0_18_1
#telethon_0_18_0
#telethon_0_17_4
#telethon_0_17_3
#telethon_0_17_2
#telethon_0_17_1
#telethon_0_17_0
#telethon_0_16_2
#telethon_0_16_1
#telethon_0_16_0
#telethon_0_15_5
#telethon_0_15_4
#telethon_0_15_3
#telethon_0_15_2
#telethon_0_15_1
#telethon_0_15_0
#telethon_0_14_2
#telethon_0_14_1
#telethon_0_14_0
#telethon_0_13_6
#telethon_0_13_5
#telethon_0_13_4
#telethon_0_13_3
#telethon_0_13_2
#telethon_0_13_1
#telethon_0_13_0
#telethon_0_12_2
#telethon_0_12_1
#telethon_0_12_0
#telethon_0_11_5
#telethon_0_11_4
#telethon_0_11_3
#telethon_0_11_2
#telethon_0_11_1
#telethon_0_11_0
#telethon_0_10_1
#telethon_0_10_0
#telethon_0_9_1
#telethon_0_9_0
#telethon_0_8_0
#telethon_0_7_1
#telethon_0_7_0
#telethon_0_6_0
#telethon_0_5_0
#telethon_0_4_0
#telethon_0_3_0
#telethon_0_2_0
#telethon_0_1_0
};
}

View File

@ -1,59 +0,0 @@
# This file provides all the buildable and cacheable packages and
# package outputs in you package set. These are what gets built by CI,
# so if you correctly mark packages as
#
# - broken (using `meta.broken`),
# - unfree (using `meta.license.free`), and
# - locally built (using `preferLocalBuild`)
#
# then your CI will be able to build and cache only those packages for
# which this is possible.
{ pkgs ? import <nixpkgs> {}, enableEnvs ? false }:
with builtins;
let
isReserved = n: n == "lib" || n == "overlays" || n == "modules";
isDerivation = p: isAttrs p && p ? type && p.type == "derivation";
isBuildable = p: !(p.meta.broken or false) && p.meta.license.free or true;
isCacheable = p: !(p.preferLocalBuild or false);
shouldRecurseForDerivations = p:
isAttrs p && p.recurseForDerivations or false;
nameValuePair = n: v: { name = n; value = v; };
concatMap = builtins.concatMap or (f: xs: concatLists (map f xs));
flattenPkgs = s:
let
f = p:
if shouldRecurseForDerivations p then flattenPkgs p
else if isDerivation p then [p]
else [];
in
concatMap f (attrValues s);
outputsOf = p: map (o: p.${o}) p.outputs;
# build & test packages across Python versions
# (withPackages "distributions" are also generated for testing)
nurAttrs = import ./extended.nix { inherit pkgs enableEnvs; };
nurPkgs =
flattenPkgs
(listToAttrs
(map (n: nameValuePair n nurAttrs.${n})
(filter (n: !isReserved n)
(attrNames nurAttrs))));
in
rec {
buildPkgs = filter isBuildable nurPkgs;
cachePkgs = filter isCacheable buildPkgs;
buildOutputs = concatMap outputsOf buildPkgs;
cacheOutputs = concatMap outputsOf cachePkgs;
}

View File

@ -1,86 +0,0 @@
{ pkgs ? import <nixpkgs> { }, enableEnvs ? true, useRelease ? true }:
# packages built against all Python versions (along with withPackages
# environments for testing)
# to use for testing, you'll probably want a variant of:
# ```sh
# nix-shell nix/extended.nix -A telethon-devel-python37 --run "python"
# ```
let
inherit (pkgs.lib) attrNames attrValues concatMap head listToAttrs
mapAttrsToList optional optionals tail;
nurAttrs = import ../default.nix { inherit pkgs useRelease; };
pyVersions = concatMap (n: optional (pkgs ? ${n}) n) [
"python3"
"python35"
"python36"
"python37"
# "pypy3"
# "pypy35"
# "pypy36"
# "pypy37"
];
pyPkgEnvs = [
[ "telethon" "telethon" ]
[ "telethon-devel" "telethon-devel" ]
[ "telethon_1" "telethon_1" ]
[ "telethon_1_10" "telethon_1_10" ]
[ "telethon_1_10_1" "telethon_1_10_1" ]
[ "telethon_1_10_0" "telethon_1_10_0" ]
[ "telethon_1_9" "telethon_1_9" ]
[ "telethon_1_9_0" "telethon_1_9_0" ]
[ "telethon_1_8" "telethon_1_8" ]
[ "telethon_1_8_0" "telethon_1_8_0" ]
[ "telethon_1_7" "telethon_1_7" ]
[ "telethon_1_7_7" "telethon_1_7_7" ]
[ "telethon_1_7_6" "telethon_1_7_6" ]
[ "telethon_1_7_5" "telethon_1_7_5" ]
[ "telethon_1_7_4" "telethon_1_7_4" ]
[ "telethon_1_7_3" "telethon_1_7_3" ]
[ "telethon_1_7_2" "telethon_1_7_2" ]
[ "telethon_1_7_1" "telethon_1_7_1" ]
[ "telethon_1_7_0" "telethon_1_7_0" ]
[ "telethon_1_6" "telethon_1_6" ]
[ "telethon_1_6_2" "telethon_1_6_2" ]
[ "telethon_1_6_1" "telethon_1_6_1" ]
[ "telethon_1_6_0" "telethon_1_6_0" ]
[ "telethon_1_5" "telethon_1_5" ]
[ "telethon_1_5_5" "telethon_1_5_5" ]
[ "telethon_1_5_4" "telethon_1_5_4" ]
[ "telethon_1_5_3" "telethon_1_5_3" ]
[ "telethon_1_5_2" "telethon_1_5_2" ]
[ "telethon_1_5_1" "telethon_1_5_1" ]
[ "telethon_1_5_0" "telethon_1_5_0" ]
[ "telethon_1_4" "telethon_1_4" ]
[ "telethon_1_4_3" "telethon_1_4_3" ]
# [ "telethon_1_4_2" "telethon_1_4_2" ]
# [ "telethon_1_4_1" "telethon_1_4_1" ]
# [ "telethon_1_4_0" "telethon_1_4_0" ]
];
getPkgPair = pkgs: n: let p = pkgs.${n}; in { name = n; value = p; };
getPkgPairs = pkgs: map (getPkgPair pkgs);
pyPkgPairs = py:
concatMap (d: map (getPkgPair py.pkgs) (tail d)) pyPkgEnvs;
pyPkgEnvPair = pyNm: py: envNm: env: {
name = "${envNm}-env-${pyNm}";
value = (py.withPackages (ps: map (pn: ps.${pn}) env)).overrideAttrs (o: {
name = "${envNm}-${py.name}-env";
preferLocalBuild = true;
});
};
pyNurPairs = pyNm: py:
map ({ name, value }: { name = "${name}-${pyNm}"; inherit value; })
(pyPkgPairs py) ++
optionals enableEnvs
(map (d: pyPkgEnvPair pyNm py (head d) (tail d)) pyPkgEnvs);
in nurAttrs // (listToAttrs (concatMap (py: let
python = pkgs.${py}.override {
packageOverrides = nurAttrs.pythonPackageOverrides;
}; in
pyNurPairs py python) pyVersions))

View File

@ -1,18 +0,0 @@
# You can use this file as a nixpkgs overlay. This is useful in the
# case where you don't want to add the whole NUR namespace to your
# configuration.
self: super:
let
isReserved = n: n == "lib" || n == "overlays" || n == "modules";
nameValuePair = n: v: { name = n; value = v; };
nurAttrs = import ./default.nix { pkgs = super; };
in
builtins.listToAttrs
(map (n: nameValuePair n nurAttrs.${n})
(builtins.filter (n: !isReserved n)
(builtins.attrNames nurAttrs)))

View File

@ -1,39 +0,0 @@
{ lib, buildPythonPackage, pythonOlder
, fetchFromGitHub ? null, fetchPypi ? null, fetchpatch ? null
, pyaes, rsa
, version
, useRelease ? true
}:
assert useRelease -> fetchPypi != null;
assert !useRelease -> fetchFromGitHub != null;
let
common = import ./common.nix {
inherit lib fetchFromGitHub fetchPypi fetchpatch;
};
versions = {
"1.10.1" = {
pypiSha256 = "1ql8ai01c6v3l13lh3csh37jjkrb33gj50jyvdfi3qjn60qs2rfl";
sourceSha256 = "1skckq4lai51p476r3shgld89x5yg5snrcrzjfxxxai00lm65cbv";
};
"1.10.0" = {
pypiSha256 = "1n2g2r5w44nlhn229r8kamhwjxggv16gl3jxq25bpg5y4qgrxzd8";
sourceSha256 = "1rvrc63j6i7yr887g2csciv4zyy407yhdn4n8q2q00dkildh64qw";
};
};
in buildPythonPackage rec {
pname = "telethon";
inherit version;
src = common.fetchTelethon {
inherit useRelease version;
versionData = versions.${version};
};
propagatedBuildInputs = [ rsa pyaes ];
doCheck = false; # No tests available
disabled = pythonOlder "3.5";
meta = common.meta;
}

View File

@ -1,56 +0,0 @@
{ lib, buildPythonPackage, pythonOlder
, fetchFromGitHub ? null, fetchPypi ? null, fetchpatch ? null
, async_generator, pyaes, rsa
, version
, useRelease ? true
}:
assert useRelease -> fetchPypi != null;
assert !useRelease -> fetchFromGitHub != null && fetchpatch != null;
let
common = import ./common.nix {
inherit lib fetchFromGitHub fetchPypi fetchpatch;
};
versions = {
"1.4.3" = {
pypiSha256 = "1igslvhd743qy9p4kfs7lg09s8d5vhn9jhzngpv12797569p4lcj";
sourceSha256 = "19vz0ppk7lq1dmqzf47n6h023i08pqvcwnixvm28vrijykq0z315";
};
"1.4.2" = {
pypiSha256 = "1f4ncyfzqj4b6zib0417r01pgnd0hb1p4aiinhlkxkmk7vy5fqfy";
sourceSha256 = "0rsbz5kqp0d10gasadir3mgalc9aqq4fcv8xa1p7fg263f43rjl4";
};
"1.4.1" = {
pypiSha256 = "1n0jhdqflinyamzy5krnww7hc0s7pw9yfck1p7816pdbgir74qsw";
sourceSha256 = "07q48gw4ry3wf9yzi6kf8lw3b23a0dvk9r8sabpxwrlqy7gnksxx";
};
"1.4.0" = {
version = "1.4";
pypiSha256 = "1g7rznwmj87n9k86zby9i75h570hm84izrv0srhsmxi52pjan1ml";
sourceSha256 = "14nv86yrj01wmlj5cfg6iq5w03ssl67av1arfy9mq1935mly5nly";
};
};
in buildPythonPackage rec {
pname = "telethon";
inherit version;
src = common.fetchTelethon {
inherit useRelease version;
versionData = versions.${version};
};
patches = lib.optionals (!useRelease) [
(if (lib.versionOlder version "1.4.3") then
common.patches.generator-use-pathlib-to-1_4_3
else
common.patches.generator-use-pathlib-from-1_4_3-to-1_5_0)
common.patches.generator-use-pathlib-open-to-1_5_3
common.patches.sort-generated-tlobjects-to-1_7_1
];
propagatedBuildInputs = [ async_generator rsa pyaes ];
doCheck = false; # No tests available
disabled = pythonOlder "3.5";
meta = common.meta;
}

View File

@ -1,60 +0,0 @@
{ lib, buildPythonPackage, pythonOlder
, fetchFromGitHub ? null, fetchPypi ? null, fetchpatch ? null
, async_generator, pyaes, rsa
, version
, useRelease ? true
}:
assert useRelease -> fetchPypi != null;
assert !useRelease -> fetchFromGitHub != null && fetchpatch != null;
let
common = import ./common.nix {
inherit lib fetchFromGitHub fetchPypi fetchpatch;
};
versions = {
"1.5.5" = {
pypiSha256 = "1qpc4vc3lidhlp1c7521nxizjr6y5c3l9x41knqv02x8n3l9knxa";
sourceSha256 = "1x5niscjbrg5a0cg261z6awln57v3nn8si5j58vhsnckws2c48a5";
};
"1.5.4" = {
pypiSha256 = "1kjqi3wy4hswsf3vmrjg7z5c3f9wpdfk4wz1yfsqmj9ppwllkjsj";
sourceSha256 = "0rmp9zk7a354nb39c01mjcrhi2j6v9im40xmdcvmizx990vlv476";
};
"1.5.3" = {
pypiSha256 = "11xd5ni0chzsfny0vwwqyh37mvmrwrk2bmkhwp1ipbxyis8jjjia";
sourceSha256 = "1l3i6wx3fgcy3vmr75qdbv5fvc5qnk0j47hv7jszsqq9rvqvz2xs";
};
"1.5.2" = {
pypiSha256 = "0ymv6l9xn41sgpkilqkivwbjna89m43i0a728lak2cppp7i1i1h7";
sourceSha256 = "0gnqvlhh3qyvibl7icn6774rshlx1nnhb5f78609da44743lyv17";
};
"1.5.1" = {
pypiSha256 = "1ypxpsfj814gzln4fl7z17l1l6q0bzd5p1ivas85yim3a992ixww";
sourceSha256 = "15w5nshvmj8hgqdcbpw0fjcf1cspaci8dldm9ml1pmijw7zgmpdg";
};
"1.5.0" = {
version = "1.5";
pypiSha256 = "1kzkzcxyz7adjzvm2ml9faz2c5yx469j211yvi5xfvjwp58ic2jc";
sourceSha256 = "12232d3xfv0bbykk9xaxpxsr3656ywjx4ra1q5q99rpp6wv438n1";
};
};
in buildPythonPackage rec {
pname = "telethon";
inherit version;
src = common.fetchTelethon {
inherit useRelease version;
versionData = versions.${version};
};
patches = lib.optionals (!useRelease) ([
common.patches.sort-generated-tlobjects-to-1_7_1
] ++ lib.optional (lib.versionOlder version "1.5.3")
common.patches.generator-use-pathlib-open-to-1_5_3);
propagatedBuildInputs = [ async_generator rsa pyaes ];
doCheck = false; # No tests available
disabled = pythonOlder "3.5";
meta = common.meta;
}

View File

@ -1,50 +0,0 @@
{ lib, buildPythonPackage, pythonOlder
, fetchFromGitHub ? null, fetchPypi ? null, fetchpatch ? null
, pyaes, rsa
, version
, useRelease ? true
}:
assert useRelease -> fetchPypi != null;
assert !useRelease -> fetchFromGitHub != null && fetchpatch != null;
let
common = import ./common.nix {
inherit lib fetchFromGitHub fetchPypi fetchpatch;
};
versions = {
"1.6.2" = {
pypiSha256 = "074h5gj0c330rb1nxzpqm31fp1vw7calh1cdkapbjx90j769iz18";
sourceSha256 = "1daqlb4sva5qkljzbjr8xvjfgp7bdcrl2li1i4434za6a0isgd3j";
};
"1.6.1" = {
# hotpatch with missing .pyc files and fixed Telethon.egg-info perms
pypiVersion = "1.6.1.post1";
pypiSha256 = "17s1qp69bbj6jniam9wbcpaj60ah56sjw0q3kr8ca28y17s88si7";
# pypiVersion = "1.6.1";
# pypiSha256 = "036lhr1jr79np74c6ih51c4pjy828r3lvwcq07q5wynyjprm1qbz";
sourceSha256 = "1hk1bpnk51rpsifb67s31c2qph5hmw28i2vgh97i4i56vynx2yxz";
};
"1.6.0" = {
version = "1.6";
pypiSha256 = "06prmld9068zcm9rfmq3rpq1szw72c6dkxl62b035i9w8wdpvg0m";
sourceSha256 = "0qk14mrnvv9a043ik0y2w6q97l83abvbvn441zn2jl00w4ykfqrh";
};
};
in buildPythonPackage rec {
pname = "telethon";
inherit version;
src = common.fetchTelethon {
inherit useRelease version;
versionData = versions.${version};
};
patches = lib.optional (!useRelease)
common.patches.sort-generated-tlobjects-to-1_7_1;
propagatedBuildInputs = [ rsa pyaes ];
doCheck = false; # No tests available
disabled = pythonOlder "3.5";
meta = common.meta;
}

View File

@ -1,66 +0,0 @@
{ lib, buildPythonPackage, pythonOlder
, fetchFromGitHub ? null, fetchPypi ? null, fetchpatch ? null
, pyaes, rsa
, version
, useRelease ? true
}:
assert useRelease -> fetchPypi != null;
assert !useRelease -> fetchFromGitHub != null;
let
common = import ./common.nix {
inherit lib fetchFromGitHub fetchPypi fetchpatch;
};
versions = {
"1.7.7" = {
pypiSha256 = "0mgpihjc7g4gfrq57srripdavxbsgivn4qsjanv3yds5drskciv0";
sourceSha256 = "08c3iakd7fyacc79pg8hyzpa6zx3gbp7xivi10af34zj775lp2pi";
};
"1.7.6" = {
pypiSha256 = "192xda98685s3hmz7ircxpsn7yq913y0r1kmqrsav90m4g4djn4j";
sourceSha256 = "1ss2pfpd3hby25g9ighbr7ccp66awfzda4srsnvr9s6i28har6ag";
};
"1.7.5" = {
pypiSha256 = "0i5s7ahicw5k0s1i7pi26vc6rp6ppr1gr848sa61yh3qqa4c0qnr";
sourceSha256 = "1rssh0l466h9y6v0z095c9aa63nz9im7gg5771jjj5w70mkpm5w6";
};
"1.7.4" = {
pypiSha256 = "1qpc9f1y559zdwz59qqz4hbf1mrynjjbcg357nzaa2x5a2q4lz0s";
sourceSha256 = "1q43lwfp67q4skfcrb6sdlnjw4ajrpizf08fd9wjrw521kkd8g4y";
};
"1.7.3" = {
pypiSha256 = "0s8qmsarlfgpb0k3w50siv354hpa7b1dnrjjd0iqz7vc5bc7ni84";
sourceSha256 = "0c393smp1qm8kk39r0k31p74p89qzvjdjxq4bxq75h07a1yqbs8x";
};
"1.7.2" = {
pypiSha256 = "0465dwikhpbka2sj1g952rac03jkixq497gbmmyx2i9xb594db27";
sourceSha256 = "1gw09zbaqvn074skwjhmm4yp8p75rw9njwjbkcfvqb4gr6dg8wpq";
};
"1.7.1" = {
pypiSha256 = "186z6imf7zqy8vf4yv2w2kxpd7lxmfppa1qi8nxjdgq8rz7wbglf";
sourceSha256 = "05mpqfj4w5qxyl1ai5p0f31pkagz55xxh8060r8y9i3d44j9bn1c";
};
"1.7.0" = {
version = "1.7";
pypiSha256 = "06cqb121k2y0h3x7gvckyvbsn97wc1a25pghinxz2vb7vg8wwxvw";
sourceSha256 = "0myx32hqax71ijfw6ksxvk27cb6x06kbz8jb7ib9d1cayr2viir6";
};
};
in buildPythonPackage rec {
pname = "telethon";
inherit version;
src = common.fetchTelethon {
inherit useRelease version;
versionData = versions.${version};
};
patches = lib.optional (!useRelease && lib.versionOlder version "1.7.1")
common.patches.sort-generated-tlobjects-to-1_7_1;
propagatedBuildInputs = [ rsa pyaes ];
doCheck = false; # No tests available
disabled = pythonOlder "3.5";
meta = common.meta;
}

View File

@ -1,35 +0,0 @@
{ lib, buildPythonPackage, pythonOlder
, fetchFromGitHub ? null, fetchPypi ? null, fetchpatch ? null
, pyaes, rsa
, version
, useRelease ? true
}:
assert useRelease -> fetchPypi != null;
assert !useRelease -> fetchFromGitHub != null;
let
common = import ./common.nix {
inherit lib fetchFromGitHub fetchPypi fetchpatch;
};
versions = {
"1.8.0" = {
pypiSha256 = "099br8ldjrfzwipv7g202lnjghmqj79j6gicgx11s0vawb5mb3vf";
sourceSha256 = "1q5mcijmjw2m2v3ilw28xnavmcdck5md0k98kwnz0kyx4iqckcv0";
};
};
in buildPythonPackage rec {
pname = "telethon";
inherit version;
src = common.fetchTelethon {
inherit useRelease version;
versionData = versions.${version};
};
propagatedBuildInputs = [ rsa pyaes ];
doCheck = false; # No tests available
disabled = pythonOlder "3.5";
meta = common.meta;
}

View File

@ -1,35 +0,0 @@
{ lib, buildPythonPackage, pythonOlder
, fetchFromGitHub ? null, fetchPypi ? null, fetchpatch ? null
, pyaes, rsa
, version
, useRelease ? true
}:
assert useRelease -> fetchPypi != null;
assert !useRelease -> fetchFromGitHub != null;
let
common = import ./common.nix {
inherit lib fetchFromGitHub fetchPypi fetchpatch;
};
versions = {
"1.9.0" = {
pypiSha256 = "1p4y4qd1ndzi1lg4fhnvq1rqz7611yrwnwwvzh63aazfpzaplyd8";
sourceSha256 = "1g6khxc7mvm3q8rqksw9dwn4l2w8wzvr3zb74n2lb7g5ilpxsadd";
};
};
in buildPythonPackage rec {
pname = "telethon";
inherit version;
src = common.fetchTelethon {
inherit useRelease version;
versionData = versions.${version};
};
propagatedBuildInputs = [ rsa pyaes ];
doCheck = false; # No tests available
disabled = pythonOlder "3.5";
meta = common.meta;
}

View File

@ -1,60 +0,0 @@
{ lib, fetchFromGitHub ? null, fetchPypi ? null, fetchpatch ? null }:
rec {
fetchTelethon = { useRelease, version, versionData }:
if useRelease then assert versionData.pypiSha256 != null; fetchPypi {
pname = "Telethon";
version = versionData.pypiVersion or (versionData.version or version);
sha256 = versionData.pypiSha256;
} else assert versionData.sourceSha256 != null; fetchFromGitHub {
owner = "LonamiWebs";
repo = "Telethon";
rev = versionData.rev or "v${versionData.version or version}";
sha256 = versionData.sourceSha256;
};
fetchpatchTelethon = { rev, ... } @ args:
fetchpatch ({
url = "https://github.com/LonamiWebs/Telethon/commit/${rev}.patch";
} // (builtins.removeAttrs args [ "rev" ]));
# sorted by name, then by logical version range
patches = rec {
generator-use-pathlib-to-1_4_3 = ./generator-use-pathlib-to-1_4_3.patch;
generator-use-pathlib-from-1_4_3-to-1_5_0 = [
(fetchpatchTelethon {
rev = "e71c556ca71aec11166dc66f949a05e700aeb24f";
sha256 = "058phfaggf22j0cjpy9j17y63zgd9m8j4qf7ldsg0jqm1vrym76w";
})
(fetchpatchTelethon {
rev = "8224e5aabf18bb31c6af8c460c38ced11756f080";
sha256 = "0x3xfkld4d2kc0a1a8ldxy85pi57zaipq3b401b16r6rzbi4sh1j";
})
(fetchpatchTelethon {
rev = "aefa429236d28ae68bec4e4ef9f12d13f647dfe6";
sha256 = "043hks8hg5sli1amfv5453h831nwy4dgyw8xr4xxfaxh74754icx";
})
];
generator-use-pathlib-open-to-1_5_3 = fetchpatchTelethon {
rev = "b57e3e3e0a752903fe7d539fb87787ec6712a3d9";
sha256 = "1rl3lkwfi3h62ppzglrmz13zfai8i8cchzqgbjccr4l7nzh1n6nq";
};
sort-generated-tlobjects-to-1_7_1 = fetchpatchTelethon {
rev = "08f8aa3c526c043c107ec1b489b89c011555722f";
sha256 = "1lkvvjzhm9jfrxpm4hbvvysz5f3qi0v4f7vqnfmrzawl73s8qk80";
};
};
meta = let inherit (lib) licenses maintainers; in {
description = "Full-featured Telegram client library for Python 3";
fullDescription = ''
Telegram is a popular messaging application. This library is meant to
make it easy for you to write Python programs that can interact with
Telegram. Think of it as a wrapper that has already done the heavy job
for you, so you can focus on developing an application.
'';
homepage = https://github.com/LonamiWebs/Telethon;
license = licenses.mit;
maintainers = [ maintainers.bb010g maintainers.nyanloutre ];
};
}

View File

@ -1,27 +0,0 @@
{ lib, buildPythonPackage, nix-gitignore, pythonOlder
, async_generator, pyaes, rsa
}:
let
common = import ./common.nix { inherit lib; };
in buildPythonPackage rec {
pname = "telethon";
# If pinning to a specific commit, use the following output instead:
# ```sh
# TZ=UTC git show -s --format=format:%cd --date=short-local
# ```
version = "HEAD";
src = nix-gitignore.gitignoreSource ''
/.git
/default.nix
/nix
'' ../..;
propagatedBuildInputs = [ async_generator rsa pyaes ];
doCheck = false; # No tests available
disabled = pythonOlder "3.5";
meta = common.meta;
}

View File

@ -1,819 +0,0 @@
--- a/setup.py
+++ b/setup.py
@@ -12,10 +12,11 @@
import itertools
import json
-import os
import re
import shutil
-from codecs import open
+from os import chdir
+from pathlib import Path
+from subprocess import run
from sys import argv
from setuptools import find_packages, setup
@@ -29,30 +30,29 @@
self.original = None
def __enter__(self):
- self.original = os.path.abspath(os.path.curdir)
- os.chdir(os.path.abspath(os.path.dirname(__file__)))
+ self.original = Path('.')
+ chdir(str(Path(__file__).parent))
return self
def __exit__(self, *args):
- os.chdir(self.original)
+ chdir(str(self.original))
-GENERATOR_DIR = 'telethon_generator'
-LIBRARY_DIR = 'telethon'
+GENERATOR_DIR = Path('telethon_generator')
+LIBRARY_DIR = Path('telethon')
-ERRORS_IN_JSON = os.path.join(GENERATOR_DIR, 'data', 'errors.json')
-ERRORS_IN_DESC = os.path.join(GENERATOR_DIR, 'data', 'error_descriptions')
-ERRORS_OUT = os.path.join(LIBRARY_DIR, 'errors', 'rpcerrorlist.py')
+ERRORS_IN_JSON = GENERATOR_DIR / 'data/errors.json'
+ERRORS_IN_DESC = GENERATOR_DIR / 'data/error_descriptions'
+ERRORS_OUT = LIBRARY_DIR / 'errors/rpcerrorlist.py'
-INVALID_BM_IN = os.path.join(GENERATOR_DIR, 'data', 'invalid_bot_methods.json')
+INVALID_BM_IN = GENERATOR_DIR / 'data/invalid_bot_methods.json'
-TLOBJECT_IN_CORE_TL = os.path.join(GENERATOR_DIR, 'data', 'mtproto_api.tl')
-TLOBJECT_IN_TL = os.path.join(GENERATOR_DIR, 'data', 'telegram_api.tl')
-TLOBJECT_OUT = os.path.join(LIBRARY_DIR, '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 = os.path.join(GENERATOR_DIR, 'data', 'html')
-DOCS_OUT = 'docs'
+DOCS_IN_RES = GENERATOR_DIR / 'data/html'
+DOCS_OUT = Path('docs')
def generate(which):
@@ -60,15 +60,12 @@
from telethon_generator.generators import\
generate_errors, generate_tlobjects, generate_docs, clean_tlobjects
- # Older Python versions open the file as bytes instead (3.4.2)
- with open(INVALID_BM_IN, 'r') as f:
+ with INVALID_BM_IN.open('r') as f:
invalid_bot_methods = set(json.load(f))
-
- layer = find_layer(TLOBJECT_IN_TL)
+ layer = next(filter(None, map(find_layer, TLOBJECT_IN_TLS)))
errors = list(parse_errors(ERRORS_IN_JSON, ERRORS_IN_DESC))
- tlobjects = list(itertools.chain(
- parse_tl(TLOBJECT_IN_CORE_TL, layer, invalid_bot_methods),
- parse_tl(TLOBJECT_IN_TL, layer, invalid_bot_methods)))
+ tlobjects = list(itertools.chain(*(
+ parse_tl(file, layer, invalid_bot_methods) for file in TLOBJECT_IN_TLS)))
if not which:
which.extend(('tl', 'errors'))
@@ -96,30 +93,29 @@
which.remove('errors')
print(action, 'RPCErrors...')
if clean:
- if os.path.isfile(ERRORS_OUT):
- os.remove(ERRORS_OUT)
+ if ERRORS_OUT.is_file():
+ ERRORS_OUT.unlink()
else:
- with open(ERRORS_OUT, 'w', encoding='utf-8') as file:
+ with ERRORS_OUT.open('w') as file:
generate_errors(errors, file)
if 'docs' in which:
which.remove('docs')
print(action, 'documentation...')
if clean:
- if os.path.isdir(DOCS_OUT):
- shutil.rmtree(DOCS_OUT)
+ if DOCS_OUT.is_dir():
+ shutil.rmtree(str(DOCS_OUT))
else:
generate_docs(tlobjects, methods, layer, DOCS_IN_RES, DOCS_OUT)
if 'json' in which:
which.remove('json')
print(action, 'JSON schema...')
- mtproto = 'mtproto_api.json'
- telegram = 'telegram_api.json'
+ json_files = [x.with_suffix('.json') for x in TLOBJECT_IN_TLS]
if clean:
- for x in (mtproto, telegram):
- if os.path.isfile(x):
- os.remove(x)
+ for file in json_files:
+ if file.is_file():
+ file.unlink()
else:
def gen_json(fin, fout):
methods = []
@@ -131,8 +130,8 @@
with open(fout, 'w') as f:
json.dump(what, f, indent=2)
- gen_json(TLOBJECT_IN_CORE_TL, mtproto)
- gen_json(TLOBJECT_IN_TL, telegram)
+ for fin, fout in zip(TLOBJECT_IN_TLS, json_files):
+ gen_json(fin, fout)
if which:
print('The following items were not understood:', which)
@@ -156,22 +155,17 @@
print('Packaging for PyPi aborted, importing the module failed.')
return
- # Need python3.5 or higher, but Telethon is supposed to support 3.x
- # Place it here since noone should be running ./setup.py pypi anyway
- from subprocess import run
- from shutil import rmtree
-
for x in ('build', 'dist', 'Telethon.egg-info'):
- rmtree(x, ignore_errors=True)
+ shutil.rmtree(x, ignore_errors=True)
run('python3 setup.py sdist', shell=True)
run('python3 setup.py bdist_wheel', shell=True)
run('twine upload dist/*', shell=True)
for x in ('build', 'dist', 'Telethon.egg-info'):
- rmtree(x, ignore_errors=True)
+ shutil.rmtree(x, ignore_errors=True)
else:
# e.g. install from GitHub
- if os.path.isdir(GENERATOR_DIR):
+ if GENERATOR_DIR.is_dir():
generate(['tl', 'errors'])
# Get the long description from the README file
--- a/telethon_generator/docswriter.py
+++ b/telethon_generator/docswriter.py
@@ -2,0 +2,0 @@
class DocsWriter:
- """Utility class used to write the HTML files used on the documentation"""
- def __init__(self, filename, type_to_path):
- """Initializes the writer to the specified output file,
- creating the parent directories when used if required.
-
- 'type_to_path_function' should be a function which, given a type
- name and a named argument relative_to, returns the file path for
- the specified type, relative to the given filename
+ """
+ Utility class used to write the HTML files used on the documentation.
+ """
+ def __init__(self, root, filename, type_to_path):
"""
+ Initializes the writer to the specified output file,
+ creating the parent directories when used if required.
+ """
+ self.root = root
self.filename = filename
+ self._parent = str(self.filename.parent)
self.handle = None
+ self.title = ''
# Should be set before calling adding items to the menu
self.menu_separator_tag = None
- # Utility functions TODO There must be a better way
- self.type_to_path = lambda t: type_to_path(
- t, relative_to=self.filename
- )
+ # Utility functions
+ self.type_to_path = lambda t: self._rel(type_to_path(t))
# Control signals
self.menu_began = False
@@ -30,11 +30,20 @@
self.write_copy_script = False
self._script = ''
+ def _rel(self, path):
+ """
+ Get the relative path for the given path from the current
+ file by working around https://bugs.python.org/issue20012.
+ """
+ return os.path.relpath(str(path), self._parent)
+
# High level writing
- def write_head(self, title, relative_css_path, default_css):
+ def write_head(self, title, css_path, default_css):
"""Writes the head part for the generated document,
with the given title and CSS
"""
+ #
+ self.title = title
self.write(
'''<!DOCTYPE html>
<html>
@@ -54,17 +63,17 @@
<body>
<div id="main_div">''',
title=title,
- rel_css=relative_css_path.rstrip('/'),
+ rel_css=self._rel(css_path),
def_css=default_css
)
- def set_menu_separator(self, relative_image_path):
+ def set_menu_separator(self, img):
"""Sets the menu separator.
Must be called before adding entries to the menu
"""
- if relative_image_path:
- self.menu_separator_tag = \
- '<img src="{}" alt="/" />'.format(relative_image_path)
+ if img:
+ self.menu_separator_tag = '<img src="{}" alt="/" />'.format(
+ self._rel(img))
else:
self.menu_separator_tag = None
@@ -80,7 +89,7 @@
self.write('<li>')
if link:
- self.write('<a href="{}">', link)
+ self.write('<a href="{}">', self._rel(link))
# Write the real menu entry text
self.write(name)
@@ -210,7 +219,7 @@
if bold:
self.write('<b>')
if link:
- self.write('<a href="{}">', link)
+ self.write('<a href="{}">', self._rel(link))
# Finally write the real table data, the given text
self.write(text)
@@ -278,10 +287,7 @@
# With block
def __enter__(self):
# Sanity check
- parent = os.path.dirname(self.filename)
- if parent:
- os.makedirs(parent, exist_ok=True)
-
+ self.filename.parent.mkdir(parents=True, exist_ok=True)
self.handle = open(self.filename, 'w', encoding='utf-8')
return self
--- a/telethon_generator/generators/docs.py
+++ b/telethon_generator/generators/docs.py
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
-import csv
import functools
-import os
import re
import shutil
from collections import defaultdict
+from pathlib import Path
from ..docswriter import DocsWriter
from ..parsers import TLObject, Usability
@@ -35,41 +34,33 @@
def _get_create_path_for(root, tlobject, make=True):
"""Creates and returns the path for the given TLObject at root."""
- out_dir = 'methods' if tlobject.is_function else 'constructors'
+ # TODO Can we pre-create all required directories?
+ out_dir = root / ('methods' if tlobject.is_function else 'constructors')
if tlobject.namespace:
- out_dir = os.path.join(out_dir, tlobject.namespace)
+ out_dir /= tlobject.namespace
- out_dir = os.path.join(root, out_dir)
if make:
- os.makedirs(out_dir, exist_ok=True)
- return os.path.join(out_dir, _get_file_name(tlobject))
+ out_dir.mkdir(parents=True, exist_ok=True)
+ return out_dir / _get_file_name(tlobject)
-def _get_path_for_type(root, type_, relative_to='.'):
+
+def _get_path_for_type(type_):
"""Similar to `_get_create_path_for` but for only type names."""
if type_.lower() in CORE_TYPES:
- path = 'index.html#%s' % type_.lower()
+ return Path('index.html#%s' % type_.lower())
elif '.' in type_:
namespace, name = type_.split('.')
- path = 'types/%s/%s' % (namespace, _get_file_name(name))
+ return Path('types', namespace, _get_file_name(name))
else:
- path = 'types/%s' % _get_file_name(type_)
-
- return _get_relative_path(os.path.join(root, path), relative_to)
-
-
-def _get_relative_path(destination, relative_to, folder=False):
- """Return the relative path to destination from relative_to."""
- if not folder:
- relative_to = os.path.dirname(relative_to)
-
- return os.path.relpath(destination, start=relative_to)
+ return Path('types', _get_file_name(type_))
def _find_title(html_file):
"""Finds the <title> for the given HTML file, or (Unknown)."""
- with open(html_file, 'r') as fp:
- for line in fp:
+ # TODO Is it necessary to read files like this?
+ with html_file.open() as f:
+ for line in f:
if '<title>' in line:
# + 7 to skip len('<title>')
return line[line.index('<title>') + 7:line.index('</title>')]
@@ -77,25 +68,27 @@
return '(Unknown)'
-def _build_menu(docs, filename, root, relative_main_index):
- """Builds the menu using the given DocumentWriter up to 'filename',
- which must be a file (it cannot be a directory)"""
- filename = _get_relative_path(filename, root)
- docs.add_menu('API', relative_main_index)
-
- items = filename.split('/')
- for i in range(len(items) - 1):
- item = items[i]
- link = '../' * (len(items) - (i + 2))
- link += 'index.html'
- docs.add_menu(item.title(), link=link)
+def _build_menu(docs):
+ """
+ Builds the menu used for the current ``DocumentWriter``.
+ """
+
+ paths = []
+ current = docs.filename
+ while current != docs.root:
+ current = current.parent
+ paths.append(current)
+
+ for path in reversed(paths):
+ docs.add_menu(path.stem.title(), link=path / 'index.html')
+
+ if docs.filename.stem != 'index':
+ docs.add_menu(docs.title, link=docs.filename)
- if items[-1] != 'index.html':
- docs.add_menu(os.path.splitext(items[-1])[0])
docs.end_menu()
-def _generate_index(folder, original_paths, root,
+def _generate_index(root, folder, paths,
bots_index=False, bots_index_paths=()):
"""Generates the index file for the specified folder"""
# Determine the namespaces listed here (as sub folders)
@@ -105,38 +98,24 @@
INDEX = 'index.html'
BOT_INDEX = 'botindex.html'
- if not bots_index:
- for item in os.listdir(folder):
- if os.path.isdir(os.path.join(folder, item)):
- namespaces.append(item)
- elif item not in (INDEX, BOT_INDEX):
- files.append(item)
- else:
- # bots_index_paths should be a list of "namespace/method.html"
- # or "method.html"
- for item in bots_index_paths:
- dirname = os.path.dirname(item)
- if dirname and dirname not in namespaces:
- namespaces.append(dirname)
- elif not dirname and item not in (INDEX, BOT_INDEX):
- files.append(item)
-
- paths = {k: _get_relative_path(v, folder, folder=True)
- for k, v in original_paths.items()}
+ for item in (bots_index_paths or folder.iterdir()):
+ if item.is_dir():
+ namespaces.append(item)
+ elif item.name not in (INDEX, BOT_INDEX):
+ files.append(item)
# Now that everything is setup, write the index.html file
- filename = os.path.join(folder, BOT_INDEX if bots_index else INDEX)
- with DocsWriter(filename, type_to_path=_get_path_for_type) as docs:
+ filename = folder / (BOT_INDEX if bots_index else INDEX)
+ with DocsWriter(root, filename, _get_path_for_type) as docs:
# Title should be the current folder name
- docs.write_head(folder.title(),
- relative_css_path=paths['css'],
- default_css=original_paths['default_css'])
+ docs.write_head(str(folder).title(),
+ css_path=paths['css'],
+ default_css=paths['default_css'])
docs.set_menu_separator(paths['arrow'])
- _build_menu(docs, filename, root,
- relative_main_index=paths['index_all'])
+ _build_menu(docs)
+ docs.write_title(str(filename.parent.relative_to(root)).title())
- docs.write_title(_get_relative_path(folder, root, folder=True).title())
if bots_index:
docs.write_text('These are the methods that you may be able to '
'use as a bot. Click <a href="{}">here</a> to '
@@ -153,24 +132,22 @@
namespace_paths = []
if bots_index:
for item in bots_index_paths:
- if os.path.dirname(item) == namespace:
- namespace_paths.append(os.path.basename(item))
- _generate_index(os.path.join(folder, namespace),
- original_paths, root,
+ if item.parent == namespace:
+ namespace_paths.append(item)
+
+ _generate_index(root, namespace, paths,
bots_index, namespace_paths)
- if bots_index:
- docs.add_row(namespace.title(),
- link=os.path.join(namespace, BOT_INDEX))
- else:
- docs.add_row(namespace.title(),
- link=os.path.join(namespace, INDEX))
+
+ docs.add_row(
+ namespace.stem.title(),
+ link=namespace / (BOT_INDEX if bots_index else INDEX))
docs.end_table()
docs.write_title('Available items')
docs.begin_table(2)
- files = [(f, _find_title(os.path.join(folder, f))) for f in files]
+ files = [(f, _find_title(f)) for f in files]
files.sort(key=lambda t: t[1])
for file, title in files:
@@ -231,7 +208,7 @@
))
-def _write_html_pages(tlobjects, methods, layer, input_res, output_dir):
+def _write_html_pages(root, tlobjects, methods, layer, input_res):
"""
Generates the documentation HTML files from from ``scheme.tl``
to ``/methods`` and ``/constructors``, etc.
@@ -239,21 +216,18 @@
# Save 'Type: [Constructors]' for use in both:
# * Seeing the return type or constructors belonging to the same type.
# * Generating the types documentation, showing available constructors.
- original_paths = {
- 'css': 'css',
- 'arrow': 'img/arrow.svg',
- 'search.js': 'js/search.js',
- '404': '404.html',
- 'index_all': 'index.html',
- 'bot_index': 'botindex.html',
- 'index_types': 'types/index.html',
- 'index_methods': 'methods/index.html',
- 'index_constructors': 'constructors/index.html'
- }
- original_paths = {k: os.path.join(output_dir, v)
- for k, v in original_paths.items()}
-
- original_paths['default_css'] = 'light' # docs.<name>.css, local path
+ paths = {k: root / v for k, v in (
+ ('css', 'css'),
+ ('arrow', 'img/arrow.svg'),
+ ('search.js', 'js/search.js'),
+ ('404', '404.html'),
+ ('index_all', 'index.html'),
+ ('bot_index', 'botindex.html'),
+ ('index_types', 'types/index.html'),
+ ('index_methods', 'methods/index.html'),
+ ('index_constructors', 'constructors/index.html')
+ )}
+ paths['default_css'] = 'light' # docs.<name>.css, local path
type_to_constructors = defaultdict(list)
type_to_functions = defaultdict(list)
for tlobject in tlobjects:
@@ -266,24 +240,20 @@
methods = {m.name: m for m in methods}
# Since the output directory is needed everywhere partially apply it now
- create_path_for = functools.partial(_get_create_path_for, output_dir)
- path_for_type = functools.partial(_get_path_for_type, output_dir)
+ create_path_for = functools.partial(_get_create_path_for, root)
+ path_for_type = lambda t: root / _get_path_for_type(t)
bot_docs_paths = []
for tlobject in tlobjects:
filename = create_path_for(tlobject)
- paths = {k: _get_relative_path(v, filename)
- for k, v in original_paths.items()}
-
- with DocsWriter(filename, type_to_path=path_for_type) as docs:
+ with DocsWriter(root, filename, path_for_type) as docs:
docs.write_head(title=tlobject.class_name,
- relative_css_path=paths['css'],
- default_css=original_paths['default_css'])
+ css_path=paths['css'],
+ default_css=paths['default_css'])
# Create the menu (path to the current TLObject)
docs.set_menu_separator(paths['arrow'])
- _build_menu(docs, filename, output_dir,
- relative_main_index=paths['index_all'])
+ _build_menu(docs)
# Create the page title
docs.write_title(tlobject.class_name)
@@ -333,9 +303,7 @@
inner = tlobject.result
docs.begin_table(column_count=1)
- docs.add_row(inner, link=path_for_type(
- inner, relative_to=filename
- ))
+ docs.add_row(inner, link=path_for_type(inner))
docs.end_table()
cs = type_to_constructors.get(inner, [])
@@ -349,7 +317,6 @@
docs.begin_table(column_count=2)
for constructor in cs:
link = create_path_for(constructor)
- link = _get_relative_path(link, relative_to=filename)
docs.add_row(constructor.class_name, link=link)
docs.end_table()
@@ -380,8 +347,8 @@
docs.add_row('!' + friendly_type, align='center')
else:
docs.add_row(
- friendly_type, align='center', link=
- path_for_type(arg.type, relative_to=filename)
+ friendly_type, align='center',
+ link=path_for_type(arg.type)
)
# Add a description for this argument
@@ -441,18 +408,13 @@
docs.add_script(relative_src=paths['search.js'])
docs.end_body()
- temp = []
- for item in bot_docs_paths:
- temp.append(os.path.sep.join(item.split(os.path.sep)[2:]))
- bot_docs_paths = temp
-
# Find all the available types (which are not the same as the constructors)
# Each type has a list of constructors associated to it, hence is a map
for t, cs in type_to_constructors.items():
filename = path_for_type(t)
- out_dir = os.path.dirname(filename)
+ out_dir = filename.parent
if out_dir:
- os.makedirs(out_dir, exist_ok=True)
+ out_dir.mkdir(parents=True, exist_ok=True)
# Since we don't have access to the full TLObject, split the type
if '.' in t:
@@ -460,17 +422,13 @@
else:
namespace, name = None, t
- paths = {k: _get_relative_path(v, out_dir, folder=True)
- for k, v in original_paths.items()}
-
- with DocsWriter(filename, type_to_path=path_for_type) as docs:
+ with DocsWriter(root, filename, path_for_type) as docs:
docs.write_head(title=snake_to_camel_case(name),
- relative_css_path=paths['css'],
- default_css=original_paths['default_css'])
+ css_path=paths['css'],
+ default_css=paths['default_css'])
docs.set_menu_separator(paths['arrow'])
- _build_menu(docs, filename, output_dir,
- relative_main_index=paths['index_all'])
+ _build_menu(docs)
# Main file title
docs.write_title(snake_to_camel_case(name))
@@ -489,7 +447,6 @@
for constructor in cs:
# Constructor full name
link = create_path_for(constructor)
- link = _get_relative_path(link, relative_to=filename)
docs.add_row(constructor.class_name, link=link)
docs.end_table()
@@ -509,7 +466,6 @@
docs.begin_table(2)
for func in functions:
link = create_path_for(func)
- link = _get_relative_path(link, relative_to=filename)
docs.add_row(func.class_name, link=link)
docs.end_table()
@@ -534,7 +490,6 @@
docs.begin_table(2)
for ot in other_methods:
link = create_path_for(ot)
- link = _get_relative_path(link, relative_to=filename)
docs.add_row(ot.class_name, link=link)
docs.end_table()
@@ -560,7 +515,6 @@
docs.begin_table(2)
for ot in other_types:
link = create_path_for(ot)
- link = _get_relative_path(link, relative_to=filename)
docs.add_row(ot.class_name, link=link)
docs.end_table()
docs.end_body()
@@ -570,11 +524,10 @@
# information that we have available, simply a file listing all the others
# accessible by clicking on their title
for folder in ['types', 'methods', 'constructors']:
- _generate_index(os.path.join(output_dir, folder), original_paths,
- output_dir)
+ _generate_index(root, root / folder, paths)
- _generate_index(os.path.join(output_dir, 'methods'), original_paths,
- output_dir, True, bot_docs_paths)
+ _generate_index(root, root / 'methods', paths, True,
+ bot_docs_paths)
# Write the final core index, the main index for the rest of files
types = set()
@@ -596,9 +549,8 @@
methods = sorted(methods, key=lambda m: m.name)
cs = sorted(cs, key=lambda c: c.name)
- shutil.copy(os.path.join(input_res, '404.html'), original_paths['404'])
- _copy_replace(os.path.join(input_res, 'core.html'),
- original_paths['index_all'], {
+ shutil.copy(str(input_res / '404.html'), str(paths['404']))
+ _copy_replace(input_res / 'core.html', paths['index_all'], {
'{type_count}': len(types),
'{method_count}': len(methods),
'{constructor_count}': len(tlobjects) - len(methods),
@@ -624,17 +576,15 @@
type_names = fmt(types, formatter=lambda x: x)
# Local URLs shouldn't rely on the output's root, so set empty root
- create_path_for = functools.partial(_get_create_path_for, '', make=False)
- path_for_type = functools.partial(_get_path_for_type, '')
+ create_path_for = functools.partial(
+ _get_create_path_for, Path(), make=False)
+
request_urls = fmt(methods, create_path_for)
- type_urls = fmt(types, path_for_type)
+ type_urls = fmt(types, _get_path_for_type)
constructor_urls = fmt(cs, create_path_for)
- os.makedirs(os.path.abspath(os.path.join(
- original_paths['search.js'], os.path.pardir
- )), exist_ok=True)
- _copy_replace(os.path.join(input_res, 'js', 'search.js'),
- original_paths['search.js'], {
+ paths['search.js'].parent.mkdir(parents=True, exist_ok=True)
+ _copy_replace(input_res / 'js/search.js', paths['search.js'], {
'{request_names}': request_names,
'{type_names}': type_names,
'{constructor_names}': constructor_names,
@@ -649,11 +599,11 @@
('img', ['arrow.svg'])]:
- dirpath = os.path.join(out_dir, dirname)
- os.makedirs(dirpath, exist_ok=True)
+ dirpath = out_dir / dirname
+ dirpath.mkdir(parents=True, exist_ok=True)
for file in files:
- shutil.copy(os.path.join(res_dir, dirname, file), dirpath)
+ shutil.copy(str(res_dir / dirname / file), str(dirpath))
def generate_docs(tlobjects, methods, layer, input_res, output_dir):
- os.makedirs(output_dir, exist_ok=True)
- _write_html_pages(tlobjects, methods, layer, input_res, output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ _write_html_pages(output_dir, tlobjects, methods, layer, input_res)
_copy_resources(input_res, output_dir)
--- a/telethon_generator/generators/tlobject.py
+++ b/telethon_generator/generators/tlobject.py
@@ -48,9 +48,8 @@
def _write_modules(
out_dir, depth, kind, namespace_tlobjects, type_constructors):
# namespace_tlobjects: {'namespace', [TLObject]}
- os.makedirs(out_dir, exist_ok=True)
+ out_dir.mkdir(parents=True, exist_ok=True)
for ns, tlobjects in namespace_tlobjects.items():
- file = os.path.join(out_dir, '{}.py'.format(ns or '__init__'))
- with open(file, 'w', encoding='utf-8') as f,\
- SourceBuilder(f) as builder:
+ file = out_dir / '{}.py'.format(ns or '__init__')
+ with file.open('w') as f, SourceBuilder(f) as builder:
builder.writeln(AUTO_GEN_NOTICE)
builder.writeln('from {}.tl.tlobject import TLObject', '.' * depth)
@@ -635,11 +634,10 @@
def _write_patched(out_dir, namespace_tlobjects):
- os.makedirs(out_dir, exist_ok=True)
+ out_dir.mkdir(parents=True, exist_ok=True)
for ns, tlobjects in namespace_tlobjects.items():
- file = os.path.join(out_dir, '{}.py'.format(ns or '__init__'))
- with open(file, 'w', encoding='utf-8') as f,\
- SourceBuilder(f) as builder:
+ file = out_dir / '{}.py'.format(ns or '__init__')
+ with file.open('w') as f, SourceBuilder(f) as builder:
builder.writeln(AUTO_GEN_NOTICE)
builder.writeln('import struct')
@@ -715,26 +713,24 @@
if tlobject.fullname in PATCHED_TYPES:
namespace_patched[tlobject.namespace].append(tlobject)
- get_file = functools.partial(os.path.join, output_dir)
- _write_modules(get_file('functions'), import_depth, 'TLRequest',
+ _write_modules(output_dir / 'functions', import_depth, 'TLRequest',
namespace_functions, type_constructors)
- _write_modules(get_file('types'), import_depth, 'TLObject',
+ _write_modules(output_dir / 'types', import_depth, 'TLObject',
namespace_types, type_constructors)
- _write_patched(get_file('patched'), namespace_patched)
+ _write_patched(output_dir / 'patched', namespace_patched)
- filename = os.path.join(get_file('alltlobjects.py'))
- with open(filename, 'w', encoding='utf-8') as file:
+ filename = output_dir / 'alltlobjects.py'
+ with filename.open('w') as file:
with SourceBuilder(file) as builder:
_write_all_tlobjects(tlobjects, layer, builder)
def clean_tlobjects(output_dir):
- get_file = functools.partial(os.path.join, output_dir)
for d in ('functions', 'types'):
- d = get_file(d)
- if os.path.isdir(d):
- shutil.rmtree(d)
+ d = output_dir / d
+ if d.is_dir():
+ shutil.rmtree(str(d))
- tl = get_file('alltlobjects.py')
- if os.path.isfile(tl):
- os.remove(tl)
+ tl = output_dir / 'alltlobjects.py'
+ if tl.is_file():
+ tl.unlink()
--- a/telethon_generator/parsers/errors.py
+++ b/telethon_generator/parsers/errors.py
@@ -57,7 +57,7 @@
Parses the input CSV file with columns (name, error codes, description)
and yields `Error` instances as a result.
"""
- with open(csv_file, newline='') as f:
+ with csv_file.open(newline='') as f:
f = csv.reader(f)
next(f, None) # header
for line, (name, codes, description) in enumerate(f, start=2):
--- a/telethon_generator/parsers/methods.py
+++ b/telethon_generator/parsers/methods.py
@@ -30,7 +30,7 @@
Parses the input CSV file with columns (method, usability, errors)
and yields `MethodInfo` instances as a result.
"""
- with open(csv_file, newline='') as f:
+ with csv_file.open(newline='') as f:
f = csv.reader(f)
next(f, None) # header
for line, (method, usability, errors) in enumerate(f, start=2):
--- a/telethon_generator/parsers/tlobject/parser.py
+++ b/telethon_generator/parsers/tlobject/parser.py
@@ -86,7 +86,7 @@
obj_all = []
obj_by_name = {}
obj_by_type = collections.defaultdict(list)
- with open(file_path, 'r', encoding='utf-8') as file:
+ with file_path.open() as file:
is_function = False
for line in file:
comment_index = line.find('//')

View File

@ -1,4 +1,6 @@
cryptg
pysocks
python-socks[asyncio]
hachoir
pillow
isal

View File

@ -1,6 +1,16 @@
# https://snarky.ca/what-the-heck-is-pyproject-toml/
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
# Need to use legacy format for the time being
# https://tox.readthedocs.io/en/3.20.0/example/basic.html#pyproject-toml-tox-legacy-ini
[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py35,py36,py37,py38
# run with tox -e py
[testenv]
deps =
-rrequirements.txt
@ -22,3 +32,5 @@ commands =
flake8 telethon/ telethon_generator/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 telethon/ telethon_generator/ tests/ --count --exit-zero --exclude telethon/tl/,telethon/errors/rpcerrorlist.py --max-complexity=10 --max-line-length=127 --statistics
"""

View File

@ -6,14 +6,16 @@ Installation
Telethon is a Python library, which means you need to download and install
Python from https://www.python.org/downloads/ if you haven't already. Once
you have Python installed, run:
you have Python installed, `upgrade pip`__ and run:
.. code-block:: sh
pip3 install -U telethon --user
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade telethon
To install or upgrade the library to the latest version.
…to install or upgrade the library to the latest version.
.. __: https://pythonspeed.com/articles/upgrade-pip/
Installing Development Versions
===============================
@ -23,7 +25,7 @@ you can run the following command instead:
.. code-block:: sh
pip3 install -U https://github.com/LonamiWebs/Telethon/archive/master.zip --user
python3 -m pip install --upgrade https://github.com/LonamiWebs/Telethon/archive/v1.zip
.. note::
@ -74,7 +76,7 @@ manually.
Some of the modules may require additional dependencies before being
installed through ``pip``. If you have an ``apt``-based system, consider
installing the most commonly missing dependencies:
installing the most commonly missing dependencies (with the right ``pip``):
.. code-block:: sh
@ -85,6 +87,7 @@ manually.
Thanks to `@bb010g`_ for writing down this nice list.
.. _cryptg: https://github.com/cher-nov/cryptg
.. _pyaes: https://github.com/ricmoo/pyaes
.. _pillow: https://python-pillow.org

View File

@ -20,3 +20,27 @@ that are worth learning and understanding.
From now on, you can keep pressing the "Next" button if you want,
or use the menu on the left, since some pages are quite lengthy.
A note on developing applications
=================================
If you're using the library to make an actual application (and not just
automate things), you should make sure to `comply with the ToS`__:
[…] when logging in as an existing user, apps are supposed to call
[:tl:`GetTermsOfServiceUpdate`] to check for any updates to the Terms of
Service; this call should be repeated after ``expires`` seconds have
elapsed. If an update to the Terms Of Service is available, clients are
supposed to show a consent popup; if accepted, clients should call
[:tl:`AcceptTermsOfService`], providing the ``termsOfService id`` JSON
object; in case of denial, clients are to delete the account using
[:tl:`DeleteAccount`], providing Decline ToS update as deletion reason.
.. __: https://core.telegram.org/api/config#terms-of-service
However, if you use the library to automate or enhance your Telegram
experience, it's very likely that you are using other applications doing this
check for you (so you wouldn't run the risk of violating the ToS).
The library itself will not automatically perform this check or accept the ToS
because it should require user action (the only exception is during sign-up).

View File

@ -19,7 +19,7 @@ use these if possible.
# Getting information about yourself
me = await client.get_me()
# "me" is an User object. You can pretty-print
# "me" is a user object. You can pretty-print
# any Telegram object with the "stringify" method:
print(me.stringify())
@ -41,13 +41,13 @@ use these if possible.
# ...to your contacts
await client.send_message('+34600123123', 'Hello, friend!')
# ...or even to any username
await client.send_message('TelethonChat', 'Hello, Telethon!')
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://lonamiwebs.github.io)!',
'a [nice website](https://example.com)!',
link_preview=False
)

View File

@ -99,7 +99,7 @@ You will still need an API ID and hash, but the process is very similar:
api_id = 12345
api_hash = '0123456789abcdef0123456789abcdef'
bot_token = '12345:0123456789abcdef0123456789abcdef
bot_token = '12345:0123456789abcdef0123456789abcdef'
# 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)
@ -117,7 +117,12 @@ Signing In behind a Proxy
=========================
If you need to use a proxy to access Telegram,
you will need to `install PySocks`__ and then change:
you will need to either:
* For Python >= 3.6 : `install python-socks[asyncio]`__
* For Python <= 3.5 : `install PySocks`__
and then change
.. code-block:: python
@ -127,13 +132,48 @@ with
.. code-block:: python
TelegramClient('anon', api_id, api_hash, proxy=(socks.SOCKS5, '127.0.0.1', 4444))
TelegramClient('anon', api_id, api_hash, proxy=("socks5", '127.0.0.1', 4444))
(of course, replacing the IP and port with the IP and port of the proxy).
(of course, replacing the protocol, IP and port with the protocol, IP and port of the proxy).
The ``proxy=`` argument should be a tuple, a list or a dict,
The ``proxy=`` argument should be a dict (or tuple, for backwards compatibility),
consisting of parameters described `in PySocks usage`__.
The allowed values for the argument ``proxy_type`` are:
* 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:
.. code-block:: python
proxy = {
'proxy_type': 'socks5', # (mandatory) protocol to use (see above)
'addr': '1.1.1.1', # (mandatory) proxy IP address
'port': 5555, # (mandatory) proxy port number
'username': 'foo', # (optional) username if the proxy requires auth
'password': 'bar', # (optional) password if the proxy requires auth
'rdns': True # (optional) whether to use remote or local resolve, default remote
}
For backwards compatibility with ``PySocks`` the following format
is possible (but discouraged):
.. code-block:: python
proxy = (socks.SOCKS5, '1.1.1.1', 5555, True, 'foo', 'bar')
.. __: https://github.com/romis2012/python-socks#installation
.. __: https://github.com/Anorov/PySocks#installation
.. __: https://github.com/Anorov/PySocks#usage-1

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,22 +40,22 @@ 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
loop.run_until_complete(main())
# 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?
@ -101,7 +101,7 @@ Instead of this:
# or, using asyncio's default loop (it's the same)
import asyncio
loop = asyncio.get_event_loop() # == client.loop
loop = asyncio.get_running_loop() # == client.loop
me = loop.run_until_complete(client.get_me())
print(me.username)
@ -158,13 +158,10 @@ loops or use ``async with``:
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
@ -184,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
@ -209,29 +206,36 @@ 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?
==================
Yes, you can, but you must understand that the loops themselves are
not thread safe. and you must be sure to know what is happening. You
may want to create a loop in a new thread and make sure to pass it to
the client:
not thread safe. and you must be sure to know what is happening. The
easiest and cleanest option is to use `asyncio.run` to create and manage
the new event loop for you:
.. code-block:: python
import asyncio
import threading
def go():
loop = asyncio.new_event_loop()
async def actual_work():
client = TelegramClient(..., loop=loop)
...
... # can use `await` here
def go():
asyncio.run(actual_work())
threading.Thread(target=go).start()
@ -248,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!
@ -308,27 +312,26 @@ you can run requests in parallel:
async def main():
last, sent, download_path = await asyncio.gather(
client.get_messages('TelethonChat', 10),
client.send_message('TelethonOfftopic', 'Hey guys!'),
client.download_profile_photo('TelethonChat')
client.get_messages('telegram', 10),
client.send_message('me', 'Using asyncio!'),
client.download_profile_photo('telegram')
)
loop.run_until_complete(main())
This code will get the 10 last messages from `@TelethonChat
<https://t.me/TelethonChat>`_, send one to `@TelethonOfftopic
<https://t.me/TelethonOfftopic>`_, and also download the profile
photo of the main group. `asyncio` will run all these three tasks
at the same time. You can run all the tasks you want this way.
This code will get the 10 last messages from `@telegram
<https://t.me/telegram>`_, send one to the chat with yourself, and also
download the profile photo of the channel. `asyncio` will run all these
three tasks at the same time. You can run all the tasks you want this way.
A different way would be:
.. code-block:: python
loop.create_task(client.get_messages('TelethonChat', 10))
loop.create_task(client.send_message('TelethonOfftopic', 'Hey guys!'))
loop.create_task(client.download_profile_photo('TelethonChat'))
loop.create_task(client.get_messages('telegram', 10))
loop.create_task(client.send_message('me', 'Using asyncio!'))
loop.create_task(client.download_profile_photo('telegram'))
They will run in the background as long as the loop is running too.
@ -360,6 +363,6 @@ Where can I read more?
======================
`Check out my blog post
<https://lonamiwebs.github.io/blog/asyncio/>`_ about `asyncio`, which
<https://lonami.dev/blog/asyncio/>`_ about `asyncio`, which
has some more examples and pictures to help you understand what happens
when the loop runs.

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?
================
@ -66,8 +69,8 @@ things, you will be able to easily login as a user and even keep your bot
without having to learn a new library.
If less overhead and full control didn't convince you to use Telethon yet,
check out the repository `HTTP Bot API vs MTProto comparison`_ with a more
exhaustive and up-to-date list of differences.
check out the wiki page `MTProto vs HTTP Bot API`_ with a more exhaustive
and up-to-date list of differences.
Migrating from Bot API to Telethon
@ -88,7 +91,7 @@ Next, we will see some examples from the most popular libraries.
Migrating from python-telegram-bot
----------------------------------
Let's take their `echobot2.py`_ example and shorten it a bit:
Let's take their `echobot.py`_ example and shorten it a bit:
.. code-block:: python
@ -107,7 +110,7 @@ Let's take their `echobot2.py`_ example and shorten it a bit:
updater = Updater("TOKEN")
dp = updater.dispatcher
dp.add_handler(CommandHandler("start", start))
dp.add_handler(MessageHandler(Filters.text, echo))
dp.add_handler(MessageHandler(Filters.text & ~Filters.command, echo))
updater.start_polling()
@ -145,7 +148,7 @@ After using Telethon:
Key differences:
* The recommended way to do it imports less things.
* The recommended way to do it imports fewer things.
* All handlers trigger by default, so we need ``events.StopPropagation``.
* Adding handlers, responding and running is a lot less verbose.
* Telethon needs ``async def`` and ``await``.
@ -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():
@ -323,11 +326,11 @@ Key differences:
.. _Bot FAQ: https://core.telegram.org/bots/faq
.. _tdlib: https://core.telegram.org/tdlib
.. _MTProto: https://core.telegram.org/mtproto
.. _HTTP Bot API vs MTProto comparison: https://github.com/telegram-mtproto/botapi-comparison
.. _MTProto vs HTTP Bot API: https://github.com/LonamiWebs/Telethon/wiki/MTProto-vs-HTTP-Bot-API
.. _requests: https://pypi.org/project/requests/
.. _python-telegram-bot: https://python-telegram-bot.readthedocs.io
.. _pyTelegramBotAPI: https://github.com/eternnoir/pyTelegramBotAPI
.. _aiohttp: https://docs.aiohttp.org/en/stable
.. _aiogram: https://aiogram.readthedocs.io
.. _dumbot: https://github.com/Lonami/dumbot
.. _echobot2.py: https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/echobot2.py
.. _echobot.py: https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/echobot.py

View File

@ -0,0 +1,169 @@
.. _chats-channels:
=================
Chats vs Channels
=================
Telegram's raw API can get very confusing sometimes, in particular when it
comes to talking about "chats", "channels", "groups", "megagroups", and all
those concepts.
This section will try to explain what each of these concepts are.
Chats
=====
A ``Chat`` can be used to talk about either the common "subclass" that both
chats and channels share, or the concrete :tl:`Chat` type.
Technically, both :tl:`Chat` and :tl:`Channel` are a form of the `Chat type`_.
**Most of the time**, the term :tl:`Chat` is used to talk about *small group
chats*. When you create a group through an official application, this is the
type that you get. Official applications refer to these as "Group".
Both the bot API and Telethon will add a minus sign (negate) the real chat ID
so that you can tell at a glance, with just a number, the entity type.
For example, if you create a chat with :tl:`CreateChatRequest`, the real chat
ID might be something like `123`. If you try printing it from a
`message.chat_id` you will see `-123`. This ID helps Telethon know you're
talking about a :tl:`Chat`.
Channels
========
Official applications create a *broadcast* channel when you create a new
channel (used to broadcast messages, only administrators can post messages).
Official applications implicitly *migrate* an *existing* :tl:`Chat` to a
*megagroup* :tl:`Channel` when you perform certain actions (exceed user limit,
add a public username, set certain permissions, etc.).
A ``Channel`` can be created directly with :tl:`CreateChannelRequest`, as
either a ``megagroup`` or ``broadcast``.
Official applications use the term "channel" **only** for broadcast channels.
The API refers to the different types of :tl:`Channel` with certain attributes:
* A **broadcast channel** is a :tl:`Channel` with the ``channel.broadcast``
attribute set to `True`.
* A **megagroup channel** is a :tl:`Channel` with the ``channel.megagroup``
attribute set to `True`. Official applications refer to this as "supergroup".
* A **gigagroup channel** is a :tl:`Channel` with the ``channel.gigagroup``
attribute set to `True`. Official applications refer to this as "broadcast
groups", and is used when a megagroup becomes very large and administrators
want to transform it into something where only they can post messages.
Both the bot API and Telethon will "concatenate" ``-100`` to the real chat ID
so that you can tell at a glance, with just a number, the entity type.
For example, if you create a new broadcast channel, the real channel ID might
be something like `456`. If you try printing it from a `message.chat_id` you
will see `-1000000000456`. This ID helps Telethon know you're talking about a
:tl:`Channel`.
Converting IDs
==============
You can convert between the "marked" identifiers (prefixed with a minus sign)
and the real ones with ``utils.resolve_id``. It will return a tuple with the
real ID, and the peer type (the class):
.. code-block:: python
from telethon import utils
real_id, peer_type = utils.resolve_id(-1000000000456)
print(real_id) # 456
print(peer_type) # <class 'telethon.tl.types.PeerChannel'>
peer = peer_type(real_id)
print(peer) # PeerChannel(channel_id=456)
The reverse operation can be done with ``utils.get_peer_id``:
.. code-block:: python
print(utils.get_peer_id(types.PeerChannel(456))) # -1000000000456
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_peer_id``:
.. code-block:: python
print(await client.get_peer_id('me')) # your id
If there is no "mark" (no minus sign), Telethon will assume your identifier
refers to a :tl:`User`. If this is **not** the case, you can manually fix it:
.. code-block:: python
from telethon import types
await client.send_message(types.PeerChannel(456), 'hello')
# ^^^^^^^^^^^^^^^^^ explicit peer type
A note on raw API
=================
Certain methods only work on a :tl:`Chat`, and some others only work on a
:tl:`Channel` (and these may only work in broadcast, or megagroup). Your code
likely knows what it's working with, so it shouldn't be too much of an issue.
If you need to find the :tl:`Channel` from a :tl:`Chat` that migrated to it,
access the `migrated_to` property:
.. code-block:: python
# chat is a Chat
channel = await client.get_entity(chat.migrated_to)
# channel is now a Channel
Channels do not have a "migrated_from", but a :tl:`ChannelFull` does. You can
use :tl:`GetFullChannelRequest` to obtain this:
.. code-block:: python
from telethon import functions
full = await client(functions.channels.GetFullChannelRequest(your_channel))
full_channel = full.full_chat
# full_channel is a ChannelFull
print(full_channel.migrated_from_chat_id)
This way, you can also access the linked discussion megagroup of a broadcast channel:
.. code-block:: python
print(full_channel.linked_chat_id) # prints ID of linked discussion group or None
You do not need to use ``client.get_entity`` to access the
``migrated_from_chat_id`` :tl:`Chat` or the ``linked_chat_id`` :tl:`Channel`.
They are in the ``full.chats`` attribute:
.. code-block:: python
if full_channel.migrated_from_chat_id:
migrated_from_chat = next(c for c in full.chats if c.id == full_channel.migrated_from_chat_id)
print(migrated_from_chat.title)
if full_channel.linked_chat_id:
linked_group = next(c for c in full.chats if c.id == full_channel.linked_chat_id)
print(linked_group.username)
.. _Chat type: https://tl.telethon.dev/types/chat.html

View File

@ -112,9 +112,9 @@ you're able to just do this:
dialogs = await client.get_dialogs()
# All of these work and do the same.
lonami = await client.get_entity('lonami')
lonami = await client.get_entity('t.me/lonami')
lonami = await client.get_entity('https://telegram.dog/lonami')
username = await client.get_entity('username')
username = await client.get_entity('t.me/username')
username = await client.get_entity('https://telegram.dog/username')
# Other kind of entities.
channel = await client.get_entity('telegram.me/joinchat/AAAAAEkk2WdoDrB4-Q8-gg')
@ -142,7 +142,7 @@ you're able to just do this:
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('lonami', 'hi!')
That way, convenience calls such as `client.send_message('username', 'hi!')
<telethon.client.messages.MessageMethods.send_message>`
become possible.
@ -178,7 +178,7 @@ 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 use them. You need to know their hash before you can
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.
@ -268,7 +268,7 @@ That means you can do this:
.. code-block:: python
message.user_id
await message.get_input_user()
await message.get_input_sender()
message.user
# ...etc
@ -289,17 +289,17 @@ 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 an username? Use it!
# Does it have a username? Use it!
entity = await client.get_entity(username)
# Do you have a conversation open with them? Get dialogs.
await client.get_dialogs()
# Are they participant of some group? Get them.
await client.get_participants('TelethonChat')
await client.get_participants('username')
# Is the entity the original sender of a forwarded message? Get it.
await client.get_messages('TelethonChat', 100)
await client.get_messages('username', 100)
# NOW you can use the ID, anywhere!
await client.send_message(123456, 'Hi!')

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,19 +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).
.. important::
All the examples in this documentation assume that you have
``from telethon import sync`` or ``import telethon.sync`` for the
sake of simplicity and that you understand what it does (see
:ref:`compatibility-and-convenience` for more). Simply add
either line at the beginning of your project and it will work.
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.
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.
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,
@ -233,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

@ -100,6 +100,9 @@ There are other community-maintained implementations available:
* `Redis <https://github.com/ezdev128/telethon-session-redis>`_:
stores all sessions in a single Redis data store.
* `MongoDB <https://github.com/watzon/telethon-session-mongo>`_:
stores the current session in a MongoDB database.
Creating your Own Storage
=========================
@ -140,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.

View File

@ -3,87 +3,75 @@ String-based Debugging
======================
Debugging is *really* important. Telegram's API is really big and there
is a lot of things that you should know. Such as, what attributes or fields
are a lot of things that you should know. Such as, what attributes or fields
does a result have? Well, the easiest thing to do is printing it:
.. code-block:: python
user = await client.get_entity('Lonami')
print(user)
entity = await client.get_entity('username')
print(entity)
That will show a huge **string** similar to the following:
.. code-block:: python
User(id=10885151, is_self=False, contact=False, mutual_contact=False, deleted=False, bot=False, bot_chat_history=False, bot_nochats=False, verified=False, restricted=False, min=False, bot_inline_geo=False, access_hash=123456789012345678, first_name='Lonami', last_name=None, username='Lonami', phone=None, photo=UserProfilePhoto(photo_id=123456789012345678, photo_small=FileLocation(dc_id=4, volume_id=1234567890, local_id=1234567890, secret=123456789012345678), photo_big=FileLocation(dc_id=4, volume_id=1234567890, local_id=1234567890, secret=123456789012345678)), status=UserStatusOffline(was_online=datetime.datetime(2018, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)), bot_info_version=None, restriction_reason=None, bot_inline_placeholder=None, lang_code=None)
Channel(id=1066197625, title='Telegram Usernames', photo=ChatPhotoEmpty(), date=datetime.datetime(2016, 12, 16, 15, 15, 43, tzinfo=datetime.timezone.utc), version=0, creator=False, left=True, broadcast=True, verified=True, megagroup=False, restricted=False, signatures=False, min=False, scam=False, has_link=False, has_geo=False, slowmode_enabled=False, access_hash=-6309373984955162244, username='username', restriction_reason=[], admin_rights=None, banned_rights=None, default_banned_rights=None, participants_count=None)
That's a lot of text. But as you can see, all the properties are there.
So if you want the username you **don't use regex** or anything like
splitting ``str(user)`` to get what you want. You just access the
So if you want the title you **don't use regex** or anything like
splitting ``str(entity)`` to get what you want. You just access the
attribute you need:
.. code-block:: python
username = user.username
title = entity.title
Can we get better than the shown string, though? Yes!
.. code-block:: python
print(user.stringify())
print(entity.stringify())
Will show a much better:
Will show a much better representation:
.. code-block:: python
User(
id=10885151,
is_self=False,
contact=False,
mutual_contact=False,
deleted=False,
bot=False,
bot_chat_history=False,
bot_nochats=False,
verified=False,
Channel(
id=1066197625,
title='Telegram Usernames',
photo=ChatPhotoEmpty(
),
date=datetime.datetime(2016, 12, 16, 15, 15, 43, tzinfo=datetime.timezone.utc),
version=0,
creator=False,
left=True,
broadcast=True,
verified=True,
megagroup=False,
restricted=False,
signatures=False,
min=False,
bot_inline_geo=False,
access_hash=123456789012345678,
first_name='Lonami',
last_name=None,
username='Lonami',
phone=None,
photo=UserProfilePhoto(
photo_id=123456789012345678,
photo_small=FileLocation(
dc_id=4,
volume_id=123456789,
local_id=123456789,
secret=-123456789012345678
),
photo_big=FileLocation(
dc_id=4,
volume_id=123456789,
local_id=123456789,
secret=123456789012345678
)
),
status=UserStatusOffline(
was_online=datetime.datetime(2018, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)
),
bot_info_version=None,
restriction_reason=None,
bot_inline_placeholder=None,
lang_code=None
scam=False,
has_link=False,
has_geo=False,
slowmode_enabled=False,
access_hash=-6309373984955162244,
username='username',
restriction_reason=[
],
admin_rights=None,
banned_rights=None,
default_banned_rights=None,
participants_count=None
)
Now it's easy to see how we could get, for example,
the ``was_online`` time. It's inside ``status``:
the ``year`` value. It's inside ``date``:
.. code-block:: python
online_at = user.status.was_online
channel_year = entity.date.year
You don't need to print everything to see what all the possible values
can be. You can just search in http://tl.telethon.dev/.
@ -96,5 +84,5 @@ to check the type of something. For example:
from telethon import types
if isinstance(user.status, types.UserStatusOffline):
print(user.status.was_online)
if isinstance(entity.photo, types.ChatPhotoEmpty):
print('Channel has no photo')

View File

@ -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

@ -2,72 +2,12 @@
Telegram API in Other Languages
===============================
Telethon was made for **Python**, and it has inspired other libraries such as
`gramjs <https://github.com/gram-js/gramjs>`__ (JavaScript) and `grammers
<https://github.com/Lonami/grammers>`__ (Rust). But there is a lot more beyond
those, made independently by different developers.
Telethon was made for **Python**, and as far as I know, there is no
*exact* port to other languages. However, there *are* other
implementations made by awesome people (one needs to be awesome to
understand the official Telegram documentation) on several languages
(even more Python too), listed below:
C
*
Possibly the most well-known unofficial open source implementation out
there by `@vysheng <https://github.com/vysheng>`__,
`tgl <https://github.com/vysheng/tgl>`__, and its console client
`telegram-cli <https://github.com/vysheng/tg>`__. Latest development
has been moved to `BitBucket <https://bitbucket.org/vysheng/tdcli>`__.
C++
===
The newest (and official) library, written from scratch, is called
`tdlib <https://github.com/tdlib/td>`__ and is what the Telegram X
uses. You can find more information in the official documentation,
published `here <https://core.telegram.org/tdlib/docs/>`__.
JavaScript
==========
`@zerobias <https://github.com/zerobias>`__ is working on
`telegram-mtproto <https://github.com/zerobias/telegram-mtproto>`__,
a work-in-progress JavaScript library installable via
`npm <https://www.npmjs.com/>`__.
Kotlin
======
`Kotlogram <https://github.com/badoualy/kotlogram>`__ is a Telegram
implementation written in Kotlin (one of the
`official <https://blog.jetbrains.com/kotlin/2017/05/kotlin-on-android-now-official/>`__
languages for
`Android <https://developer.android.com/kotlin/index.html>`__) by
`@badoualy <https://github.com/badoualy>`__, currently as a beta
yet working.
PHP
===
A PHP implementation is also available thanks to
`@danog <https://github.com/danog>`__ and his
`MadelineProto <https://github.com/danog/MadelineProto>`__ project, with
a very nice `online
documentation <https://daniil.it/MadelineProto/API_docs/>`__ too.
Python
======
A fairly new (as of the end of 2017) Telegram library written from the
ground up in Python by
`@delivrance <https://github.com/delivrance>`__ and his
`Pyrogram <https://github.com/pyrogram/pyrogram>`__ library.
There isn't really a reason to pick it over Telethon and it'd be kinda
sad to see you go, but it would be nice to know what you miss from each
other library in either one so both can improve.
Rust
====
Yet another work-in-progress implementation, this time for Rust thanks
to `@JuanPotato <https://github.com/JuanPotato>`__ under the fancy
name of `Vail <https://github.com/JuanPotato/Vail>`__.
If you're looking for something like Telethon but in a different programming
language, head over to `Telegram API in Other Languages in the official wiki
<https://github.com/LonamiWebs/Telethon/wiki/Telegram-API-in-Other-Languages>`__
for a (mostly) up-to-date list.

View File

@ -35,3 +35,7 @@ times, in this case, ``22222`` so we can hardcode that:
client.start(
phone='9996621234', code_callback=lambda: '22222'
)
Note that Telegram has changed the length of login codes multiple times in the
past, so if ``dc_id`` repeated five times does not work, try repeating it six
times.

View File

@ -84,6 +84,10 @@ use is very straightforward, or :tl:`InviteToChannelRequest` for channels:
[users_to_add]
))
Note that this method will only really work for friends or bot accounts.
Trying to mass-add users with this approach will not work, and can put both
your account and group to risk, possibly being flagged as spam and limited.
Checking a link without joining
===============================
@ -93,140 +97,6 @@ channel, you can use the :tl:`CheckChatInviteRequest`, which takes in
the hash of said channel or group.
Admin Permissions
=================
Giving or revoking admin permissions can be done with the :tl:`EditAdminRequest`:
.. code-block:: python
from telethon.tl.functions.channels import EditAdminRequest
from telethon.tl.types import ChatAdminRights
# You need both the channel and who to grant permissions
# They can either be channel/user or input channel/input user.
#
# ChatAdminRights is a list of granted permissions.
# Set to True those you want to give.
rights = ChatAdminRights(
post_messages=None,
add_admins=None,
invite_users=None,
change_info=True,
ban_users=None,
delete_messages=True,
pin_messages=True,
invite_link=None,
edit_messages=None
)
# Equivalent to:
# rights = ChatAdminRights(
# change_info=True,
# delete_messages=True,
# pin_messages=True
# )
# Once you have a ChatAdminRights, invoke it
await client(EditAdminRequest(channel, user, rights))
# User will now be able to change group info, delete other people's
# messages and pin messages.
#
# In a normal chat, you should do this instead:
from telethon.tl.functions.messages import EditChatAdminRequest
await client(EditChatAdminRequest(chat_id, user, is_admin=True))
.. note::
Thanks to `@Kyle2142`__ for `pointing out`__ that you **cannot** set all
parameters to `True` to give a user full permissions, as not all
permissions are related to both broadcast channels/megagroups.
E.g. trying to set ``post_messages=True`` in a megagroup will raise an
error. It is recommended to always use keyword arguments, and to set only
the permissions the user needs. If you don't need to change a permission,
it can be omitted (full list `here`__).
Restricting Users
=================
Similar to how you give or revoke admin permissions, you can edit the
banned rights of a user through :tl:`EditBannedRequest` and its parameter
:tl:`ChatBannedRights`:
.. code-block:: python
from telethon.tl.functions.channels import EditBannedRequest
from telethon.tl.types import ChatBannedRights
from datetime import datetime, timedelta
# Restricting a user for 7 days, only allowing view/send messages.
#
# Note that it's "reversed". You must set to `True` the permissions
# you want to REMOVE, and leave as `None` those you want to KEEP.
rights = ChatBannedRights(
until_date=timedelta(days=7),
view_messages=None,
send_messages=None,
send_media=True,
send_stickers=True,
send_gifs=True,
send_games=True,
send_inline=True,
embed_links=True
)
# The above is equivalent to
rights = ChatBannedRights(
until_date=datetime.now() + timedelta(days=7),
send_media=True,
send_stickers=True,
send_gifs=True,
send_games=True,
send_inline=True,
embed_links=True
)
await client(EditBannedRequest(channel, user, rights))
You can use a `datetime.datetime` object for ``until_date=``,
a `datetime.timedelta` or even a Unix timestamp. Note that if you ban
someone for less than 30 seconds or for more than 366 days, Telegram
will consider the ban to actually last forever. This is officially
documented under https://core.telegram.org/bots/api#restrictchatmember.
Kicking a member
================
Telegram doesn't actually have a request to kick a user from a group.
Instead, you need to restrict them so they can't see messages. Any date
is enough:
.. code-block:: python
from telethon.tl.functions.channels import EditBannedRequest
from telethon.tl.types import ChatBannedRights
await client(EditBannedRequest(
channel, user, ChatBannedRights(
until_date=None,
view_messages=True
)
))
__ https://github.com/Kyle2142
__ https://github.com/LonamiWebs/Telethon/issues/490
__ https://tl.telethon.dev/constructors/channel_admin_rights.html
Increasing View Count in a Channel
==================================

View File

@ -1,98 +0,0 @@
.. _telethon_projects:
=======================
Projects using Telethon
=======================
This page lists some **interesting and useful** real world
examples showcasing what can be built with the library.
.. note::
Do you have an interesting project that uses the library or know of any
that's not listed here? Feel free to leave a comment at
`issue 744 <https://github.com/LonamiWebs/Telethon/issues/744>`_
so it can be included in the next revision of the documentation!
You can also advertise your bot and its features, in the issue, although
it should be a big project which can be useful for others before being
included here, so please don't feel offended if it can't be here!
.. _projects-telegram-export:
telethon_examples/
==================
`telethon_examples <https://github.com/LonamiWebs/Telethon/tree/master/telethon_examples>`_ /
`LonamiWebs' site <https://lonamiwebs.github.io>`_
This documentation is not the only place where you can find useful code
snippets using the library. The main repository also has a folder with
some cool examples (even a Tkinter GUI!) which you can download, edit
and run to learn and play with them.
@TelethonSnippets
=================
`@TelethonSnippets <https://t.me/TelethonSnippets>`_
You can find useful short snippets for Telethon here.
telegram-export
===============
`telegram-export <https://github.com/expectocode/telegram-export>`_ /
`expectocode's GitHub <https://github.com/expectocode>`_
A tool to download Telegram data (users, chats, messages, and media)
into a database (and display the saved data).
.. _projects-mautrix-telegram:
mautrix-telegram
================
`mautrix-telegram <https://github.com/tulir/mautrix-telegram>`_ /
`maunium's site <https://maunium.net/>`_
A Matrix-Telegram hybrid puppeting/relaybot bridge.
.. _projects-telegramtui:
TelegramTUI
===========
`TelegramTUI <https://github.com/bad-day/TelegramTUI>`_ /
`bad-day's GitHub <https://github.com/bad-day>`_
A Telegram client on your terminal.
tgcloud
=======
`tgcloud <https://github.com/SlavikMIPT/tgcloud>`_ /
`tgcloud's site <https://dev.tgcloud.xyz/>`_
Opensource Telegram based cloud storage.
tgmount
=======
`tgmount <https://github.com/nktknshn/tgmount>`_ /
`nktknshn's GitHub <https://github.com/nktknshn>`_
Mount Telegram dialogs and channels as a Virtual File System.
garnet
======
`garnet <https://github.com/uwinx/pomegranate>`_ /
`uwinx's GitHub <https://github.com/uwinx>`_
Pomegranate (or ``garnet`` for short) is a small telethon add-on which
features persistent conversations based on Finite State Machines (FSM),
a new ``Filter`` to define handlers more conveniently and utilities to
run code on start and finish of the client. Be sure to check the project
to learn about its latest features, since this description may be out of
date.

View File

@ -25,7 +25,7 @@ you should use :tl:`GetFullUser`:
# or even
full = await client(GetFullUserRequest('username'))
bio = full.about
bio = full.full_user.about
See :tl:`UserFull` to know what other fields you can access.
@ -71,4 +71,4 @@ through :tl:`UploadProfilePhoto`:
await client(UploadProfilePhotoRequest(
await client.upload_file('/path/to/some/file')
)))
))

View File

@ -13,4 +13,5 @@ Full API **will** break between different minor versions of the library,
since Telegram changes very often. The friendly methods will be kept
compatible between major versions.
If you need to see real-world examples, please refer to :ref:`telethon_projects`.
If you need to see real-world examples, please refer to the
`wiki page of projects using Telethon <https://github.com/LonamiWebs/Telethon/wiki/Projects-using-Telethon>`__.

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

@ -68,6 +68,7 @@ You can also use the menu on the left to quickly skip over sections.
concepts/strings
concepts/entities
concepts/chats-vs-channels
concepts/updates
concepts/sessions
concepts/full-api
@ -83,7 +84,6 @@ You can also use the menu on the left to quickly skip over sections.
examples/chats-and-channels
examples/users
examples/working-with-messages
examples/projects-using-telethon
.. toctree::
:hidden:
@ -103,7 +103,6 @@ You can also use the menu on the left to quickly skip over sections.
:caption: Miscellaneous
misc/changelog
misc/wall-of-shame.rst
misc/compatibility-and-convenience
.. toctree::

File diff suppressed because it is too large Load Diff

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,65 +0,0 @@
=============
Wall of Shame
=============
This project has an
`issues <https://github.com/LonamiWebs/Telethon/issues>`__ section for
you to file **issues** whenever you encounter any when working with the
library. Said section is **not** for issues on *your* program but rather
issues with Telethon itself.
If you have not made the effort to 1. read through the docs and 2.
`look for the method you need <https://tl.telethon.dev/>`__,
you will end up on the `Wall of
Shame <https://github.com/LonamiWebs/Telethon/issues?q=is%3Aissue+label%3ARTFM+is%3Aclosed>`__,
i.e. all issues labeled
`"RTFM" <http://www.urbandictionary.com/define.php?term=RTFM>`__:
**rtfm**
Literally "Read The F--king Manual"; a term showing the
frustration of being bothered with questions so trivial that the asker
could have quickly figured out the answer on their own with minimal
effort, usually by reading readily-available documents. People who
say"RTFM!" might be considered rude, but the true rude ones are the
annoying people who take absolutely no self-responibility and expect to
have all the answers handed to them personally.
*"Damn, that's the twelveth time that somebody posted this question
to the messageboard today! RTFM, already!"*
*by Bill M. July 27, 2004*
If you have indeed read the docs, and have tried looking for the method,
and yet you didn't find what you need, **that's fine**. Telegram's API
can have some obscure names at times, and for this reason, there is a
`"question"
label <https://github.com/LonamiWebs/Telethon/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20label%3Aquestion%20>`__
with questions that are okay to ask. Just state what you've tried so
that we know you've made an effort, or you'll go to the Wall of Shame.
Of course, if the issue you're going to open is not even a question but
a real issue with the library (thankfully, most of the issues have been
that!), you won't end up here. Don't worry.
Current winner
--------------
The current winner is `issue
213 <https://github.com/LonamiWebs/Telethon/issues/213>`__:
**Issue:**
.. figure:: https://user-images.githubusercontent.com/6297805/29822978-9a9a6ef0-8ccd-11e7-9ec5-934ea0f57681.jpg
:alt: Winner issue
Winner issue
**Answer:**
.. figure:: https://user-images.githubusercontent.com/6297805/29822983-9d523402-8ccd-11e7-9fb1-5783740ee366.jpg
:alt: Winner issue answer
Winner issue answer

View File

@ -136,6 +136,24 @@ MessageButton
:show-inheritance:
ParticipantPermissions
======================
.. automodule:: telethon.tl.custom.participantpermissions
:members:
:undoc-members:
:show-inheritance:
QRLogin
=======
.. automodule:: telethon.tl.custom.qrlogin
:members:
:undoc-members:
:show-inheritance:
SenderGetter
============

View File

@ -9,7 +9,7 @@ you may need when using Telethon. They are sorted by relevance and are not in
alphabetical order.
You should use this page to learn about which methods are available, and
if you need an usage example or further description of the arguments, be
if you need a usage example or further description of the arguments, be
sure to follow the links.
.. contents::
@ -31,7 +31,7 @@ Auth
start
send_code_request
sign_in
sign_up
qr_login
log_out
edit_2fa
@ -48,6 +48,7 @@ Base
is_connected
disconnected
loop
set_proxy
Messages
--------
@ -64,6 +65,7 @@ Messages
iter_messages
get_messages
pin_message
unpin_message
send_read_acknowledge
Uploads
@ -88,6 +90,7 @@ Downloads
download_media
download_profile_photo
download_file
iter_download
Dialogs
-------
@ -130,12 +133,15 @@ Chats
iter_participants
get_participants
kick_participant
iter_admin_log
get_admin_log
iter_profile_photos
get_profile_photos
edit_admin
edit_permissions
get_permissions
get_stats
action
Parse Mode
@ -162,6 +168,7 @@ Updates
remove_event_handler
list_event_handlers
catch_up
set_receive_updates
Bots
----

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``,
@ -179,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?
==================================
@ -204,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?
=================================
@ -239,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

@ -244,6 +244,7 @@ These are the static methods you can use to create instances of the markup:
text
request_location
request_phone
request_poll
clear
force_reply

View File

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

View File

@ -15,12 +15,15 @@ import json
import os
import re
import shutil
import sys
import urllib.request
from pathlib import Path
from subprocess import run
from sys import argv
from setuptools import find_packages, setup
# Needed since we're importing local files
sys.path.insert(0, os.path.dirname(__file__))
class TempWorkDir:
"""Switches the working directory to be the one on which this file lives,
@ -41,6 +44,8 @@ class TempWorkDir:
os.chdir(self.original)
API_REF_URL = 'https://tl.telethon.dev/'
GENERATOR_DIR = Path('telethon_generator')
LIBRARY_DIR = Path('telethon')
@ -148,23 +153,46 @@ def generate(which, action='gen'):
)
def main():
def main(argv):
if len(argv) >= 2 and argv[1] in ('gen', 'clean'):
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
for x in ('build', 'dist', 'Telethon.egg-info'):
remove_dirs = ['__pycache__', 'build', 'dist', 'Telethon.egg-info']
for root, _dirs, _files in os.walk(LIBRARY_DIR, topdown=False):
# setuptools is including __pycache__ for some reason (#1605)
if root.endswith('/__pycache__'):
remove_dirs.append(root)
for x in remove_dirs:
shutil.rmtree(x, ignore_errors=True)
run('python3 setup.py sdist', shell=True)
run('python3 setup.py bdist_wheel', shell=True)
run('twine upload dist/*', shell=True)
@ -216,11 +244,13 @@ def main():
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6'
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
],
keywords='telegram api chat client library messaging mtproto',
packages=find_packages(exclude=[
'telethon_*', 'run_tests.py', 'try_telethon.py'
'telethon_*', 'tests*'
]),
install_requires=['pyaes', 'rsa'],
extras_require={
@ -231,4 +261,4 @@ def main():
if __name__ == '__main__':
with TempWorkDir():
main()
main(sys.argv)

View File

@ -1,8 +1,8 @@
from .client.telegramclient import TelegramClient
from .network import connection
from .tl import types, functions, custom
from .tl.custom import Button
from . import version, events, utils, errors
from .tl import patched as _ # import for its side-effects
from . import version, events, utils, errors, types, functions, custom
__version__ = version.__version__

View File

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

View File

@ -0,0 +1,59 @@
from .session import EntityType, Entity
_sentinel = object()
class EntityCache:
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, 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:
hash, ty = self.hash_map[id]
return Entity(ty, id, hash)
except KeyError:
return None
def extend(self, users, chats):
# See https://core.telegram.org/api/min for "issues" with "min constructors".
self.hash_map.update(
(u.id, (
u.access_hash,
EntityType.BOT if u.bot else EntityType.USER,
))
for u in users
if getattr(u, 'access_hash', None) and not u.min
)
self.hash_map.update(
(c.id, (
c.access_hash,
EntityType.MEGAGROUP if c.megagroup else (
EntityType.GIGAGROUP if getattr(c, 'gigagroup', None) else EntityType.CHANNEL
),
))
for c in chats
if getattr(c, 'access_hash', None) and not getattr(c, 'min', None)
)
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

@ -0,0 +1,825 @@
"""
This module deals with correct handling of updates, including gaps, and knowing when the code
should "get difference" (the set of updates that the client should know by now minus the set
of updates that it actually knows).
Each chat has its own [`Entry`] in the [`MessageBox`] (this `struct` is the "entry point").
At any given time, the message box may be either getting difference for them (entry is in
[`MessageBox::getting_diff_for`]) or not. If not getting difference, a possible gap may be
found for the updates (entry is in [`MessageBox::possible_gaps`]). Otherwise, the entry is
on its happy path.
Gaps are cleared when they are either resolved on their own (by waiting for a short time)
or because we got the difference for the corresponding entry.
While there are entries for which their difference must be fetched,
[`MessageBox::check_deadlines`] will always return [`Instant::now`], since "now" is the time
to get the difference.
"""
import asyncio
import datetime
import time
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.
NO_SEQ = 0
# See https://core.telegram.org/method/updates.getChannelDifference.
BOT_CHANNEL_DIFF_LIMIT = 100000
USER_CHANNEL_DIFF_LIMIT = 100
# > It may be useful to wait up to 0.5 seconds
POSSIBLE_GAP_TIMEOUT = 0.5
# After how long without updates the client will "timeout".
#
# When this timeout occurs, the client will attempt to fetch updates by itself, ignoring all the
# updates that arrive in the meantime. After all updates are fetched when this happens, the
# client will resume normal operation, and the timeout will reset.
#
# 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 = Sentinel('ACCOUNT')
# Account-wide `qts` includes only "secret" one-to-one chats.
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 get_running_loop().time() + NO_UPDATES_TIMEOUT
def epoch():
return datetime.datetime(*time.gmtime(0)[:6]).replace(tzinfo=datetime.timezone.utc)
class GapError(ValueError):
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`.
class PtsInfo:
__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):
pts = getattr(update, 'pts', None)
if pts:
pts_count = getattr(update, 'pts_count', None) or 0
try:
entry = update.message.peer_id.channel_id
except AttributeError:
entry = getattr(update, 'channel_id', None) or ENTRY_ACCOUNT
return cls(pts=pts, pts_count=pts_count, entry=entry)
qts = getattr(update, 'qts', None)
if qts:
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.
class State:
__slots__ = ('pts', 'deadline')
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
# > […] Manually obtaining updates is also required in the following situations:
# > • Loss of sync: a gap was found in `seq` / `pts` / `qts` (as described above).
# > It may be useful to wait up to 0.5 seconds in this situation and abort the sync in case a new update
# > arrives, that fills the gap.
#
# 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).
class PossibleGap:
__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.
class MessageBox:
__slots__ = ('_log', 'map', 'date', 'seq', 'next_deadline', 'possible_gaps', 'getting_diff_for')
def __init__(
self,
log,
# Map each entry to their current state.
map: dict = _sentinel, # entry -> state
# Additional fields beyond PTS needed by `ENTRY_ACCOUNT`.
date: datetime.datetime = epoch() + datetime.timedelta(seconds=1),
seq: int = NO_SEQ,
# Holds the entry with the closest deadline (optimization to avoid recalculating the minimum deadline).
next_deadline: object = None, # 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
# 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.
def load(self, session_state, channel_states):
"""
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()
if session_state.pts != NO_SEQ:
self.map[ENTRY_ACCOUNT] = State(pts=session_state.pts, deadline=deadline)
if session_state.qts != NO_SEQ:
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, tz=datetime.timezone.utc)
self.seq = session_state.seq
self.next_deadline = ENTRY_ACCOUNT
def session_state(self):
"""
Return the current state.
This should be used for persisting the state.
"""
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=self.date,
seq=self.seq,
), {id: state.pts for id, state in self.map.items() if isinstance(id, int)}
def is_empty(self) -> bool:
"""
Return true if the message box is empty and has no state yet.
"""
return ENTRY_ACCOUNT not in self.map
def check_deadlines(self):
"""
Return the next deadline when receiving updates should timeout.
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 = get_running_loop().time()
if self.getting_diff_for:
return now
deadline = next_updates_deadline()
# Most of the time there will be zero or one gap in flight so finding the minimum is cheap.
if self.possible_gaps:
deadline = min(deadline, *(gap.deadline for gap in self.possible_gaps.values()))
elif self.next_deadline in self.map:
deadline = min(deadline, self.map[self.next_deadline].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)
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).
for entry in self.getting_diff_for:
self.possible_gaps.pop(entry, None)
return deadline
# 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_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
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_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, reset=True):
if __debug__:
self._trace('Setting state %s', state)
deadline = next_updates_deadline()
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)
# 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)
self.date = state.date
self.seq = state.seq
# Like [`MessageBox::set_state`], but for channels. Useful when getting dialogs.
#
# 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())
# 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 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)
# Finish getting difference for the given entry.
#
# It also resets the deadline.
def end_get_diff(self, entry):
try:
self.getting_diff_for.remove(entry)
except KeyError:
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.
# region "Normal" updates flow (processing and detection of gaps).
# Process an update and return what should be done with it.
#
# Updates corresponding to entries for which their difference is currently being fetched
# will be ignored. While according to the [updates' documentation]:
#
# > Implementations [have] to postpone updates received via the socket while
# > filling gaps in the event and `Update` sequences, as well as avoid filling
# > gaps in the same sequence.
#
# In practice, these updates should have also been retrieved through getting difference.
#
# [updates documentation] https://core.telegram.org/api/updates
def process_updates(
self,
updates,
chat_hashes,
result, # out list of updates; returns list of user, chat, or raise if gap
):
# 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 []
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.try_begin_get_diff(ENTRY_ACCOUNT, 'detected gap')
raise GapError
# else apply
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)
for _ in range(len(self.possible_gaps[key].updates)):
update = self.possible_gaps[key].updates.pop(0)
# 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_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.
#
# If the update can be applied, it is returned; otherwise, the update is stored in a
# possible gap (unless it was already handled or would be handled through getting
# difference) and `None` is returned.
def apply_pts_info(
self,
update,
*,
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.
#
# 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=get_running_loop().time() + POSSIBLE_GAP_TIMEOUT,
updates=[]
)
self.possible_gaps[pts.entry].updates.append(update)
return None
else:
# Apply
if __debug__:
self._trace('Applying update pts since local pts %r = %r: %s', local_pts, pts, update)
# 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 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 = pts.pts
else:
# 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
# endregion "Normal" updates flow (processing and detection of gaps).
# region Getting and applying account difference.
# Return the request that needs to be made to get the difference, if any.
def get_difference(self):
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,
)
if __debug__:
self._trace('Requesting account difference %s', gd)
return gd
return None
# Similar to [`MessageBox::process_updates`], but using the result from getting difference.
def apply_difference(
self,
diff,
chat_hashes,
):
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
result = [], [], []
elif isinstance(diff, tl.updates.Difference):
finish = True
chat_hashes.extend(diff.users, diff.chats)
result = self.apply_difference_type(diff, chat_hashes)
elif isinstance(diff, tl.updates.DifferenceSlice):
finish = False
chat_hashes.extend(diff.users, diff.chats)
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, reset=False)
# 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)
updates.extend(tl.UpdateNewMessage(
message=m,
pts=NO_SEQ,
pts_count=NO_SEQ,
) for m in diff.new_messages)
updates.extend(tl.UpdateNewEncryptedMessage(
message=m,
qts=NO_SEQ,
) for m in diff.new_encrypted_messages)
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.
# region Getting and applying channel difference.
# Return the request that needs to be made to get a channel's difference, if any.
def get_channel_difference(
self,
chat_hashes,
):
entry = next((id for id in self.getting_diff_for if isinstance(id, int)), None)
if not entry:
return None
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.
self.map.pop(entry, None)
return None
state = self.map.get(entry)
if not state:
raise RuntimeError('Should not try to get difference for an entry without known state')
gd = fn.updates.GetChannelDifferenceRequest(
force=False,
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(
self,
request,
diff,
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):
assert diff.final
self.end_get_diff(entry)
self.map[entry].pts = diff.pts
return [], [], []
elif isinstance(diff, tl.updates.ChannelDifferenceTooLong):
assert diff.final
self.map[entry].pts = diff.dialog.pts
chat_hashes.extend(diff.users, diff.chats)
self.reset_channel_deadline(entry, diff.timeout)
# This `diff` has the "latest messages and corresponding chats", but it would
# 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):
if diff.final:
self.end_get_diff(entry)
self.map[entry].pts = diff.pts
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)
self.reset_channel_deadline(entry, None)
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

@ -0,0 +1,195 @@
from typing import Optional, Tuple
from enum import IntEnum
from ..tl.types import InputPeerUser, InputPeerChat, InputPeerChannel
import struct
class SessionState:
"""
Stores the information needed to fetch updates and about the current user.
* user_id: 64-bit number representing the user identifier.
* dc_id: 32-bit number relating to the datacenter identifier where the user is.
* bot: is the logged-in user a bot?
* pts: 64-bit number holding the state needed to fetch updates.
* qts: alternative 64-bit number holding the state needed to fetch updates.
* date: 64-bit number holding the date needed to fetch updates.
* seq: 64-bit-number holding the sequence number needed to fetch updates.
* takeout_id: 64-bit-number holding the identifier of the current takeout session.
Note that some of the numbers will only use 32 out of the 64 available bits.
However, for future-proofing reasons, we recommend you pretend they are 64-bit long.
"""
__slots__ = ('user_id', 'dc_id', 'bot', 'pts', 'qts', 'date', 'seq', 'takeout_id')
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__})
class ChannelState:
"""
Stores the information needed to fetch updates from a channel.
* channel_id: 64-bit number representing the channel identifier.
* pts: 64-bit number holding the state needed to fetch updates.
"""
__slots__ = ('channel_id', 'pts')
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):
"""
You can rely on the type value to be equal to the ASCII character one of:
* 'U' (85): this entity belongs to a :tl:`User` who is not a ``bot``.
* 'B' (66): this entity belongs to a :tl:`User` who is a ``bot``.
* 'G' (71): this entity belongs to a small group :tl:`Chat`.
* 'C' (67): this entity belongs to a standard broadcast :tl:`Channel`.
* 'M' (77): this entity belongs to a megagroup :tl:`Channel`.
* 'E' (69): this entity belongs to an "enormous" "gigagroup" :tl:`Channel`.
"""
USER = ord('U')
BOT = ord('B')
GROUP = ord('G')
CHANNEL = ord('C')
MEGAGROUP = ord('M')
GIGAGROUP = ord('E')
def canonical(self):
"""
Return the canonical version of this type.
"""
return _canon_entity_types[self]
_canon_entity_types = {
EntityType.USER: EntityType.USER,
EntityType.BOT: EntityType.USER,
EntityType.GROUP: EntityType.GROUP,
EntityType.CHANNEL: EntityType.CHANNEL,
EntityType.MEGAGROUP: EntityType.CHANNEL,
EntityType.GIGAGROUP: EntityType.CHANNEL,
}
class Entity:
"""
Stores the information needed to use a certain user, chat or channel with the API.
* ty: 8-bit number indicating the type of the entity (of type `EntityType`).
* id: 64-bit number uniquely identifying the entity among those of the same type.
* hash: 64-bit signed number needed to use this entity with the API.
The string representation of this class is considered to be stable, for as long as
Telegram doesn't need to add more fields to the entities. It can also be converted
to bytes with ``bytes(entity)``, for a more compact representation.
"""
__slots__ = ('ty', 'id', 'hash')
def __init__(
self,
ty: EntityType,
id: int,
hash: int
):
self.ty = ty
self.id = id
self.hash = hash
@property
def is_user(self):
"""
``True`` if the entity is either a user or a bot.
"""
return self.ty in (EntityType.USER, EntityType.BOT)
@property
def is_group(self):
"""
``True`` if the entity is a small group chat or `megagroup`_.
.. _megagroup: https://telegram.org/blog/supergroups5k
"""
return self.ty in (EntityType.GROUP, EntityType.MEGAGROUP)
@property
def is_broadcast(self):
"""
``True`` if the entity is a broadcast channel or `broadcast group`_.
.. _broadcast group: https://telegram.org/blog/autodelete-inv2#groups-with-unlimited-members
"""
return self.ty in (EntityType.CHANNEL, EntityType.GIGAGROUP)
@classmethod
def from_str(cls, string: str):
"""
Convert the string into an `Entity`.
"""
try:
ty, id, hash = string.split('.')
ty, id, hash = ord(ty), int(id), int(hash)
except AttributeError:
raise TypeError(f'expected str, got {string!r}') from None
except (TypeError, ValueError):
raise ValueError(f'malformed entity str (must be T.id.hash), got {string!r}') from None
return cls(EntityType(ty), id, hash)
@classmethod
def from_bytes(cls, blob):
"""
Convert the bytes into an `Entity`.
"""
try:
ty, id, hash = struct.unpack('<Bqq', blob)
except struct.error:
raise ValueError(f'malformed entity data, got {blob!r}') from None
return cls(EntityType(ty), id, hash)
def __str__(self):
return f'{chr(self.ty)}.{self.id}.{self.hash}'
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

@ -3,9 +3,11 @@ import inspect
import os
import sys
import typing
import warnings
from .. import utils, helpers, errors, password as pwd_mod
from ..tl import types, functions
from ..tl import types, functions, custom
from .._updates import SessionState
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
@ -17,8 +19,8 @@ class AuthMethods:
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: '),
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,
@ -32,12 +34,6 @@ class AuthMethods:
By default, this method will be interactive (asking for
user input if needed), and will handle 2FA if enabled too.
If the phone doesn't belong to an existing account (and will hence
`sign_up` for a new one), **you are agreeing to Telegram's
Terms of Service. This is required and your account
will be banned otherwise.** See https://telegram.org/tos
and https://core.telegram.org/api/terms.
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.
@ -91,7 +87,7 @@ class AuthMethods:
# Starting as a bot account
await client.start(bot_token=bot_token)
# Starting as an user account
# Starting as a user account
await client.start(phone)
# Please enter the code you received: 12345
# Please enter your password: *******
@ -138,7 +134,31 @@ class AuthMethods:
if not self.is_connected():
await self.connect()
if await self.is_user_authorized():
# 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:
@ -164,7 +184,6 @@ class AuthMethods:
two_step_detected = False
await self.send_code_request(phone, force_sms=force_sms)
sign_up = False # assume login
while attempts < max_attempts:
try:
value = code_callback()
@ -177,19 +196,12 @@ class AuthMethods:
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)
# 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,
@ -228,13 +240,14 @@ class AuthMethods:
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)
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)
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'))
.decode('ascii', errors='ignore'), tos, sep='')
return self
@ -342,13 +355,18 @@ class AuthMethods:
'and a password only if an RPCError was raised before.'
)
result = await self(request)
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 self._on_login(result.user)
return await self._on_login(result.user)
async def sign_up(
self: 'TelegramClient',
@ -359,92 +377,41 @@ class AuthMethods:
phone: str = None,
phone_code_hash: str = None) -> 'types.User':
"""
Signs up to Telegram as a new user account.
Use this if you don't have an account yet.
You must call `send_code_request` first.
**By using this method you're agreeing to Telegram's
Terms of Service. This is required and your account
will be banned otherwise.** See https://telegram.org/tos
and https://core.telegram.org/api/terms.
Arguments
code (`str` | `int`):
The code sent by Telegram
first_name (`str`):
The first name to be used by the new account.
last_name (`str`, optional)
Optional last name.
phone (`str` | `int`, optional):
The phone to sign up. This will be the last phone used by
default (you normally don't need to set this).
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 new created :tl:`User`.
Example
.. code-block:: python
phone = '+34 123 123 123'
await client.send_code_request(phone)
code = input('enter code: ')
await client.sign_up(code, first_name='Anna', last_name='Banana')
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.
"""
me = await self.get_me()
if me:
return me
raise ValueError('Third-party applications cannot sign up for Telegram. See https://github.com/LonamiWebs/Telethon/issues/4050 for details')
if self._tos and self._tos.text:
if self.parse_mode:
t = self.parse_mode.unparse(self._tos.text, self._tos.entities)
else:
t = self._tos.text
sys.stderr.write("{}\n".format(t))
sys.stderr.flush()
phone, phone_code_hash = \
self._parse_phone_and_hash(phone, phone_code_hash)
result = await self(functions.auth.SignUpRequest(
phone_number=phone,
phone_code_hash=phone_code_hash,
first_name=first_name,
last_name=last_name
))
if self._tos:
await self(
functions.help.AcceptTermsOfServiceRequest(self._tos.id))
return self._on_login(result.user)
def _on_login(self, user):
async def _on_login(self, user):
"""
Callback called whenever the login or sign up process completes.
Returns the input user parameter.
"""
self._bot = bool(user.bot)
self._self_input_peer = utils.get_input_peer(user, allow_self=False)
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) -> 'types.auth.SentCode':
force_sms: bool = False,
_retry_count: int = 0) -> 'types.auth.SentCode':
"""
Sends the Telegram code needed to login to the given phone number.
@ -453,7 +420,8 @@ class AuthMethods:
The phone to which the code will be sent.
force_sms (`bool`, optional):
Whether to force sending as SMS.
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`.
@ -465,6 +433,10 @@ class AuthMethods:
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)
@ -474,7 +446,14 @@ class AuthMethods:
result = await self(functions.auth.SendCodeRequest(
phone, self.api_id, self.api_hash, types.CodeSettings()))
except errors.AuthRestartError:
return await self.send_code_request(phone, force_sms=force_sms)
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):
@ -489,17 +468,72 @@ class AuthMethods:
self._phone = phone
if force_sms:
result = await self(
functions.auth.ResendCodeRequest(phone, phone_hash))
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.
@ -514,13 +548,12 @@ class AuthMethods:
except errors.RPCError:
return False
self._bot = None
self._self_input_peer = None
self._mb_entity_cache.set_self_user(None, None, None)
self._authorized = False
self._state_cache.reset()
await self.disconnect()
self.session.delete()
await utils.maybe_async(self.session.delete())
self.session = None
return True
async def edit_2fa(

View File

@ -13,6 +13,7 @@ class BotMethods:
bot: 'hints.EntityLike',
query: str,
*,
entity: 'hints.EntityLike' = None,
offset: str = None,
geo_point: 'types.GeoPoint' = None) -> custom.InlineResults:
"""
@ -25,6 +26,15 @@ class BotMethods:
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.
@ -46,12 +56,17 @@ class BotMethods:
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=types.InputPeerEmpty(),
peer=peer,
query=query,
offset=offset or '',
geo_point=geo_point
))
return custom.InlineResults(self, result)
return custom.InlineResults(self, result, entity=peer if entity else None)

View File

@ -7,8 +7,8 @@ from ..tl import types, custom
class ButtonMethods:
@staticmethod
def build_reply_markup(
buttons: 'typing.Optional[hints.MarkupLike]',
inline_only: bool = False) -> 'typing.Optional[types.TypeReplyMarkup]':
buttons: 'typing.Optional[hints.MarkupLike]'
) -> 'typing.Optional[types.TypeReplyMarkup]':
"""
Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for
the given buttons.
@ -26,23 +26,21 @@ class ButtonMethods:
The button, list of buttons, array of buttons or markup
to convert into a markup.
inline_only (`bool`, optional):
Whether the buttons **must** be inline buttons only or not.
Example
.. code-block:: python
from telethon import Button
markup = client.build_reply_markup(Button.inline('hi'))
await client.send_message('click me', buttons=markup)
# later
await client.send_message(chat, 'click me', buttons=markup)
"""
if buttons is None:
return None
try:
if buttons.SUBCLASS_OF_ID == 0xe2e10ef2:
return buttons # crc32(b'ReplyMarkup'):
if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: # crc32(b'ReplyMarkup'):
return buttons
except AttributeError:
pass
@ -56,6 +54,8 @@ class ButtonMethods:
resize = None
single_use = None
selective = None
persistent = None
placeholder = None
rows = []
for row in buttons:
@ -68,6 +68,10 @@ class ButtonMethods:
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):
@ -77,19 +81,21 @@ class ButtonMethods:
is_inline |= inline
is_normal |= not inline
if button.SUBCLASS_OF_ID == 0xbad74a3:
# 0xbad74a3 == crc32(b'KeyboardButton')
if button.SUBCLASS_OF_ID == 0xbad74a3: # crc32(b'KeyboardButton')
current.append(button)
if current:
rows.append(types.KeyboardButtonRow(current))
if inline_only and is_normal:
raise ValueError('You cannot use non-inline buttons here')
elif is_inline == is_normal and is_normal:
if is_inline and is_normal:
raise ValueError('You cannot mix inline with normal buttons')
elif is_inline:
return types.ReplyInlineMarkup(rows)
# elif is_normal:
return types.ReplyKeyboardMarkup(
rows, resize=resize, single_use=single_use, selective=selective)
rows=rows,
resize=resize,
single_use=single_use,
selective=selective,
persistent=persistent,
placeholder=placeholder
)

View File

@ -4,7 +4,7 @@ import itertools
import string
import typing
from .. import helpers, utils, hints
from .. import helpers, utils, hints, errors
from ..requestiter import RequestIter
from ..tl import types, functions, custom
@ -22,6 +22,7 @@ class _ChatAction:
'contact': types.SendMessageChooseContactAction(),
'game': types.SendMessageGamePlayAction(),
'location': types.SendMessageGeoLocationAction(),
'sticker': types.SendMessageChooseStickerAction(),
'record-audio': types.SendMessageRecordAudioAction(),
'record-voice': types.SendMessageRecordAudioAction(), # alias
@ -30,13 +31,13 @@ class _ChatAction:
'audio': types.SendMessageUploadAudioAction(1),
'voice': types.SendMessageUploadAudioAction(1), # alias
'song': types.SendMessageUploadAudioAction(1), # alias
'round': types.SendMessageUploadRoundAction(1),
'video': types.SendMessageUploadVideoAction(1),
'photo': types.SendMessageUploadPhotoAction(1),
'document': types.SendMessageUploadDocumentAction(1),
'file': types.SendMessageUploadDocumentAction(1), # alias
'song': types.SendMessageUploadDocumentAction(1), # alias
'cancel': types.SendMessageCancelAction()
}
@ -96,7 +97,7 @@ class _ChatAction:
class _ParticipantsIter(RequestIter):
async def _init(self, entity, filter, search, aggressive):
async def _init(self, entity, filter, search):
if isinstance(filter, type):
if filter in (types.ChannelParticipantsBanned,
types.ChannelParticipantsKicked,
@ -121,33 +122,25 @@ class _ParticipantsIter(RequestIter):
self.filter_entity = lambda ent: True
# Only used for channels, but we should always set the attribute
self.requests = []
# Called `requests` even though it's just one for legacy purposes.
self.requests = None
if ty == helpers._EntityType.CHANNEL:
self.total = (await self.client(
functions.channels.GetFullChannelRequest(entity)
)).full_chat.participants_count
if self.limit <= 0:
# May not have access to the channel, but getFull can get the .total.
self.total = (await self.client(
functions.channels.GetFullChannelRequest(entity)
)).full_chat.participants_count
raise StopAsyncIteration
self.seen = set()
if aggressive and not filter:
self.requests.extend(functions.channels.GetParticipantsRequest(
channel=entity,
filter=types.ChannelParticipantsSearch(x),
offset=0,
limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
hash=0
) for x in (search or string.ascii_lowercase))
else:
self.requests.append(functions.channels.GetParticipantsRequest(
channel=entity,
filter=filter or types.ChannelParticipantsSearch(search),
offset=0,
limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
hash=0
))
self.requests = functions.channels.GetParticipantsRequest(
channel=entity,
filter=filter or types.ChannelParticipantsSearch(search),
offset=0,
limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
hash=0
)
elif ty == helpers._EntityType.CHAT:
full = await self.client(
@ -162,11 +155,18 @@ class _ParticipantsIter(RequestIter):
users = {user.id: user for user in full.users}
for participant in full.full_chat.participants.participants:
user = users[participant.user_id]
if isinstance(participant, types.ChannelParticipantLeft):
# See issue #3231 to learn why this is ignored.
continue
elif isinstance(participant, types.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[participant.user_id]
user = users[user_id]
user.participant = participant
self.buffer.append(user)
@ -185,51 +185,74 @@ class _ParticipantsIter(RequestIter):
if not self.requests:
return True
# Only care about the limit for the first request
# (small amount of people, won't be aggressive).
#
# 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.requests[0].limit = min(
self.limit - self.requests[0].offset, _MAX_PARTICIPANTS_CHUNK_SIZE)
self.requests.limit = min(self.limit - self.requests.offset, _MAX_PARTICIPANTS_CHUNK_SIZE)
if self.requests[0].offset > self.limit:
if self.requests.offset > self.limit:
return True
results = await self.client(self.requests)
for i in reversed(range(len(self.requests))):
participants = results[i]
if not participants.users:
self.requests.pop(i)
if self.total is None:
f = self.requests.filter
if (
not isinstance(f, types.ChannelParticipantsRecent)
and (not isinstance(f, types.ChannelParticipantsSearch) or f.q)
):
# Only do an additional getParticipants here to get the total
# if there's a filter which would reduce the real total number.
# getParticipants is cheaper than getFull.
self.total = (await self.client(functions.channels.GetParticipantsRequest(
channel=self.requests.channel,
filter=types.ChannelParticipantsRecent(),
offset=0,
limit=1,
hash=0
))).count
participants = await self.client(self.requests)
if self.total is None:
# Will only get here if there was one request with a filter that matched all users.
self.total = participants.count
if not participants.users:
self.requests = None
return
self.requests.offset += len(participants.participants)
users = {user.id: user for user in participants.users}
for participant in participants.participants:
if isinstance(participant, types.ChannelParticipantLeft):
# See issue #3231 to learn why this is ignored.
continue
self.requests[i].offset += len(participants.participants)
users = {user.id: user for user in participants.users}
for participant in participants.participants:
user = users[participant.user_id]
if not self.filter_entity(user) or user.id in self.seen:
elif isinstance(participant, types.ChannelParticipantBanned):
if not isinstance(participant.peer, types.PeerUser):
# May have the entire channel banned. See #3105.
continue
user_id = participant.peer.user_id
else:
user_id = participant.user_id
self.seen.add(participant.user_id)
user = users[participant.user_id]
user.participant = participant
self.buffer.append(user)
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]
user.participant = participant
self.buffer.append(user)
class _AdminLogIter(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
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)):
promote, demote, info, settings, pinned, edit, delete,
group_call)):
events_filter = types.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
edit=edit, delete=delete, group_call=group_call
)
else:
events_filter = None
@ -337,9 +360,31 @@ class _ProfilePhotoIter(RequestIter):
else:
self.request.offset += len(result.photos)
else:
self.buffer = [x.action.photo for x in result.messages
if isinstance(x.action, types.MessageActionChatEditPhoto)]
# 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, types.messages.ChannelMessages):
channel = await self.client(functions.channels.GetFullChannelRequest(self.request.peer))
photo = channel.full_chat.chat_photo
if isinstance(photo, types.Photo):
self.buffer.append(photo)
seen_id = photo.id
self.buffer.extend(
x.action.photo for x in result.messages
if isinstance(x.action, types.MessageActionChatEditPhoto)
and x.action.photo.id != seen_id
)
if len(result.messages) < self.request.limit:
self.left = len(self.buffer)
elif result.messages:
@ -374,9 +419,6 @@ class ChatMethods:
search (`str`, optional):
Look for participants with this string in name/username.
If ``aggressive is True``, the symbols from this string will
be used.
filter (:tl:`ChannelParticipantsFilter`, optional):
The filter to be used, if you want e.g. only admins
Note that you might not have permissions for some filter.
@ -389,14 +431,11 @@ class ChatMethods:
use :tl:`ChannelParticipantsKicked` instead.
aggressive (`bool`, optional):
Aggressively looks for all participants in the chat.
Does nothing. This is kept for backwards-compatibility.
This is useful for channels since 20 July 2018,
Telegram added a server-side limit where only the
first 200 members can be retrieved. With this flag
set, more than 200 will be often be retrieved.
This has no effect if a ``filter`` is given.
There have been several changes to Telegram's API that limits
the amount of members that can be retrieved, and this was a
hack that no longer works.
Yields
The :tl:`User` objects returned by :tl:`GetParticipantsRequest`
@ -425,8 +464,7 @@ class ChatMethods:
limit,
entity=entity,
filter=filter,
search=search,
aggressive=aggressive
search=search
)
async def get_participants(
@ -451,6 +489,7 @@ class ChatMethods:
get_participants.__signature__ = inspect.signature(iter_participants)
def iter_admin_log(
self: 'TelegramClient',
entity: 'hints.EntityLike',
@ -473,7 +512,8 @@ class ChatMethods:
settings: bool = None,
pinned: bool = None,
edit: bool = None,
delete: bool = None) -> _AdminLogIter:
delete: bool = None,
group_call: bool = None) -> _AdminLogIter:
"""
Iterator over the admin log for the specified channel.
@ -560,6 +600,9 @@ class ChatMethods:
delete (`bool`):
If `True`, events of message deletions will be returned.
group_call (`bool`):
If `True`, events related to group calls will be returned.
Yields
Instances of `AdminLogEvent <telethon.tl.custom.adminlogevent.AdminLogEvent>`.
@ -591,7 +634,8 @@ class ChatMethods:
settings=settings,
pinned=pinned,
edit=edit,
delete=delete
delete=delete,
group_call=group_call
)
async def get_admin_log(
@ -711,6 +755,7 @@ class ChatMethods:
* ``'contact'``: choosing a contact.
* ``'game'``: playing a game.
* ``'location'``: choosing a geo location.
* ``'sticker'``: choosing a sticker.
* ``'record-audio'``: recording a voice note.
You may use ``'record-voice'`` as alias.
* ``'record-round'``: recording a round video.
@ -760,7 +805,8 @@ class ChatMethods:
try:
action = _ChatAction._str_mapping[action.lower()]
except KeyError:
raise ValueError('No such action "{}"'.format(action)) from None
raise ValueError(
'No such action "{}"'.format(action)) from None
elif not isinstance(action, types.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21:
# 0x20b2cc21 = crc32(b'SendMessageAction')
if isinstance(action, type):
@ -789,6 +835,8 @@ class ChatMethods:
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) -> types.Updates:
"""
@ -832,6 +880,21 @@ class ChatMethods:
add_admins (`bool`, optional):
Whether the user will be able to add admins.
manage_call (`bool`, optional):
Whether the user will be able to manage group calls.
anonymous (`bool`, optional):
Whether the user will remain anonymous when sending messages.
The sender of the anonymous messages becomes the group itself.
.. note::
Users may be able to identify the anonymous admin by its
custom title, so additional care is needed when using both
``anonymous`` and custom titles. For example, if multiple
anonymous admins share the same title, users won't be able
to distinguish them.
is_admin (`bool`, optional):
Whether the user will be an admin in the chat.
This will only work in small group chats.
@ -865,13 +928,11 @@ class ChatMethods:
"""
entity = await self.get_input_entity(entity)
user = await self.get_input_entity(user)
ty = helpers._entity_type(user)
if ty != helpers._EntityType.USER:
raise ValueError('You must pass a user entity')
perm_names = (
'change_info', 'post_messages', 'edit_messages', 'delete_messages',
'ban_users', 'invite_users', 'pin_messages', 'add_admins'
'ban_users', 'invite_users', 'pin_messages', 'add_admins',
'anonymous', 'manage_call',
)
ty = helpers._entity_type(entity)
@ -904,10 +965,11 @@ class ChatMethods:
is_admin = any(locals()[x] for x in perm_names)
return await self(functions.messages.EditChatAdminRequest(
entity, user, is_admin=is_admin))
entity.chat_id, user, is_admin=is_admin))
else:
raise ValueError('You can only edit permissions in groups and channels')
raise ValueError(
'You can only edit permissions in groups and channels')
async def edit_permissions(
self: 'TelegramClient',
@ -922,6 +984,7 @@ class ChatMethods:
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,
@ -986,6 +1049,12 @@ class ChatMethods:
send_inline (`bool`, optional):
Whether the user is able to use inline bots or not.
embed_link_previews (`bool`, optional):
Whether the user is able to enable the link preview in the
messages they send. Note that the user will still be able to
send messages with links if this permission is removed, but
these links won't display a link preview.
send_polls (`bool`, optional):
Whether the user is able to send polls or not.
@ -1031,6 +1100,7 @@ class ChatMethods:
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,
@ -1044,16 +1114,10 @@ class ChatMethods:
))
user = await self.get_input_entity(user)
ty = helpers._entity_type(user)
if ty != helpers._EntityType.USER:
raise ValueError('You must pass a user entity')
if isinstance(user, types.InputPeerSelf):
raise ValueError('You cannot restrict yourself')
return await self(functions.channels.EditBannedRequest(
channel=entity,
user_id=user,
participant=user,
banned_rights=rights
))
@ -1080,39 +1144,193 @@ class ChatMethods:
user (`entity`, optional):
The user to kick.
Returns
Returns the service `Message <telethon.tl.custom.message.Message>`
produced about a user being kicked, if any.
Example
.. code-block:: python
# Kick some user from some chat
await client.kick_participant(chat, user)
# Kick some user from some chat, and deleting the service message
msg = await client.kick_participant(chat, user)
await msg.delete()
# Leaving chat
await client.kick_participant(chat, 'me')
"""
entity = await self.get_input_entity(entity)
user = await self.get_input_entity(user)
if helpers._entity_type(user) != helpers._EntityType.USER:
raise ValueError('You must pass a user entity')
ty = helpers._entity_type(entity)
if ty == helpers._EntityType.CHAT:
await self(functions.messages.DeleteChatUserRequest(entity.chat_id, user))
resp = await self(functions.messages.DeleteChatUserRequest(entity.chat_id, user))
elif ty == helpers._EntityType.CHANNEL:
if isinstance(user, types.InputPeerSelf):
await self(functions.channels.LeaveChannelRequest(entity))
# Despite no longer being in the channel, the account still
# seems to get the service message.
resp = await self(functions.channels.LeaveChannelRequest(entity))
else:
await self(functions.channels.EditBannedRequest(
resp = await self(functions.channels.EditBannedRequest(
channel=entity,
user_id=user,
banned_rights=types.ChatBannedRights(until_date=None, view_messages=True)
participant=user,
banned_rights=types.ChatBannedRights(
until_date=None, view_messages=True)
))
await asyncio.sleep(0.5)
await self(functions.channels.EditBannedRequest(
channel=entity,
user_id=user,
participant=user,
banned_rights=types.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',
entity: 'hints.EntityLike',
user: 'hints.EntityLike' = None
) -> 'typing.Optional[custom.ParticipantPermissions]':
"""
Fetches the permissions of a user in a specific chat or channel or
get Default Restricted Rights of Chat or Channel.
.. note::
This request has to fetch the entire chat for small group chats,
which can get somewhat expensive, so use of a cache is advised.
Arguments
entity (`entity`):
The channel or chat the user is participant of.
user (`entity`, optional):
Target user.
Returns
A `ParticipantPermissions <telethon.tl.custom.participantpermissions.ParticipantPermissions>`
instance. Refer to its documentation to see what properties are
available.
Example
.. code-block:: python
permissions = await client.get_permissions(chat, user)
if permissions.is_admin:
# do something
# Get Banned Permissions of Chat
await client.get_permissions(chat)
"""
entity = await self.get_entity(entity)
if not user:
if isinstance(entity, types.Channel):
FullChat = await self(functions.channels.GetFullChannelRequest(entity))
elif isinstance(entity, types.Chat):
FullChat = await self(functions.messages.GetFullChatRequest(entity.id))
else:
return
return FullChat.chats[0].default_banned_rights
entity = await self.get_input_entity(entity)
user = await self.get_input_entity(user)
if helpers._entity_type(entity) == helpers._EntityType.CHANNEL:
participant = await self(functions.channels.GetParticipantRequest(
entity,
user
))
return custom.ParticipantPermissions(participant.participant, False)
elif helpers._entity_type(entity) == helpers._EntityType.CHAT:
chat = await self(functions.messages.GetFullChatRequest(
entity.chat_id
))
if isinstance(user, types.InputPeerSelf):
user = await self.get_me(input_peer=True)
for participant in chat.full_chat.participants.participants:
if participant.user_id == user.user_id:
return custom.ParticipantPermissions(participant, True)
raise errors.UserNotParticipantError(None)
raise ValueError('You must pass either a channel or a chat')
async def get_stats(
self: 'TelegramClient',
entity: 'hints.EntityLike',
message: 'typing.Union[int, types.Message]' = None,
):
"""
Retrieves statistics from the given megagroup or broadcast channel.
Note that some restrictions apply before being able to fetch statistics,
in particular the channel must have enough members (for megagroups, this
requires `at least 500 members`_).
Arguments
entity (`entity`):
The channel from which to get statistics.
message (`int` | ``Message``, optional):
The message ID from which to get statistics, if your goal is
to obtain the statistics of a single message.
Raises
If the given entity is not a channel (broadcast or megagroup),
a `TypeError` is raised.
If there are not enough members (poorly named) errors such as
``telethon.errors.ChatAdminRequiredError`` will appear.
Returns
If both ``entity`` and ``message`` were provided, returns
:tl:`MessageStats`. Otherwise, either :tl:`BroadcastStats` or
:tl:`MegagroupStats`, depending on whether the input belonged to a
broadcast channel or megagroup.
Example
.. code-block:: python
# Some megagroup or channel username or ID to fetch
channel = -100123
stats = await client.get_stats(channel)
print('Stats from', stats.period.min_date, 'to', stats.period.max_date, ':')
print(stats.stringify())
.. _`at least 500 members`: https://telegram.org/blog/profile-videos-people-nearby-and-more
"""
entity = await self.get_input_entity(entity)
if helpers._entity_type(entity) != helpers._EntityType.CHANNEL:
raise TypeError('You must pass a channel entity')
message = utils.get_message_id(message)
if message is not None:
try:
req = functions.stats.GetMessageStatsRequest(entity, message)
return await self(req)
except errors.StatsMigrateError 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 = functions.stats.GetBroadcastStatsRequest(entity)
return await self(req)
except errors.StatsMigrateError as e:
dc = e.dc
except errors.BroadcastRequiredError:
req = functions.stats.GetMegagroupStatsRequest(entity)
try:
return await self(req)
except errors.StatsMigrateError 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)
# endregion

View File

@ -3,7 +3,7 @@ import inspect
import itertools
import typing
from .. import helpers, utils, hints
from .. import helpers, utils, hints, errors
from ..requestiter import RequestIter
from ..tl import types, functions, custom
@ -55,12 +55,15 @@ class _DialogsIter(RequestIter):
self.total = getattr(r, 'count', len(r.dialogs))
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)}
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.to_id, m.id)] = m
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
@ -73,16 +76,24 @@ class _DialogsIter(RequestIter):
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
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 len(r.dialogs) < self.request.limit\
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
@ -99,8 +110,7 @@ class _DialogsIter(RequestIter):
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 =\
entities[utils.get_peer_id(r.dialogs[-1].peer)]
self.request.offset_peer = self.buffer[-1].input_entity
class _DraftsIter(RequestIter):
@ -245,7 +255,7 @@ class DialogMethods:
# Getting only archived dialogs (both equivalent)
archived = await client.get_dialogs(folder=1)
non_archived = await client.get_dialogs(archived=True)
archived = await client.get_dialogs(archived=True)
"""
return await self.iter_dialogs(*args, **kwargs).collect()
@ -364,7 +374,7 @@ class DialogMethods:
await client.edit_folder(dialogs, [0, 1])
# Un-archiving all dialogs
await client.archive(unpack=1)
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')
@ -378,7 +388,7 @@ class DialogMethods:
entities = [await self.get_input_entity(entity)]
else:
entities = await asyncio.gather(
*(self.get_input_entity(x) for x in entity), loop=self.loop)
*(self.get_input_entity(x) for x in entity))
if folder is None:
raise ValueError('You must specify a folder')
@ -435,14 +445,26 @@ class DialogMethods:
# 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:
result = await self(functions.messages.DeleteChatUserRequest(
entity.chat_id, types.InputUserSelf()))
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
@ -464,6 +486,16 @@ class DialogMethods:
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.
@ -558,10 +590,10 @@ class DialogMethods:
# <you> Your name didn't have any letters! Try again
conv.send_message("Your name didn't have any letters! Try again")
# <usr> Lonami
# <usr> Human
name = conv.get_response().raw_text
# <you> Thanks Lonami!
# <you> Thanks Human!
conv.send_message('Thanks {}!'.format(name))
"""
return custom.Conversation(

View File

@ -4,6 +4,9 @@ import os
import pathlib
import typing
import inspect
import asyncio
from ..crypto import AES
from .. import utils, helpers, errors, hints
from ..requestiter import RequestIter
@ -17,25 +20,38 @@ except ImportError:
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 _CdnRedirect(Exception):
def __init__(self, cdn_redirect=None):
self.cdn_redirect = cdn_redirect
class _DirectDownloadIter(RequestIter):
async def _init(
self, file, dc_id, offset, stride, chunk_size, request_size, file_size
):
self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data, cdn_redirect=None):
self.request = functions.upload.GetFileRequest(
file, offset=offset, limit=request_size)
file, offset=offset, limit=request_size)
self._client = self.client
self._cdn_redirect = cdn_redirect
if cdn_redirect is not None:
self.request = functions.upload.GetCdnFileRequest(cdn_redirect.file_token, offset=offset, limit=request_size)
self._client = await self.client._get_cdn_client(cdn_redirect)
self.total = file_size
self._stride = stride
self._chunk_size = chunk_size
self._last_part = None
self._exported = dc_id and self.client.session.dc_id != dc_id
self._msg_data = msg_data
self._timed_out = False
self._exported = dc_id and self._client.session.dc_id != dc_id
if not self._exported:
# The used sender will also change if ``FileMigrateError`` occurs
self._sender = self.client._sender
@ -47,9 +63,12 @@ class _DirectDownloadIter(RequestIter):
config = await self.client(functions.help.GetConfigRequest())
for option in config.dc_options:
if option.ip_address == self.client.session.server_address:
self.client.session.set_dc(
option.id, option.ip_address, option.port)
self.client.session.save()
await utils.maybe_async(
self.client.session.set_dc(
option.id, option.ip_address, option.port
)
)
await utils.maybe_async(self.client.session.save())
break
# TODO Figure out why the session may have the wrong DC ID
@ -67,18 +86,58 @@ class _DirectDownloadIter(RequestIter):
async def _request(self):
try:
result = await self._sender.send(self.request)
result = await self._client._call(self._sender, self.request)
self._timed_out = False
if isinstance(result, types.upload.FileCdnRedirect):
raise NotImplementedError # TODO Implement
if self.client._mb_entity_cache.self_bot:
raise ValueError('FileCdnRedirect but the GetCdnFileRequest API access for bot users is restricted. Try to change api_id to avoid FileCdnRedirect')
raise _CdnRedirect(result)
if isinstance(result, types.upload.CdnFileReuploadNeeded):
await self.client._call(self.client._sender, functions.upload.ReuploadCdnFileRequest(file_token=self._cdn_redirect.file_token, request_token=result.request_token))
result = await self._client._call(self._sender, self.request)
return result.bytes
else:
return result.bytes
except errors.TimedOutError 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, errors.FileReferenceExpiredError) 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, types.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, types.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.file_reference = document.file_reference
return await self._request()
async def close(self):
if not self._sender:
return
@ -102,12 +161,12 @@ class _DirectDownloadIter(RequestIter):
class _GenericDownloadIter(_DirectDownloadIter):
async def _load_next_chunk(self, mask=MIN_CHUNK_SIZE - 1):
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 & mask
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
@ -183,7 +242,8 @@ class DownloadMethods:
The output file path, directory, or stream-like object.
If the path exists and is a file, it will be overwritten.
If file is the type `bytes`, it will be downloaded in-memory
as a bytestring (e.g. ``file=bytes``).
and returned as a bytestring (i.e. ``file=bytes``, without
parentheses or quotes).
download_big (`bool`, optional):
Whether to use the big version of the available photos.
@ -231,11 +291,11 @@ class DownloadMethods:
if isinstance(photo, (types.UserProfilePhoto, types.ChatPhoto)):
dc_id = photo.dc_id
which = photo.photo_big if download_big else photo.photo_small
loc = types.InputPeerPhotoFileLocation(
peer=await self.get_input_entity(entity),
local_id=which.local_id,
volume_id=which.volume_id,
# min users can be used to download profile photos
# self.get_input_entity would otherwise not accept those
peer=utils.get_input_peer(entity, check_hash=False),
photo_id=photo.photo_id,
big=download_big
)
else:
@ -293,7 +353,8 @@ class DownloadMethods:
The output file path, directory, or stream-like object.
If the path exists and is a file, it will be overwritten.
If file is the type `bytes`, it will be downloaded in-memory
as a bytestring (e.g. ``file=bytes``).
and returned as a bytestring (i.e. ``file=bytes``, without
parentheses or quotes).
progress_callback (`callable`, optional):
A callback function accepting two parameters:
@ -309,13 +370,20 @@ class DownloadMethods:
The parameter should be an integer index between ``0`` and
``len(sizes)``. ``0`` will download the smallest thumbnail,
and ``len(sizes) - 1`` will download the largest thumbnail.
You can also use negative indices.
You can also use negative indices, which work the same as
they do in Python's `list`.
You can also pass the :tl:`PhotoSize` instance to use.
Alternatively, the thumb size type `str` may be used.
In short, use ``thumb=0`` if you want the smallest thumbnail
and ``thumb=-1`` if you want the largest thumbnail.
.. note::
The largest thumbnail may be a video instead of a photo,
as they are available since layer 116 and are bigger than
any of the photos.
Returns
`None` if no media was provided, or if it was Empty. On success
the file path is returned since it may differ from the one given.
@ -328,11 +396,27 @@ class DownloadMethods:
# or
path = await message.download_media()
await message.download_media(filename)
# Downloading to memory
blob = await client.download_media(message, bytes)
# Printing download progress
def callback(current, total):
print('Downloaded', current, 'out of', total,
'bytes: {:.2%}'.format(current / total))
await client.download_media(message, progress_callback=callback)
"""
# 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
if isinstance(message, types.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
@ -340,6 +424,11 @@ class DownloadMethods:
if isinstance(media, str):
media = utils.resolve_bot_file_id(media)
if isinstance(media, types.MessageService):
if isinstance(message.action,
types.MessageActionChatEditPhoto):
media = media.photo
if isinstance(media, types.MessageMediaWebPage):
if isinstance(media.webpage, types.WebPage):
media = media.webpage.document or media.webpage.photo
@ -350,7 +439,7 @@ class DownloadMethods:
)
elif isinstance(media, (types.MessageMediaDocument, types.Document)):
return await self._download_document(
media, file, date, thumb, progress_callback
media, file, date, thumb, progress_callback, msg_data
)
elif isinstance(media, types.MessageMediaContact) and thumb is None:
return self._download_contact(
@ -369,10 +458,17 @@ class DownloadMethods:
part_size_kb: float = None,
file_size: int = None,
progress_callback: 'hints.ProgressCallback' = None,
dc_id: int = None) -> typing.Optional[bytes]:
dc_id: int = None,
key: bytes = None,
iv: bytes = None) -> typing.Optional[bytes]:
"""
Low-level method to download files from their input location.
.. note::
Generally, you should instead use `download_media`.
This method is intended to be a bit more low-level.
Arguments
input_location (:tl:`InputFileLocation`):
The file location from which the file will be downloaded.
@ -403,6 +499,13 @@ class DownloadMethods:
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
Example
.. code-block:: python
@ -410,6 +513,31 @@ class DownloadMethods:
data = await client.download_file(input_file, bytes)
print(data[:16])
"""
return await self._download_file(
input_location,
file,
part_size_kb=part_size_kb,
file_size=file_size,
progress_callback=progress_callback,
dc_id=dc_id,
key=key,
iv=iv,
)
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,
cdn_redirect: types.upload.FileCdnRedirect = None
) -> typing.Optional[bytes]:
if not part_size_kb:
if not file_size:
part_size_kb = 64 # Reasonable default
@ -421,6 +549,9 @@ class DownloadMethods:
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()
@ -432,8 +563,10 @@ class DownloadMethods:
f = file
try:
async for chunk in self.iter_download(
input_location, request_size=part_size, dc_id=dc_id):
async for chunk in self._iter_download(
input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data, cdn_redirect=cdn_redirect):
if iv and key:
chunk = AES.decrypt_ige(chunk, key, iv)
r = f.write(chunk)
if inspect.isawaitable(r):
await r
@ -449,6 +582,20 @@ class DownloadMethods:
if in_memory:
return f.getvalue()
except _CdnRedirect as e:
self._log[__name__].info('FileCdnRedirect to CDN data center %s', e.cdn_redirect.dc_id)
return await self._download_file(
input_location=input_location,
file=file,
part_size_kb=part_size_kb,
file_size=file_size,
progress_callback=progress_callback,
dc_id=e.cdn_redirect.dc_id,
key=e.cdn_redirect.encryption_key,
iv=e.cdn_redirect.encryption_iv,
msg_data=msg_data,
cdn_redirect=e.cdn_redirect
)
finally:
if isinstance(file, str) or in_memory:
f.close()
@ -535,26 +682,44 @@ class DownloadMethods:
# Streaming `media` to an output file
# After the iteration ends, the sender is cleaned up
with open('photo.jpg', 'wb') as fd:
async for chunk client.iter_download(media):
async for chunk in client.iter_download(media):
fd.write(chunk)
# Fetching only the header of a file (32 bytes)
# You should manually close the iterator in this case.
#
# telethon.sync must be imported for this to work,
# and you must not be inside an "async def".
# "stream" is a common name for asynchronous generators,
# and iter_download will yield `bytes` (chunks of the file).
stream = client.iter_download(media, request_size=32)
header = next(stream)
stream.close()
header = await stream.__anext__() # "manual" version of `async for`
await stream.close()
assert len(header) == 32
# Fetching only the header, inside of an ``async def``
async def main():
stream = client.iter_download(media, request_size=32)
header = await stream.__anext__()
await stream.close()
assert len(header) == 32
"""
return self._iter_download(
file,
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,
cdn_redirect: types.upload.FileCdnRedirect = None
):
info = utils._get_file_info(file)
if info.dc_id is not None:
dc_id = info.dc_id
@ -583,7 +748,8 @@ class DownloadMethods:
if chunk_size == request_size \
and offset % MIN_CHUNK_SIZE == 0 \
and stride % 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)
@ -601,7 +767,9 @@ class DownloadMethods:
stride=stride,
chunk_size=chunk_size,
request_size=request_size,
file_size=file_size
file_size=file_size,
msg_data=msg_data,
cdn_redirect=cdn_redirect
)
# endregion
@ -610,12 +778,44 @@ class DownloadMethods:
@staticmethod
def _get_thumb(thumbs, thumb):
if not thumbs:
return None
# Seems Telegram has changed the order and put `PhotoStrippedSize`
# last while this is the smallest (layer 116). Ensure we have the
# sizes sorted correctly with a custom function.
def sort_thumbs(thumb):
if isinstance(thumb, types.PhotoStrippedSize):
return 1, len(thumb.bytes)
if isinstance(thumb, types.PhotoCachedSize):
return 1, len(thumb.bytes)
if isinstance(thumb, types.PhotoSize):
return 1, thumb.size
if isinstance(thumb, types.PhotoSizeProgressive):
return 1, max(thumb.sizes)
if isinstance(thumb, types.VideoSize):
return 2, thumb.size
# Empty size or invalid should go last
return 0, 0
thumbs = list(sorted(thumbs, key=sort_thumbs))
for i in reversed(range(len(thumbs))):
# :tl:`PhotoPathSize` is used for animated stickers preview, and the thumb is actually
# a SVG path of the outline. Users expect thumbnails to be JPEG files, so pretend this
# thumb size doesn't actually exist (#1655).
if isinstance(thumbs[i], types.PhotoPathSize):
thumbs.pop(i)
if thumb is None:
return thumbs[-1]
elif isinstance(thumb, int):
return thumbs[thumb]
elif isinstance(thumb, str):
return next((t for t in thumbs if t.type == thumb), None)
elif isinstance(thumb, (types.PhotoSize, types.PhotoCachedSize,
types.PhotoStrippedSize)):
types.PhotoStrippedSize, types.VideoSize)):
return thumb
else:
return None
@ -650,14 +850,24 @@ class DownloadMethods:
if not isinstance(photo, types.Photo):
return
size = self._get_thumb(photo.sizes, thumb)
# Include video sizes here (but they may be None so provide an empty list)
size = self._get_thumb(photo.sizes + (photo.video_sizes or []), thumb)
if not size or isinstance(size, types.PhotoSizeEmpty):
return
file = self._get_proper_filename(file, 'photo', '.jpg', date=date)
if isinstance(size, types.VideoSize):
file = self._get_proper_filename(file, 'video', '.mp4', date=date)
else:
file = self._get_proper_filename(file, 'photo', '.jpg', date=date)
if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)):
return self._download_cached_photo_size(size, file)
if isinstance(size, types.PhotoSizeProgressive):
file_size = max(size.sizes)
else:
file_size = size.size
result = await self.download_file(
types.InputPhotoFileLocation(
id=photo.id,
@ -666,7 +876,7 @@ class DownloadMethods:
thumb_size=size.type
),
file,
file_size=size.size,
file_size=file_size,
progress_callback=progress_callback
)
return result if file is bytes else file
@ -696,27 +906,30 @@ class DownloadMethods:
return kind, possible_names
async def _download_document(
self, document, file, date, thumb, progress_callback):
self, document, file, date, thumb, progress_callback, msg_data):
"""Specialized version of .download_media() for documents."""
if isinstance(document, types.MessageMediaDocument):
document = document.document
if not isinstance(document, types.Document):
return
kind, possible_names = self._get_kind_and_names(document.attributes)
file = self._get_proper_filename(
file, kind, utils.get_extension(document),
date=date, possible_names=possible_names
)
if thumb is None:
kind, possible_names = self._get_kind_and_names(document.attributes)
file = self._get_proper_filename(
file, kind, utils.get_extension(document),
date=date, possible_names=possible_names
)
size = None
else:
file = self._get_proper_filename(file, 'photo', '.jpg', date=date)
size = self._get_thumb(document.thumbs, thumb)
if not size or isinstance(size, types.PhotoSizeEmpty):
return
if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)):
return self._download_cached_photo_size(size, file)
result = await self.download_file(
result = await self._download_file(
types.InputDocumentFileLocation(
id=document.id,
access_hash=document.access_hash,
@ -725,7 +938,8 @@ class DownloadMethods:
),
file,
file_size=size.size if size else document.size,
progress_callback=progress_callback
progress_callback=progress_callback,
msg_data=msg_data,
)
return result if file is bytes else file
@ -752,22 +966,19 @@ class DownloadMethods:
'END:VCARD\n'
).format(f=first_name, l=last_name, p=phone_number).encode('utf-8')
file = cls._get_proper_filename(
file, 'contact', '.vcard',
possible_names=[first_name, phone_number, last_name]
)
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
f = file if hasattr(file, 'write') else open(file, 'wb')
try:
f.write(result)
finally:
# Only close the stream if we opened it
if isinstance(file, str):
if f != file:
f.close()
return file
@ -784,21 +995,20 @@ class DownloadMethods:
)
# TODO Better way to get opened handles of files and auto-close
in_memory = file is bytes
if in_memory:
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
)
if file is bytes:
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:
elif hasattr(file, 'write'):
f = file
else:
f = open(file, 'wb')
try:
with aiohttp.ClientSession() as session:
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:
@ -808,10 +1018,10 @@ class DownloadMethods:
break
f.write(chunk)
finally:
if isinstance(file, str) or file is bytes:
if f != file:
f.close()
return f.getvalue() if in_memory else file
return f.getvalue() if file is bytes else file
@staticmethod
def _get_proper_filename(file, kind, extension,

View File

@ -67,7 +67,7 @@ class MessageParseMethods:
entities[i].offset, entities[i].length,
await self.get_input_entity(user)
)
return True
return True
except (ValueError, TypeError):
return False
@ -83,10 +83,19 @@ class MessageParseMethods:
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 isinstance(e, types.MessageEntityTextUrl):
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
@ -123,7 +132,6 @@ class MessageParseMethods:
random_to_id = {}
id_to_message = {}
sched_to_message = {} # scheduled IDs may collide with normal IDs
for update in updates:
if isinstance(update, types.UpdateMessageID):
random_to_id[update.random_id] = update.id
@ -131,30 +139,49 @@ class MessageParseMethods:
elif isinstance(update, (
types.UpdateNewChannelMessage, types.UpdateNewMessage)):
update.message._finish_init(self, entities, input_chat)
id_to_message[update.message.id] = update.message
# 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):
if request.id == update.message.id:
update.message._finish_init(self, entities, input_chat)
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.to_id)):
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)
sched_to_message[update.message.id] = update.message
# 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,
to_id=utils.get_peer(request.peer),
peer_id=utils.get_peer(request.peer),
media=types.MessageMediaPoll(
poll=update.poll,
results=update.results
@ -166,22 +193,15 @@ class MessageParseMethods:
if request is None:
return id_to_message
# Use the scheduled mapping if we got a request with a scheduled message
#
# This breaks if the schedule date is too young, however, since the message
# is sent immediately, so have a fallback.
if getattr(request, 'schedule_date', None) is None:
mapping = id_to_message
opposite = {} # if there's no schedule it can never be scheduled
else:
mapping = sched_to_message
opposite = id_to_message # scheduled may be treated as normal, though
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
random_id = request if isinstance(request, (int, list)) else request.random_id
if not utils.is_list_like(random_id):
msg = mapping.get(random_to_id.get(random_id))
if not msg:
msg = opposite.get(random_to_id.get(random_id))
msg = id_to_message.get(random_to_id.get(random_id))
if not msg:
self._log[__name__].warning(
@ -190,22 +210,24 @@ class MessageParseMethods:
return msg
try:
return [mapping[random_to_id[rnd]] for rnd in random_id]
return [id_to_message[random_to_id[rnd]] for rnd in random_id]
except KeyError:
try:
return [opposite[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.
self._log[__name__].warning(
'Request %s had missing message mappings %s', request, result)
# 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 [
mapping.get(random_to_id.get(rnd))
or opposite.get(random_to_id.get(rnd))
for rnd in random_to_id
id_to_message.get(random_to_id[rnd])
if rnd in random_to_id
else None
for rnd in random_id
]
# endregion

View File

@ -1,6 +1,7 @@
import inspect
import itertools
import typing
import warnings
from .. import helpers, utils, errors, hints
from ..requestiter import RequestIter
@ -18,7 +19,8 @@ class _MessagesIter(RequestIter):
"""
async def _init(
self, entity, offset_id, min_id, max_id,
from_user, offset_date, add_offset, filter, search
from_user, offset_date, add_offset, filter, search, reply_to,
scheduled
):
# Note that entity being `None` will perform a global search.
if entity:
@ -57,34 +59,50 @@ class _MessagesIter(RequestIter):
if from_user:
from_user = await self.client.get_input_entity(from_user)
ty = helpers._entity_type(from_user)
if ty != helpers._EntityType.USER:
from_user = None # Ignore from_user unless it's a user
if 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` queries.
# If we want to perform global a search with `from_user` or `filter`,
# we have to perform a normal `messages.search`, *but* we can make the
# entity be `inputPeerEmpty`.
if not self.entity and (filter or from_user):
# `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 = types.InputPeerEmpty()
if filter is None:
filter = types.InputMessagesFilterEmpty()
else:
filter = filter() if isinstance(filter, type) else filter
if not self.entity:
self.request = functions.messages.SearchGlobalRequest(
q=search or '',
offset_rate=offset_date,
filter=filter,
min_date=None,
max_date=offset_date,
offset_rate=0,
offset_peer=types.InputPeerEmpty(),
offset_id=offset_id,
limit=1
)
elif search is not None or filter or from_user:
if filter is None:
filter = types.InputMessagesFilterEmpty()
elif scheduled:
self.request = functions.messages.GetScheduledHistoryRequest(
peer=entity,
hash=0
)
elif reply_to is not None:
self.request = functions.messages.GetRepliesRequest(
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, types.InputMessagesFilterEmpty) or from_user:
# Telegram completely ignores `from_id` in private chats
ty = helpers._entity_type(self.entity)
if ty == helpers._EntityType.USER:
@ -99,7 +117,7 @@ class _MessagesIter(RequestIter):
self.request = functions.messages.SearchRequest(
peer=self.entity,
q=search or '',
filter=filter() if isinstance(filter, type) else filter,
filter=filter,
min_date=None,
max_date=offset_date,
offset_id=offset_id,
@ -118,7 +136,8 @@ class _MessagesIter(RequestIter):
#
# Even better, using `filter` and `from_id` seems to always
# trigger `RPC_CALL_FAIL` which is "internal issues"...
if filter and offset_date and not search and not offset_id:
if not isinstance(filter, types.InputMessagesFilterEmpty) \
and offset_date and not search and not offset_id:
async for m in self.client.iter_messages(
self.entity, 1, offset_date=offset_date):
self.request.offset_id = m.id + 1
@ -171,7 +190,7 @@ class _MessagesIter(RequestIter):
messages = reversed(r.messages) if self.reverse else r.messages
for message in messages:
if (isinstance(message, types.MessageEmpty)
or self.from_id and message.from_id != self.from_id):
or self.from_id and message.sender_id != self.from_id):
continue
if not self._message_in_range(message):
@ -185,13 +204,30 @@ class _MessagesIter(RequestIter):
message._finish_init(self.client, entities, self.entity)
self.buffer.append(message)
if len(r.messages) < self.request.limit:
# Not a slice (using offset would return the same, with e.g. SearchGlobal).
if isinstance(r, types.messages.Messages):
return True
# Some channels are "buggy" and may return less messages than
# requested (apparently, the messages excluded are, for example,
# "not displayable due to local laws").
#
# This means it's not safe to rely on `len(r.messages) < req.limit` as
# the stop condition. Unfortunately more requests must be made.
#
# However we can still check if the highest ID is equal to or lower
# than the limit, in which case there won't be any more messages
# because the lowest message ID is 1.
#
# We also assume the API will always return, at least, one message if
# there is more to fetch.
if not r.messages or (not self.reverse and r.messages[0].id <= 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])
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
@ -217,7 +253,7 @@ class _MessagesIter(RequestIter):
return True
def _update_offset(self, last_message):
def _update_offset(self, last_message, response):
"""
After making the request, update its offset with the last message.
"""
@ -233,11 +269,16 @@ class _MessagesIter(RequestIter):
# (only for the first request), it's safe to just clear it off.
self.request.max_date = None
else:
# getHistory and searchGlobal call it offset_date
# getHistory, searchGlobal and getReplies call it offset_date
self.request.offset_date = last_message.date
if isinstance(self.request, functions.messages.SearchGlobalRequest):
self.request.offset_peer = last_message.input_chat
if last_message.input_chat:
self.request.offset_peer = last_message.input_chat
else:
self.request.offset_peer = types.InputPeerEmpty()
self.request.offset_rate = getattr(response, 'next_rate', 0)
class _IDsIter(RequestIter):
@ -270,7 +311,7 @@ class _IDsIter(RequestIter):
else:
r = await self.client(functions.messages.GetMessagesRequest(ids))
if self._entity:
from_id = await self.client.get_peer_id(self._entity)
from_id = await self.client._get_peer(self._entity)
if isinstance(r, types.messages.MessagesNotModified):
self.buffer.extend(None for _ in ids)
@ -289,7 +330,7 @@ class _IDsIter(RequestIter):
# arbitrary chats. Validate these unless ``from_id is None``.
for message in r.messages:
if isinstance(message, types.MessageEmpty) or (
from_id and message.chat_id != from_id):
from_id and message.peer_id != from_id):
self.buffer.append(None)
else:
message._finish_init(self.client, entities, self._entity)
@ -317,7 +358,9 @@ class MessageMethods:
from_user: 'hints.EntityLike' = None,
wait_time: float = None,
ids: 'typing.Union[int, typing.Sequence[int]]' = None,
reverse: bool = False
reverse: bool = False,
reply_to: int = None,
scheduled: bool = False
) -> 'typing.Union[_MessagesIter, _IDsIter]':
"""
Iterator over the messages for the given chat.
@ -384,8 +427,7 @@ class MessageMethods:
containing photos.
from_user (`entity`):
Only messages from this user will be returned.
This parameter will be ignored if it is not an user.
Only messages from this entity will be returned.
wait_time (`int`):
Wait time (in seconds) between different
@ -425,6 +467,30 @@ class MessageMethods:
You cannot use this if both `entity` and `ids` are `None`.
reply_to (`int`, optional):
If set to a message ID, the messages that reply to this ID
will be returned. This feature is also known as comments in
posts of broadcast channels, or viewing threads in groups.
This feature can only be used in broadcast channels and their
linked megagroups. Using it in a chat or private conversation
will result in ``telethon.errors.PeerIdInvalidError`` to occur.
When using this parameter, the ``filter`` and ``search``
parameters have no effect, since Telegram's API doesn't
support searching messages in replies.
.. note::
This feature is used to get replies to a message in the
*discussion* group. If the same broadcast channel sends
a message and replies to it itself, that reply will not
be included in the results.
scheduled (`bool`, optional):
If set to `True`, messages which are scheduled will be returned.
All other parameter will be ignored for this, except `entity`.
Yields
Instances of `Message <telethon.tl.custom.message.Message>`.
@ -451,6 +517,10 @@ class MessageMethods:
from telethon.tl.types import InputMessagesFilterPhotos
async for message in client.iter_messages(chat, filter=InputMessagesFilterPhotos):
print(message.photo)
# Getting comments from a post in a channel:
async for message in client.iter_messages(channel, reply_to=123):
print(message.chat.title, message.text)
"""
if ids is not None:
if not utils.is_list_like(ids):
@ -478,10 +548,14 @@ class MessageMethods:
offset_date=offset_date,
add_offset=add_offset,
filter=filter,
search=search
search=search,
reply_to=reply_to,
scheduled=scheduled
)
async def get_messages(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList':
async def get_messages(
self: 'TelegramClient', *args, **kwargs
) -> typing.Union['hints.TotalList', typing.Optional['types.Message']]:
"""
Same as `iter_messages()`, but returns a
`TotalList <telethon.helpers.TotalList>` instead.
@ -536,20 +610,42 @@ class MessageMethods:
# region Message sending/editing/deleting
async def _get_comment_data(
self: 'TelegramClient',
entity: 'hints.EntityLike',
message: 'typing.Union[int, types.Message]'
):
r = await self(functions.messages.GetDiscussionMessageRequest(
peer=entity,
msg_id=utils.get_message_id(message)
))
m = min(r.messages, key=lambda msg: msg.id)
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',
entity: 'hints.EntityLike',
message: 'hints.MessageLike' = '',
*,
reply_to: 'typing.Union[int, types.Message]' = None,
attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
parse_mode: typing.Optional[str] = (),
formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
link_preview: bool = True,
file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None,
thumb: 'hints.FileLike' = None,
force_document: bool = False,
clear_draft: bool = False,
buttons: 'hints.MarkupLike' = None,
buttons: typing.Optional['hints.MarkupLike'] = None,
silent: bool = None,
schedule: 'hints.DateLike' = None
background: bool = None,
supports_streaming: bool = False,
schedule: 'hints.DateLike' = None,
comment_to: 'typing.Union[int, types.Message]' = None,
nosound_video: bool = None,
send_as: typing.Optional['hints.EntityLike'] = None,
message_effect_id: typing.Optional[int] = None
) -> 'types.Message':
"""
Sends a message to the specified user, chat or channel.
@ -584,12 +680,19 @@ class MessageMethods:
Whether to reply to a message or not. If an integer is provided,
it should be the ID of the message that it should reply to.
attributes (`list`, optional):
Optional attributes that override the inferred ones, like
:tl:`DocumentAttributeFilename` and so on.
parse_mode (`object`, optional):
See the `TelegramClient.parse_mode
<telethon.client.messageparse.MessageParseMethods.parse_mode>`
property for allowed values. Markdown parsing will be used by
default.
formatting_entities (`list`, optional):
A list of message formatting entities. When provided, the ``parse_mode`` is ignored.
link_preview (`bool`, optional):
Should the link preview be shown?
@ -597,12 +700,22 @@ class MessageMethods:
Sends a message with a file attached (e.g. a photo,
video, audio or document). The ``message`` may be empty.
thumb (`str` | `bytes` | `file`, optional):
Optional JPEG thumbnail (for documents). **Telegram will
ignore this parameter** unless you pass a ``.jpg`` file!
The file must also be small in dimensions and in disk size.
Successful thumbnails were files below 20kB and 320x320px.
Width/height and dimensions/size ratios may be important.
For Telegram to accept a thumbnail, you must provide the
dimensions of the underlying media through ``attributes=``
with :tl:`DocumentAttributesVideo` or by installing the
optional ``hachoir`` dependency.
force_document (`bool`, optional):
Whether to send the given file as a document or not.
clear_draft (`bool`, optional):
Whether the existing draft should be cleared or not.
Has no effect when sending a file.
buttons (`list`, `custom.Button <telethon.tl.custom.button.Button>`, :tl:`KeyboardButton`):
The matrix (list of lists), row list or button to be shown
@ -623,11 +736,48 @@ class MessageMethods:
channel or not. Defaults to `False`, which means it will
notify them. Set it to `True` to alter this behaviour.
background (`bool`, optional):
Whether the message should be send in background.
supports_streaming (`bool`, optional):
Whether the sent video supports streaming or not. Note that
Telegram only recognizes as streamable some formats like MP4,
and others like AVI or MKV will not work. You should convert
these to MP4 before sending if you want them to be streamable.
Unsupported formats will result in ``VideoContentTypeError``.
schedule (`hints.DateLike`, optional):
If set, the message won't send immediately, and instead
it will be scheduled to be automatically sent at a later
time.
comment_to (`int` | `Message <telethon.tl.custom.message.Message>`, optional):
Similar to ``reply_to``, but replies in the linked group of a
broadcast channel instead (effectively leaving a "comment to"
the specified message).
This parameter takes precedence over ``reply_to``. If there is
no linked chat, `telethon.errors.sgIdInvalidError` is raised.
nosound_video (`bool`, optional):
Only applicable when sending a video file without an audio
track. If set to ``True``, the video will be displayed in
Telegram as a video. If set to ``False``, Telegram will attempt
to display the video as an animated gif. (It may still display
as a video due to other factors.) The value is ignored if set
on non-video files. This is set to ``True`` for albums, as gifs
cannot be sent in albums.
send_as (`entity`):
Unique identifier (int) or username (str) of the chat or channel to send the message as.
You can use this to send the message on behalf of a chat or channel where you have appropriate permissions.
Use the GetSendAs to return the list of message sender identifiers, which can be used to send messages in the chat,
This setting applies to the current message and will remain effective for future messages unless explicitly changed.
To set this behavior permanently for all messages, use SaveDefaultSendAs.
message_effect_id (`int`, optional):
Unique identifier of the message effect to be added to the message; for private chats only
Returns
The sent `custom.Message <telethon.tl.custom.message.Message>`.
@ -635,7 +785,7 @@ class MessageMethods:
.. code-block:: python
# Markdown is the default
await client.send_message('lonami', 'Thanks for the **Telethon** library!')
await client.send_message('me', 'Hello **world**!')
# Default to another parse mode
client.parse_mode = 'html'
@ -669,7 +819,7 @@ class MessageMethods:
# Matrix of inline buttons
await client.send_message(chat, 'Pick one from this grid', buttons=[
[Button.inline('Left'), Button.inline('Right')],
[Button.url('Check this site!', 'https://lonamiwebs.github.io')]
[Button.url('Check this site!', 'https://example.com')]
])
# Reply keyboard
@ -688,13 +838,27 @@ class MessageMethods:
await client.send_message(chat, 'Hi, future!', schedule=timedelta(minutes=5))
"""
if file is not None:
if isinstance(message, types.Message):
formatting_entities = formatting_entities or message.entities
message = message.message
return await self.send_file(
entity, file, caption=message, reply_to=reply_to,
parse_mode=parse_mode, force_document=force_document,
buttons=buttons
attributes=attributes, parse_mode=parse_mode,
force_document=force_document, thumb=thumb,
buttons=buttons, clear_draft=clear_draft, silent=silent,
schedule=schedule, supports_streaming=supports_streaming,
formatting_entities=formatting_entities,
comment_to=comment_to, background=background,
nosound_video=nosound_video,
send_as=send_as, message_effect_id=message_effect_id
)
entity = await self.get_input_entity(entity)
if comment_to is not None:
entity, reply_to = await self._get_comment_data(entity, comment_to)
else:
reply_to = utils.get_message_id(reply_to)
if isinstance(message, types.Message):
if buttons is None:
markup = message.reply_markup
@ -711,27 +875,34 @@ class MessageMethods:
message.media,
caption=message.message,
silent=silent,
background=background,
reply_to=reply_to,
buttons=markup,
entities=message.entities,
schedule=schedule
formatting_entities=message.entities,
parse_mode=None, # explicitly disable parse_mode to force using even empty formatting_entities
schedule=schedule,
send_as=send_as, message_effect_id=message_effect_id
)
request = functions.messages.SendMessageRequest(
peer=entity,
message=message.message or '',
silent=silent,
reply_to_msg_id=utils.get_message_id(reply_to),
background=background,
reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to),
reply_markup=markup,
entities=message.entities,
clear_draft=clear_draft,
no_webpage=not isinstance(
message.media, types.MessageMediaWebPage),
schedule_date=schedule
schedule_date=schedule,
send_as=await self.get_input_entity(send_as) if send_as else None,
effect=message_effect_id
)
message = message.message
else:
message, msg_ent = await self._parse_message_text(message, parse_mode)
if formatting_entities is None:
message, formatting_entities = await self._parse_message_text(message, parse_mode)
if not message:
raise ValueError(
'The message cannot be empty unless a file is provided'
@ -740,26 +911,31 @@ class MessageMethods:
request = functions.messages.SendMessageRequest(
peer=entity,
message=message,
entities=msg_ent,
entities=formatting_entities,
no_webpage=not link_preview,
reply_to_msg_id=utils.get_message_id(reply_to),
reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to),
clear_draft=clear_draft,
silent=silent,
background=background,
reply_markup=self.build_reply_markup(buttons),
schedule_date=schedule
schedule_date=schedule,
send_as=await self.get_input_entity(send_as) if send_as else None,
effect=message_effect_id
)
result = await self(request)
if isinstance(result, types.UpdateShortSentMessage):
message = types.Message(
id=result.id,
to_id=utils.get_peer(entity),
peer_id=await self._get_peer(entity),
message=message,
date=result.date,
out=result.out,
media=result.media,
entities=result.entities,
reply_markup=request.reply_markup
reply_markup=request.reply_markup,
ttl_period=result.ttl_period,
reply_to=request.reply_to
)
message._finish_init(self, {}, entity)
return message
@ -772,9 +948,13 @@ class MessageMethods:
messages: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]',
from_peer: 'hints.EntityLike' = None,
*,
background: bool = None,
with_my_score: bool = None,
silent: bool = None,
as_album: bool = None,
schedule: 'hints.DateLike' = None
schedule: 'hints.DateLike' = None,
drop_author: bool = None,
drop_media_captions: bool = None,
) -> 'typing.Sequence[types.Message]':
"""
Forwards the given messages to the specified entity.
@ -804,22 +984,26 @@ class MessageMethods:
the person has the chat muted). Set it to `True` to alter
this behaviour.
as_album (`bool`, optional):
Whether several image messages should be forwarded as an
album (grouped) or not. The default behaviour is to treat
albums specially and send outgoing requests with
``as_album=True`` only for the albums if message objects
are used. If IDs are used it will group by default.
background (`bool`, optional):
Whether the message should be forwarded in background.
In short, the default should do what you expect,
`True` will group always (even converting separate
images into albums), and `False` will never group.
with_my_score (`bool`, optional):
Whether forwarded should contain your game score.
as_album (`bool`, optional):
This flag no longer has any effect.
schedule (`hints.DateLike`, optional):
If set, the message(s) won't forward immediately, and
instead they will be scheduled to be automatically sent
at a later time.
drop_author (`bool`, optional):
Whether to forward messages without quoting the original author.
drop_media_captions (`bool`, optional):
Whether to strip captions from media. Setting this to `True` requires that `drop_author` also be set to `True`.
Returns
The list of forwarded `Message <telethon.tl.custom.message.Message>`,
or a single one if a list wasn't provided as input.
@ -846,6 +1030,9 @@ class MessageMethods:
# Forwarding as a copy
await client.send_message(chat, message)
"""
if as_album is not None:
warnings.warn('the as_album argument is deprecated and no longer has any effect')
single = not utils.is_list_like(messages)
if single:
messages = (messages,)
@ -858,44 +1045,24 @@ class MessageMethods:
else:
from_peer_id = None
def _get_key(m):
def get_key(m):
if isinstance(m, int):
if from_peer_id is not None:
return from_peer_id, None
return from_peer_id
raise ValueError('from_peer must be given if integer IDs are used')
elif isinstance(m, types.Message):
return m.chat_id, m.grouped_id
return m.chat_id
else:
raise TypeError('Cannot forward messages of type {}'.format(type(m)))
# We want to group outgoing chunks differently if we are "smart"
# about sending as album.
#
# Why? We need separate requests for ``as_album=True/False``, so
# if we want that behaviour, when we group messages to create the
# chunks, we need to consider the grouped ID too. But if we don't
# care about that, we don't need to consider it for creating the
# chunks, so we can make less requests.
if as_album is None:
get_key = _get_key
else:
def get_key(m):
return _get_key(m)[0] # Ignore grouped_id
sent = []
for chat_id, chunk in itertools.groupby(messages, key=get_key):
for _chat_id, chunk in itertools.groupby(messages, key=get_key):
chunk = list(chunk)
if isinstance(chunk[0], int):
chat = from_peer
grouped = True if as_album is None else as_album
else:
chat = await chunk[0].get_input_chat()
if as_album is None:
grouped = any(m.grouped_id is not None for m in chunk)
else:
grouped = as_album
chat = from_peer or await self.get_input_entity(chunk[0].peer_id)
chunk = [m.id for m in chunk]
req = functions.messages.ForwardMessagesRequest(
@ -903,11 +1070,11 @@ class MessageMethods:
id=chunk,
to_peer=entity,
silent=silent,
# Trying to send a single message as grouped will cause
# GROUPED_MEDIA_INVALID. If more than one message is forwarded
# (even without media...), this error goes away.
grouped=len(chunk) > 1 and grouped,
schedule_date=schedule
background=background,
with_my_score=with_my_score,
schedule_date=schedule,
drop_author=drop_author,
drop_media_captions=drop_media_captions
)
result = await self(req)
sent.extend(self._get_response_message(req, result, entity))
@ -917,14 +1084,18 @@ class MessageMethods:
async def edit_message(
self: 'TelegramClient',
entity: 'typing.Union[hints.EntityLike, types.Message]',
message: 'hints.MessageLike' = None,
message: 'typing.Union[int, types.Message, types.InputMessageID, str]' = None,
text: str = None,
*,
parse_mode: str = (),
attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
link_preview: bool = True,
file: 'hints.FileLike' = None,
thumb: 'hints.FileLike' = None,
force_document: bool = False,
buttons: 'hints.MarkupLike' = None,
buttons: typing.Optional['hints.MarkupLike'] = None,
supports_streaming: bool = False,
schedule: 'hints.DateLike' = None
) -> 'types.Message':
"""
@ -939,11 +1110,11 @@ class MessageMethods:
from it, so the next parameter will be assumed to be the
message text.
You may also pass a :tl:`InputBotInlineMessageID`,
You may also pass a :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64`,
which is the only way to edit messages that were sent
after the user selects an inline query result.
message (`int` | `Message <telethon.tl.custom.message.Message>` | `str`):
message (`int` | `Message <telethon.tl.custom.message.Message>` | :tl:`InputMessageID` | `str`):
The ID of the message (or `Message
<telethon.tl.custom.message.Message>` itself) to be edited.
If the `entity` was a `Message
@ -960,6 +1131,13 @@ class MessageMethods:
property for allowed values. Markdown parsing will be used by
default.
attributes (`list`, optional):
Optional attributes that override the inferred ones, like
:tl:`DocumentAttributeFilename` and so on.
formatting_entities (`list`, optional):
A list of message formatting entities. When provided, the ``parse_mode`` is ignored.
link_preview (`bool`, optional):
Should the link preview be shown?
@ -967,6 +1145,17 @@ class MessageMethods:
The file object that should replace the existing media
in the message.
thumb (`str` | `bytes` | `file`, optional):
Optional JPEG thumbnail (for documents). **Telegram will
ignore this parameter** unless you pass a ``.jpg`` file!
The file must also be small in dimensions and in disk size.
Successful thumbnails were files below 20kB and 320x320px.
Width/height and dimensions/size ratios may be important.
For Telegram to accept a thumbnail, you must provide the
dimensions of the underlying media through ``attributes=``
with :tl:`DocumentAttributesVideo` or by installing the
optional ``hachoir`` dependency.
force_document (`bool`, optional):
Whether to send the given file as a document or not.
@ -976,6 +1165,13 @@ class MessageMethods:
you have signed in as a bot. You can also pass your own
:tl:`ReplyMarkup` here.
supports_streaming (`bool`, optional):
Whether the sent video supports streaming or not. Note that
Telegram only recognizes as streamable some formats like MP4,
and others like AVI or MKV will not work. You should convert
these to MP4 before sending if you want them to be streamable.
Unsupported formats will result in ``VideoContentTypeError``.
schedule (`hints.DateLike`, optional):
If set, the message won't be edited immediately, and instead
it will be scheduled to be automatically edited at a later
@ -986,7 +1182,7 @@ class MessageMethods:
Returns
The edited `Message <telethon.tl.custom.message.Message>`,
unless `entity` was a :tl:`InputBotInlineMessageID` in which
unless `entity` was a :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64` in which
case this method returns a boolean.
Raises
@ -1012,27 +1208,42 @@ class MessageMethods:
# or
await client.edit_message(message, 'hello!!!')
"""
if isinstance(entity, types.InputBotInlineMessageID):
text = message
if isinstance(entity, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)):
text = text or message
message = entity
elif isinstance(entity, types.Message):
text = message # Shift the parameters to the right
message = entity
entity = entity.to_id
entity = entity.peer_id
text, msg_entities = await self._parse_message_text(text, parse_mode)
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(entity, types.InputBotInlineMessageID):
return await self(functions.messages.EditInlineBotMessageRequest(
if isinstance(entity, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)):
request = functions.messages.EditInlineBotMessageRequest(
id=entity,
message=text,
no_webpage=not link_preview,
entities=msg_entities,
entities=formatting_entities,
media=media,
reply_markup=self.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.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_entity(entity)
request = functions.messages.EditMessageRequest(
@ -1040,13 +1251,12 @@ class MessageMethods:
id=utils.get_message_id(message),
message=text,
no_webpage=not link_preview,
entities=msg_entities,
entities=formatting_entities,
media=media,
reply_markup=self.build_reply_markup(buttons),
schedule_date=schedule
)
msg = self._get_response_message(request, await self(request), entity)
await self._cache_media(msg, file, file_handle, image=image)
return msg
async def delete_messages(
@ -1108,8 +1318,14 @@ class MessageMethods:
else int(m) for m in message_ids
)
entity = await self.get_input_entity(entity) if entity else None
if helpers._entity_type(entity) == helpers._EntityType.CHANNEL:
if entity:
entity = await self.get_input_entity(entity)
ty = helpers._entity_type(entity)
else:
# no entity (None), set a value that's not a channel for private delete
ty = helpers._EntityType.USER
if ty == helpers._EntityType.CHANNEL:
return await self([functions.channels.DeleteMessagesRequest(
entity, list(c)) for c in utils.chunks(message_ids)])
else:
@ -1126,7 +1342,8 @@ class MessageMethods:
message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None,
*,
max_id: int = None,
clear_mentions: bool = False) -> bool:
clear_mentions: bool = False,
clear_reactions: bool = False) -> bool:
"""
Marks messages as read and optionally clears mentions.
@ -1136,6 +1353,10 @@ class MessageMethods:
If neither message nor maximum ID are provided, all messages will be
marked as read by assuming that ``max_id = 0``.
If a message or maximum ID is provided, all the messages up to and
including such ID will be marked as read (for all messages whose ID
max_id).
See also `Message.mark_read() <telethon.tl.custom.message.Message.mark_read>`.
Arguments
@ -1146,8 +1367,8 @@ class MessageMethods:
Either a list of messages or a single message.
max_id (`int`):
Overrides messages, until which message should the
acknowledge should be sent.
Until which message should the read acknowledge be sent for.
This has priority over the ``message`` parameter.
clear_mentions (`bool`):
Whether the mention badge should be cleared (so that
@ -1156,6 +1377,13 @@ class MessageMethods:
If no message is provided, this will be the only action
taken.
clear_reactions (`bool`):
Whether the reactions badge should be cleared (so that
there are no more reaction notifications) or not for the given entity.
If no message is provided, this will be the only action
taken.
Example
.. code-block:: python
@ -1178,6 +1406,10 @@ class MessageMethods:
entity = await self.get_input_entity(entity)
if clear_mentions:
await self(functions.messages.ReadMentionsRequest(entity))
if max_id is None and not clear_reactions:
return True
if clear_reactions:
await self(functions.messages.ReadReactionsRequest(entity))
if max_id is None:
return True
@ -1196,10 +1428,11 @@ class MessageMethods:
entity: 'hints.EntityLike',
message: 'typing.Optional[hints.MessageIDLike]',
*,
notify: bool = False
notify: bool = False,
pm_oneside: bool = False
):
"""
Pins or unpins a message in a chat.
Pins a message in a chat.
The default behaviour is to *not* notify members, unlike the
official applications.
@ -1212,11 +1445,16 @@ class MessageMethods:
message (`int` | `Message <telethon.tl.custom.message.Message>`):
The message or the message ID to pin. If it's
`None`, the message will be unpinned instead.
`None`, all messages will be unpinned instead.
notify (`bool`, optional):
Whether the pin should notify people or not.
pm_oneside (`bool`, optional):
Whether the message should be pinned for everyone or not.
By default it has the opposite behaviour of official clients,
and it will pin the message for both sides, in private chats.
Example
.. code-block:: python
@ -1224,13 +1462,63 @@ class MessageMethods:
message = await client.send_message(chat, 'Pinotifying is fun!')
await client.pin_message(chat, message, notify=True)
"""
return await self._pin(entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside)
async def unpin_message(
self: 'TelegramClient',
entity: 'hints.EntityLike',
message: 'typing.Optional[hints.MessageIDLike]' = None,
*,
notify: bool = False
):
"""
Unpins a message in a chat.
If no message ID is specified, all pinned messages will be unpinned.
See also `Message.unpin() <telethon.tl.custom.message.Message.unpin>`.
Arguments
entity (`entity`):
The chat where the message should be pinned.
message (`int` | `Message <telethon.tl.custom.message.Message>`):
The message or the message ID to unpin. If it's
`None`, all messages will be unpinned instead.
Example
.. code-block:: python
# Unpin all messages from a chat
await client.unpin_message(chat)
"""
return await self._pin(entity, 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_entity(entity)
await self(functions.messages.UpdatePinnedMessageRequest(
if message <= 0: # old behaviour accepted negative IDs to unpin
await self(functions.messages.UnpinAllMessagesRequest(entity))
return
request = functions.messages.UpdatePinnedMessageRequest(
peer=entity,
id=message,
silent=not notify
))
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)
# endregion

View File

@ -1,32 +1,68 @@
import abc
import inspect
import re
import asyncio
import collections
import logging
import platform
import sys
import time
import typing
import datetime
import pathlib
from .. import version, helpers, __name__ as __base_name__
from .. import utils, version, helpers, __name__ as __base_name__
from ..crypto import rsa
from ..entitycache import EntityCache
from ..extensions import markdown
from ..network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy
from ..sessions import Session, SQLiteSession, MemorySession
from ..statecache import StateCache
from ..tl import TLObject, functions, types
from ..tl import functions, types
from ..tl.alltlobjects import LAYER
from .._updates import MessageBox, EntityCache as MbEntityCache, SessionState, ChannelState, Entity, EntityType
DEFAULT_DC_ID = 4
DEFAULT_DC_ID = 2
DEFAULT_IPV4_IP = '149.154.167.51'
DEFAULT_IPV6_IP = '[2001:67c:4e8:f002::a]'
DEFAULT_IPV6_IP = '2001:67c:4e8:f002::a'
DEFAULT_PORT = 443
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
__default_log__ = logging.getLogger(__base_name__)
__default_log__.addHandler(logging.NullHandler())
_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`?
@ -55,7 +91,7 @@ class TelegramBaseClient(abc.ABC):
The API ID you obtained from https://my.telegram.org.
api_hash (`str`):
The API ID you obtained from https://my.telegram.org.
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
@ -75,6 +111,11 @@ class TelegramBaseClient(abc.ABC):
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
@ -125,13 +166,19 @@ class TelegramBaseClient(abc.ABC):
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 ``platform.node()``.
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.system()``.
Defaults to ``platform.uname().release`` stripped of everything ahead of -.
app_version (`str`, optional):
"App version" to be sent when creating the initial connection.
@ -146,13 +193,37 @@ class TelegramBaseClient(abc.ABC):
Defaults to `lang_code`.
loop (`asyncio.AbstractEventLoop`, optional):
Asyncio event loop to use. Defaults to `asyncio.get_event_loop()`
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
@ -166,39 +237,44 @@ class TelegramBaseClient(abc.ABC):
def __init__(
self: 'TelegramClient',
session: 'typing.Union[str, Session]',
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,
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):
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
self._loop = loop or asyncio.get_event_loop()
if isinstance(base_logger, str):
base_logger = logging.getLogger(base_logger)
elif not isinstance(base_logger, logging.Logger):
base_logger = __default_log__
base_logger = _base_log
class _Loggers(dict):
def __missing__(self, key):
@ -210,9 +286,9 @@ class TelegramBaseClient(abc.ABC):
self._log = _Loggers()
# Determine what session object we have
if isinstance(session, str) or session is None:
if isinstance(session, (str, pathlib.Path)):
try:
session = SQLiteSession(session)
session = SQLiteSession(str(session))
except ImportError:
import warnings
warnings.warn(
@ -223,24 +299,17 @@ class TelegramBaseClient(abc.ABC):
'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.'
)
# ':' in session.server_address is True if it's an IPv6 address
if (not session.server_address or
(':' in session.server_address) != use_ipv6):
session.set_dc(
DEFAULT_DC_ID,
DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP,
DEFAULT_PORT
)
self.flood_sleep_threshold = flood_sleep_threshold
# TODO Use AsyncClassWrapper(session)
# ChatGetter and SenderGetter can use the in-memory _entity_cache
# 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
@ -248,7 +317,6 @@ class TelegramBaseClient(abc.ABC):
# TODO Session should probably return all cached
# info of entities, not just the input versions
self.session = session
self._entity_cache = EntityCache()
self.api_id = int(api_id)
self.api_hash = api_hash
@ -259,21 +327,34 @@ class TelegramBaseClient(abc.ABC):
# 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)):
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__
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
@ -285,67 +366,63 @@ class TelegramBaseClient(abc.ABC):
# Used on connection. Capture the variables in a lambda since
# exporting clients need to create this InvokeWithLayerRequest.
system = platform.uname()
self._init_with = lambda x: functions.InvokeWithLayerRequest(
LAYER, functions.InitConnectionRequest(
api_id=self.api_id,
device_model=device_model or system.system or 'Unknown',
system_version=system_version or system.release 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=x,
proxy=init_proxy
)
)
self._sender = MTProtoSender(
self.session.auth_key, self._loop,
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,
update_callback=self._handle_update,
auto_reconnect_callback=self._handle_auto_reconnect
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: (n, MTProtoSender)}`` for all borrowed senders,
# being ``n`` the amount of borrows a given sender has; once ``n``
# reaches ``0`` it should be disconnected and removed.
# Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders
self._borrowed_senders = {}
self._borrow_sender_lock = asyncio.Lock(loop=self._loop)
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._channel_pts = {}
self._no_updates = not receive_updates
if sequential_updates:
self._updates_queue = asyncio.Queue(loop=self._loop)
self._dispatching_updates_queue = asyncio.Event(loop=self._loop)
else:
# Use a set of pending instead of a queue so we can properly
# terminate all pending updates on disconnect.
self._updates_queue = set()
self._dispatching_updates_queue = None
# 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
# Update state (for catching up after a disconnection)
# TODO Get state from channels too
self._state_cache = StateCache(
self.session.get_update_state(0), self._log)
# 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
@ -355,13 +432,29 @@ class TelegramBaseClient(abc.ABC):
self._phone = None
self._tos = None
# Sometimes we need to know who we are, cache the self peer
self._self_input_peer = None
self._bot = 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
@ -375,7 +468,7 @@ class TelegramBaseClient(abc.ABC):
.. code-block:: python
# Download media in the background
task = client.loop_create_task(message.download_media())
task = client.loop.create_task(message.download_media())
# Do some work
...
@ -383,7 +476,7 @@ class TelegramBaseClient(abc.ABC):
# Join the task (wait for it to complete)
await task
"""
return self._loop
return helpers.get_running_loop()
@property
def disconnected(self: 'TelegramClient') -> asyncio.Future:
@ -436,24 +529,86 @@ class TelegramBaseClient(abc.ABC):
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,
loop=self._loop,
loggers=self._log,
proxy=self._proxy
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
self.session.save()
await utils.maybe_async(self.session.save())
await self._sender.send(self._init_with(
functions.help.GetConfigRequest()))
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
self._updates_handle = self._loop.create_task(self._update_loop())
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:
"""
@ -478,17 +633,27 @@ class TelegramBaseClient(abc.ABC):
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():
return self._disconnect_coro()
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())
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:
@ -497,29 +662,90 @@ class TelegramBaseClient(abc.ABC):
# 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._dispatching_updates_queue is None and self._updates_queue:
for task in self._updates_queue:
if self._event_handler_tasks:
for task in self._event_handler_tasks:
task.cancel()
await asyncio.wait(self._updates_queue, loop=self._loop)
self._updates_queue.clear()
await asyncio.wait(self._event_handler_tasks)
self._event_handler_tasks.clear()
pts, date = self._state_cache[None]
if pts and date:
self.session.set_update_state(0, types.updates.State(
pts=pts,
qts=0,
date=date,
seq=0,
unread_count=0
))
await self._save_states_and_entities()
self.session.close()
await utils.maybe_async(self.session.close())
async def _disconnect(self: 'TelegramClient'):
"""
@ -530,7 +756,8 @@ class TelegramBaseClient(abc.ABC):
"""
await self._sender.disconnect()
await helpers._cancel(self._log[__name__],
updates_handle=self._updates_handle)
updates_handle=self._updates_handle,
keepalive_handle=self._keepalive_handle)
async def _switch_dc(self: 'TelegramClient', new_dc):
"""
@ -539,22 +766,22 @@ class TelegramBaseClient(abc.ABC):
self._log[__name__].info('Reconnecting to new data center %s', new_dc)
dc = await self._get_dc(new_dc)
self.session.set_dc(dc.id, dc.ip_address, dc.port)
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
self.session.save()
await utils.maybe_async(self.session.save())
await self._disconnect()
return await self.connect()
def _auth_key_callback(self: 'TelegramClient', auth_key):
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
self.session.save()
await utils.maybe_async(self.session.save())
# endregion
@ -569,13 +796,27 @@ class TelegramBaseClient(abc.ABC):
if cdn and not self._cdn_config:
cls._cdn_config = await self(functions.help.GetCdnConfigRequest())
for pk in cls._cdn_config.public_keys:
rsa.add_key(pk.public_key)
if pk.dc_id == dc_id:
rsa.add_key(pk.public_key, old=False)
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
)
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):
"""
@ -589,21 +830,19 @@ class TelegramBaseClient(abc.ABC):
#
# If one were to do that, Telegram would reset the connection
# with no further clues.
sender = MTProtoSender(None, self._loop, loggers=self._log)
sender = MTProtoSender(None, loggers=self._log)
await sender.connect(self._connection(
dc.ip_address,
dc.port,
dc.id,
loop=self._loop,
loggers=self._log,
proxy=self._proxy
proxy=self._proxy,
local_addr=self._local_addr
))
self._log[__name__].info('Exporting authorization for data center %s',
dc)
self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc)
auth = await self(functions.auth.ExportAuthorizationRequest(dc_id))
req = self._init_with(functions.auth.ImportAuthorizationRequest(
id=auth.id, bytes=auth.bytes
))
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
@ -616,24 +855,28 @@ class TelegramBaseClient(abc.ABC):
Once its job is over it should be `_return_exported_sender`.
"""
async with self._borrow_sender_lock:
n, sender = self._borrowed_senders.get(dc_id, (0, None))
if not sender:
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
elif not n:
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,
loop=self._loop,
loggers=self._log,
proxy=self._proxy
proxy=self._proxy,
local_addr=self._local_addr
))
self._borrowed_senders[dc_id] = (n + 1, sender)
return sender
state.add_borrow()
return sender
async def _return_exported_sender(self: 'TelegramClient', sender):
"""
@ -641,39 +884,50 @@ class TelegramBaseClient(abc.ABC):
been returned, the sender is cleanly disconnected.
"""
async with self._borrow_sender_lock:
dc_id = sender.dc_id
n, _ = self._borrowed_senders[dc_id]
n -= 1
self._borrowed_senders[dc_id] = (n, sender)
if not n:
self._log[__name__].info(
'Disconnecting borrowed sender for DC %d', dc_id)
await sender.disconnect()
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"""
# TODO Implement
raise NotImplementedError
session = self._exported_sessions.get(cdn_redirect.dc_id)
if not session:
dc = await self._get_dc(cdn_redirect.dc_id, cdn=True)
session = self.session.clone()
await session.set_dc(dc.id, dc.ip_address, dc.port)
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 = TelegramBaseClient(
client = self.__class__(
session, self.api_id, self.api_hash,
proxy=self._sender.connection.conn.proxy,
timeout=self._sender.connection.get_timeout()
proxy=self._proxy,
timeout=self._timeout,
loop=self.loop
)
# This will make use of the new RSA keys for this specific CDN.
#
# We won't be calling GetConfigRequest because it's only called
# when needed by ._get_dc, and also it's static so it's likely
# set already. Avoid invoking non-CDN methods by not syncing updates.
client.connect(_sync_updates=False)
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
@ -695,16 +949,17 @@ class TelegramBaseClient(abc.ABC):
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 _handle_update(self: 'TelegramClient', update):
raise NotImplementedError
@abc.abstractmethod
def _update_loop(self: 'TelegramClient'):
raise NotImplementedError

View File

@ -1,17 +1,30 @@
import asyncio
import inspect
import itertools
import random
import sys
import time
import traceback
import typing
import logging
import warnings
from collections import deque
import sqlite3
from .. import events, utils, errors
from ..events.common import EventBuilder, EventCommon
from ..tl import types, functions
from .._updates import GapError, PrematureEndReason
from ..helpers import get_running_loop
from ..version import __version__
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
Callback = typing.Callable[[typing.Any], typing.Any]
class UpdateMethods:
# region Public methods
@ -20,18 +33,34 @@ class UpdateMethods:
try:
# Make a high-level request to notify that we want updates
await self(functions.updates.GetStateRequest())
return await self.disconnected
result = await self.disconnected
if self._updates_error is not None:
raise self._updates_error
return result
except KeyboardInterrupt:
pass
finally:
await self.disconnect()
async def set_receive_updates(self: 'TelegramClient', receive_updates):
"""
Change the value of `receive_updates`.
This is an `async` method, because in order for Telegram to start
sending updates again, a request must be made.
"""
self._no_updates = not receive_updates
if receive_updates:
await self(functions.updates.GetStateRequest())
def run_until_disconnected(self: 'TelegramClient'):
"""
Runs the event loop until the library is disconnected.
It also notifies Telegram that we want to receive updates
as described in https://core.telegram.org/api/updates.
If an unexpected error occurs during update handling,
the client will disconnect and said error will be raised.
Manual disconnections can be made by calling `disconnect()
<telethon.client.telegrambaseclient.TelegramBaseClient.disconnect>`
@ -100,7 +129,7 @@ class UpdateMethods:
def add_event_handler(
self: 'TelegramClient',
callback: callable,
callback: Callback,
event: EventBuilder = None):
"""
Registers a new event handler callback.
@ -149,7 +178,7 @@ class UpdateMethods:
def remove_event_handler(
self: 'TelegramClient',
callback: callable,
callback: Callback,
event: EventBuilder = None) -> int:
"""
Inverse operation of `add_event_handler()`.
@ -187,7 +216,7 @@ class UpdateMethods:
return found
def list_event_handlers(self: 'TelegramClient')\
-> 'typing.Sequence[typing.Tuple[callable, EventBuilder]]':
-> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]':
"""
Lists all registered event handlers.
@ -220,112 +249,246 @@ class UpdateMethods:
await client.catch_up()
"""
pts, date = self._state_cache[None]
if not pts:
return
self.session.catching_up = True
try:
while True:
d = await self(functions.updates.GetDifferenceRequest(
pts, date, 0
))
if isinstance(d, (types.updates.DifferenceSlice,
types.updates.Difference)):
if isinstance(d, types.updates.Difference):
state = d.state
else:
state = d.intermediate_state
pts, date = state.pts, state.date
self._handle_update(types.Updates(
users=d.users,
chats=d.chats,
date=state.date,
seq=state.seq,
updates=d.other_updates + [
types.UpdateNewMessage(m, 0, 0)
for m in d.new_messages
]
))
# TODO Implement upper limit (max_pts)
# We don't want to fetch updates we already know about.
#
# We may still get duplicates because the Difference
# contains a lot of updates and presumably only has
# the state for the last one, but at least we don't
# unnecessarily fetch too many.
#
# updates.getDifference's pts_total_limit seems to mean
# "how many pts is the request allowed to return", and
# if there is more than that, it returns "too long" (so
# there would be duplicate updates since we know about
# some). This can be used to detect collisions (i.e.
# it would return an update we have already seen).
else:
if isinstance(d, types.updates.DifferenceEmpty):
date = d.date
elif isinstance(d, types.updates.DifferenceTooLong):
pts = d.pts
break
except (ConnectionError, asyncio.CancelledError):
pass
finally:
# TODO Save new pts to session
self._state_cache._pts_date = (pts, date)
self.session.catching_up = False
await self._updates_queue.put(types.UpdatesTooLong())
# endregion
# region Private methods
# It is important to not make _handle_update async because we rely on
# the order that the updates arrive in to update the pts and date to
# be always-increasing. There is also no need to make this async.
def _handle_update(self: 'TelegramClient', update):
self.session.process_entities(update)
self._entity_cache.add(update)
if isinstance(update, (types.Updates, types.UpdatesCombined)):
entities = {utils.get_peer_id(x): x for x in
itertools.chain(update.users, update.chats)}
for u in update.updates:
self._process_update(u, update.updates, entities=entities)
elif isinstance(update, types.UpdateShort):
self._process_update(update.update, None)
else:
self._process_update(update, None)
self._state_cache.update(update)
def _process_update(self: 'TelegramClient', update, others, entities=None):
update._entities = entities or {}
# This part is somewhat hot so we don't bother patching
# update with channel ID/its state. Instead we just pass
# arguments which is faster.
channel_id = self._state_cache.get_channel_id(update)
args = (update, others, channel_id, self._state_cache[channel_id])
if self._dispatching_updates_queue is None:
task = self._loop.create_task(self._dispatch_update(*args))
self._updates_queue.add(task)
task.add_done_callback(lambda _: self._updates_queue.discard(task))
else:
self._updates_queue.put_nowait(args)
if not self._dispatching_updates_queue.is_set():
self._dispatching_updates_queue.set()
self._loop.create_task(self._dispatch_queue_updates())
self._state_cache.update(update)
async def _update_loop(self: 'TelegramClient'):
# If the MessageBox is not empty, the account had to be logged-in to fill in its state.
# This flag is used to propagate the "you got logged-out" error up (but getting logged-out
# can only happen if it was once logged-in).
was_once_logged_in = self._authorized is True or not self._message_box.is_empty()
self._updates_error = None
try:
if self._catch_up:
# User wants to catch up as soon as the client is up and running,
# so this is the best place to do it.
await self.catch_up()
updates_to_dispatch = deque()
while self.is_connected():
if updates_to_dispatch:
if self._sequential_updates:
await self._dispatch_update(updates_to_dispatch.popleft())
else:
while updates_to_dispatch:
# TODO if _dispatch_update fails for whatever reason, it's not logged! this should be fixed
task = self.loop.create_task(self._dispatch_update(updates_to_dispatch.popleft()))
self._event_handler_tasks.add(task)
task.add_done_callback(self._event_handler_tasks.discard)
continue
if len(self._mb_entity_cache) >= self._entity_cache_limit:
self._log[__name__].info(
'In-memory entity cache limit reached (%s/%s), flushing to session',
len(self._mb_entity_cache),
self._entity_cache_limit
)
await self._save_states_and_entities()
self._mb_entity_cache.retain(lambda id: id == self._mb_entity_cache.self_id or id in self._message_box.map)
if len(self._mb_entity_cache) >= self._entity_cache_limit:
warnings.warn('in-memory entities exceed entity_cache_limit after flushing; consider setting a larger limit')
self._log[__name__].info(
'In-memory entity cache at %s/%s after flushing to session',
len(self._mb_entity_cache),
self._entity_cache_limit
)
get_diff = self._message_box.get_difference()
if get_diff:
self._log[__name__].debug('Getting difference for account updates')
try:
diff = await self(get_diff)
except (
errors.ServerError,
errors.TimedOutError,
errors.FloodWaitError,
ValueError
) as e:
# Telegram is having issues
self._log[__name__].info('Cannot get difference since Telegram is having issues: %s', type(e).__name__)
self._message_box.end_difference()
continue
except (errors.UnauthorizedError, errors.AuthKeyError) as e:
# Not logged in or broken authorization key, can't get difference
self._log[__name__].info('Cannot get difference since the account is not logged in: %s', type(e).__name__)
self._message_box.end_difference()
if was_once_logged_in:
self._updates_error = e
await self.disconnect()
break
continue
except (errors.TypeNotFoundError, sqlite3.OperationalError) as e:
# User is likely doing weird things with their account or session and Telegram gets confused as to what layer they use
self._log[__name__].warning('Cannot get difference since the account is likely misusing the session: %s', e)
self._message_box.end_difference()
self._updates_error = e
await self.disconnect()
break
except OSError as e:
# Network is likely down, but it's unclear for how long.
# If disconnect is called this task will be cancelled along with the sleep.
# If disconnect is not called, getting difference should be retried after a few seconds.
self._log[__name__].info('Cannot get difference since the network is down: %s: %s', type(e).__name__, e)
await asyncio.sleep(5)
continue
updates, users, chats = self._message_box.apply_difference(diff, self._mb_entity_cache)
if updates:
self._log[__name__].info('Got difference for account updates')
_preprocess_updates = await self._preprocess_updates(updates, users, chats)
updates_to_dispatch.extend(_preprocess_updates)
continue
get_diff = self._message_box.get_channel_difference(self._mb_entity_cache)
if get_diff:
self._log[__name__].debug('Getting difference for channel %s updates', get_diff.channel.channel_id)
try:
diff = await self(get_diff)
except (errors.UnauthorizedError, errors.AuthKeyError) as e:
# Not logged in or broken authorization key, can't get difference
self._log[__name__].warning(
'Cannot get difference for channel %s since the account is not logged in: %s',
get_diff.channel.channel_id, type(e).__name__
)
self._message_box.end_channel_difference(
get_diff,
PrematureEndReason.TEMPORARY_SERVER_ISSUES,
self._mb_entity_cache
)
if was_once_logged_in:
self._updates_error = e
await self.disconnect()
break
continue
except (errors.TypeNotFoundError, sqlite3.OperationalError) as e:
self._log[__name__].warning(
'Cannot get difference for channel %s since the account is likely misusing the session: %s',
get_diff.channel.channel_id, e
)
self._message_box.end_channel_difference(
get_diff,
PrematureEndReason.TEMPORARY_SERVER_ISSUES,
self._mb_entity_cache
)
self._updates_error = e
await self.disconnect()
break
except (
errors.PersistentTimestampOutdatedError,
errors.PersistentTimestampInvalidError,
errors.ServerError,
errors.TimedOutError,
errors.FloodWaitError,
ValueError
) as e:
# According to Telegram's docs:
# "Channel internal replication issues, try again later (treat this like an RPC_CALL_FAIL)."
# We can treat this as "empty difference" and not update the local pts.
# Then this same call will be retried when another gap is detected or timeout expires.
#
# Another option would be to literally treat this like an RPC_CALL_FAIL and retry after a few
# seconds, but if Telegram is having issues it's probably best to wait for it to send another
# update (hinting it may be okay now) and retry then.
#
# This is a bit hacky because MessageBox doesn't really have a way to "not update" the pts.
# Instead we manually extract the previously-known pts and use that.
#
# For PersistentTimestampInvalidError:
# Somehow our pts is either too new or the server does not know about this.
# We treat this as PersistentTimestampOutdatedError for now.
# TODO investigate why/when this happens and if this is the proper solution
self._log[__name__].warning(
'Getting difference for channel updates %s caused %s;'
' ending getting difference prematurely until server issues are resolved',
get_diff.channel.channel_id, type(e).__name__
)
self._message_box.end_channel_difference(
get_diff,
PrematureEndReason.TEMPORARY_SERVER_ISSUES,
self._mb_entity_cache
)
continue
except (errors.ChannelPrivateError, errors.ChannelInvalidError):
# Timeout triggered a get difference, but we have been banned in the channel since then.
# Because we can no longer fetch updates from this channel, we should stop keeping track
# of it entirely.
self._log[__name__].info(
'Account is now banned in %d so we can no longer fetch updates from it',
get_diff.channel.channel_id
)
self._message_box.end_channel_difference(
get_diff,
PrematureEndReason.BANNED,
self._mb_entity_cache
)
continue
except OSError as e:
self._log[__name__].info(
'Cannot get difference for channel %d since the network is down: %s: %s',
get_diff.channel.channel_id, type(e).__name__, e
)
await asyncio.sleep(5)
continue
updates, users, chats = self._message_box.apply_channel_difference(get_diff, diff, self._mb_entity_cache)
if updates:
self._log[__name__].info('Got difference for channel %d updates', get_diff.channel.channel_id)
_preprocess_updates = await self._preprocess_updates(updates, users, chats)
updates_to_dispatch.extend(_preprocess_updates)
continue
deadline = self._message_box.check_deadlines()
deadline_delay = deadline - get_running_loop().time()
if deadline_delay > 0:
# Don't bother sleeping and timing out if the delay is already 0 (pollutes the logs).
try:
updates = await asyncio.wait_for(self._updates_queue.get(), deadline_delay)
except asyncio.TimeoutError:
self._log[__name__].debug('Timeout waiting for updates expired')
continue
else:
continue
processed = []
try:
users, chats = self._message_box.process_updates(updates, self._mb_entity_cache, processed)
except GapError:
continue # get(_channel)_difference will start returning requests
_preprocess_updates = await self._preprocess_updates(processed, users, chats)
updates_to_dispatch.extend(_preprocess_updates)
except asyncio.CancelledError:
pass
except Exception as e:
self._log[__name__].exception(f'Fatal error handling updates (this is a bug in Telethon v{__version__}, please report it)')
self._updates_error = e
await self.disconnect()
async def _preprocess_updates(self, updates, users, chats):
self._mb_entity_cache.extend(users, chats)
await utils.maybe_async(self.session.process_entities(types.contacts.ResolvedPeer(None, users, chats)))
entities = {utils.get_peer_id(x): x
for x in itertools.chain(users, chats)}
for u in updates:
u._entities = entities
return updates
async def _keepalive_loop(self: 'TelegramClient'):
# Pings' ID don't really need to be secure, just "random"
rnd = lambda: random.randrange(-2**63, 2**63)
while self.is_connected():
try:
await asyncio.wait_for(
self.disconnected, timeout=60, loop=self._loop
self.disconnected, timeout=60
)
continue # We actually just want to act upon timeout
except asyncio.TimeoutError:
@ -335,6 +498,9 @@ class UpdateMethods:
except Exception:
continue # Any disconnected exception should be ignored
# Check if we have any exported senders to clean-up periodically
await self._clean_exported_senders()
# Don't bother sending pings until the low-level connection is
# ready, otherwise a lot of pings will be batched to be sent upon
# reconnect, when we really don't care about that.
@ -344,7 +510,7 @@ class UpdateMethods:
# We also don't really care about their result.
# Just send them periodically.
try:
self._sender.send(functions.PingRequest(rnd()))
self._sender._keepalive_ping(rnd())
except (ConnectionError, asyncio.CancelledError):
return
@ -352,48 +518,25 @@ class UpdateMethods:
# inserted because this is a rather expensive operation
# (default's sqlite3 takes ~0.1s to commit changes). Do
# it every minute instead. No-op if there's nothing new.
self.session.save()
await self._save_states_and_entities()
# We need to send some content-related request at least hourly
# for Telegram to keep delivering updates, otherwise they will
# just stop even if we're connected. Do so every 30 minutes.
#
# TODO Call getDifference instead since it's more relevant
if time.time() - self._last_request > 30 * 60:
if not await self.is_user_authorized():
# What can be the user doing for so
# long without being logged in...?
continue
await utils.maybe_async(self.session.save())
try:
await self(functions.updates.GetStateRequest())
except (ConnectionError, asyncio.CancelledError):
return
async def _dispatch_update(self: 'TelegramClient', update):
# TODO only used for AlbumHack, and MessageBox is not really designed for this
others = None
async def _dispatch_queue_updates(self: 'TelegramClient'):
while not self._updates_queue.empty():
await self._dispatch_update(*self._updates_queue.get_nowait())
self._dispatching_updates_queue.clear()
async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, pts_date):
if not self._entity_cache.ensure_cached(update):
# We could add a lock to not fetch the same pts twice if we are
# already fetching it. However this does not happen in practice,
# which makes sense, because different updates have different pts.
if self._state_cache.update(update, check_only=True):
# If the update doesn't have pts, fetching won't do anything.
# For example, UpdateUserStatus or UpdateChatUserTyping.
try:
await self._get_difference(update, channel_id, pts_date)
except OSError:
pass # We were disconnected, that's okay
if not self._self_input_peer:
if not self._mb_entity_cache.self_id:
# Some updates require our own ID, so we must make sure
# that the event builder has offline access to it. Calling
# `get_me()` will cache it under `self._self_input_peer`.
await self.get_me(input_peer=True)
# `get_me()` will cache it under `self._mb_entity_cache`.
#
# It will return `None` if we haven't logged in yet which is
# fine, we will just retry next time anyway.
try:
await self.get_me(input_peer=True)
except OSError:
pass # might not have connection
built = EventBuilderDict(self, update, others)
for conv_set in self._conversations.values():
@ -421,7 +564,10 @@ class UpdateMethods:
if not builder.resolved:
await builder.resolve(self)
if not builder.filter(event):
filter = builder.filter(event)
if inspect.isawaitable(filter):
filter = await filter
if not filter:
continue
try:
@ -441,67 +587,59 @@ class UpdateMethods:
except Exception as e:
if not isinstance(e, asyncio.CancelledError) or self.is_connected():
name = getattr(callback, '__name__', repr(callback))
self._log[__name__].exception('Unhandled exception on %s',
name)
self._log[__name__].exception('Unhandled exception on %s', name)
async def _get_difference(self: 'TelegramClient', update, channel_id, pts_date):
async def _dispatch_event(self: 'TelegramClient', event):
"""
Get the difference for this `channel_id` if any, then load entities.
Dispatches a single, out-of-order event. Used by `AlbumHack`.
"""
# We're duplicating a most logic from `_dispatch_update`, but all in
# the name of speed; we don't want to make it worse for all updates
# just because albums may need it.
for builder, callback in self._event_builders:
if isinstance(builder, events.Raw):
continue
if not isinstance(event, builder.Event):
continue
if not builder.resolved:
await builder.resolve(self)
filter = builder.filter(event)
if inspect.isawaitable(filter):
filter = await filter
if not filter:
continue
Calls :tl:`updates.getDifference`, which fills the entities cache
(always done by `__call__`) and lets us know about the full entities.
"""
# Fetch since the last known pts/date before this update arrived,
# in order to fetch this update at full, including its entities.
self._log[__name__].debug('Getting difference for entities '
'for %r', update.__class__)
if channel_id:
try:
where = await self.get_input_entity(channel_id)
except ValueError:
# There's a high chance that this fails, since
# we are getting the difference to fetch entities.
return
if not pts_date:
# First-time, can't get difference. Get pts instead.
result = await self(functions.channels.GetFullChannelRequest(
utils.get_input_channel(where)
))
self._state_cache[channel_id] = result.full_chat.pts
return
result = await self(functions.updates.GetChannelDifferenceRequest(
channel=where,
filter=types.ChannelMessagesFilterEmpty(),
pts=pts_date, # just pts
limit=100,
force=True
))
else:
if not pts_date[0]:
# First-time, can't get difference. Get pts instead.
result = await self(functions.updates.GetStateRequest())
self._state_cache[None] = result.pts, result.date
return
result = await self(functions.updates.GetDifferenceRequest(
pts=pts_date[0],
date=pts_date[1],
qts=0
))
if isinstance(result, (types.updates.Difference,
types.updates.DifferenceSlice,
types.updates.ChannelDifference,
types.updates.ChannelDifferenceTooLong)):
update._entities.update({
utils.get_peer_id(x): x for x in
itertools.chain(result.users, result.chats)
})
await callback(event)
except errors.AlreadyInConversationError:
name = getattr(callback, '__name__', repr(callback))
self._log[__name__].debug(
'Event handler "%s" already has an open conversation, '
'ignoring new one', name)
except events.StopPropagation:
name = getattr(callback, '__name__', repr(callback))
self._log[__name__].debug(
'Event handler "%s" stopped chain of propagation '
'for event %s.', name, type(event).__name__
)
break
except Exception as e:
if not isinstance(e, asyncio.CancelledError) or self.is_connected():
name = getattr(callback, '__name__', repr(callback))
self._log[__name__].exception('Unhandled exception on %s', name)
async def _handle_auto_reconnect(self: 'TelegramClient'):
# TODO Catch-up
# For now we make a high-level request to let Telegram
# know we are still interested in receiving more updates.
try:
await self.get_me()
except Exception as e:
self._log[__name__].warning('Error executing high-level request '
'after reconnect: %s: %s', type(e), e)
return
try:
self._log[__name__].info(
@ -532,8 +670,8 @@ class UpdateMethods:
self._log[__name__].warning('Failed to get missed updates after '
'reconnect: %r', e)
except Exception:
self._log[__name__].exception('Unhandled exception while getting '
'update difference after reconnect')
self._log[__name__].exception(
'Unhandled exception while getting update difference after reconnect')
# endregion
@ -552,7 +690,7 @@ class EventBuilderDict:
return self.__dict__[builder]
except KeyError:
event = self.__dict__[builder] = builder.build(
self.update, self.others, self.client._self_input_peer.user_id)
self.update, self.others, self.client._self_id)
if isinstance(event, EventCommon):
event.original_update = self.update

View File

@ -5,9 +5,10 @@ import os
import pathlib
import re
import typing
import inspect
from io import BytesIO
from ..crypto import AES
from .. import utils, helpers, hints
from ..tl import types, functions, custom
@ -17,7 +18,6 @@ try:
except ImportError:
PIL = None
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
@ -35,7 +35,7 @@ class _CacheType:
def _resize_photo_if_needed(
file, is_image, width=1280, height=1280, background=(255, 255, 255)):
file, is_image, width=2560, height=2560, background=(255, 255, 255)):
# https://github.com/telegramdesktop/tdesktop/blob/12905f0dcb9d513378e7db11989455a1b764ef75/Telegram/SourceFiles/boxes/photo_crop_box.cpp#L254
if (not is_image
@ -46,40 +46,64 @@ def _resize_photo_if_needed(
if isinstance(file, bytes):
file = io.BytesIO(file)
before = file.tell() if isinstance(file, io.IOBase) else None
if isinstance(file, io.IOBase):
# Pillow seeks to 0 unconditionally later anyway
old_pos = file.tell()
file.seek(0, io.SEEK_END)
before = file.tell()
elif isinstance(file, str) and os.path.exists(file):
# Check if file exists as a path and if so, get its size on disk
before = os.path.getsize(file)
else:
# Would be weird...
before = 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)
if image.width <= width and image.height <= height:
return file
try:
kwargs = {'exif': image.info['exif']}
except KeyError:
kwargs = {}
image.thumbnail((width, height), PIL.Image.ANTIALIAS)
if image.mode == 'RGB':
# Check if image is within acceptable bounds, if so, check if the image is at or below 10 MB, or assume it isn't if size is None or 0
if image.width <= width and image.height <= height and (before <= 10000000 if before else False):
return file
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.
# If the image is already RGB, don't convert it
# certain modes such as 'P' have no alpha index but can't be saved as JPEG directly
image.thumbnail((width, height), PIL.Image.LANCZOS)
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.
image.thumbnail((width, height), PIL.Image.LANCZOS)
result = PIL.Image.new('RGB', image.size, background)
result.paste(image, mask=image.split()[alpha_index])
mask = None
if image.has_transparency_data:
if image.mode == 'RGBA':
mask = image.getchannel('A')
else:
mask = image.convert('RGBA').getchannel('A')
result.paste(image, mask=mask)
buffer = io.BytesIO()
result.save(buffer, 'JPEG')
result.save(buffer, 'JPEG', progressive=True, **kwargs)
buffer.seek(0)
buffer.name = 'a.jpg'
return buffer
except IOError:
return file
finally:
if before is not None:
file.seek(before, io.SEEK_SET)
# The original position might matter
if isinstance(file, io.IOBase):
file.seek(old_pos)
class UploadMethods:
@ -93,19 +117,33 @@ class UploadMethods:
*,
caption: typing.Union[str, typing.Sequence[str]] = None,
force_document: bool = False,
mime_type: str = None,
file_size: int = None,
clear_draft: bool = False,
progress_callback: 'hints.ProgressCallback' = None,
reply_to: 'hints.MessageIDLike' = None,
attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
thumb: 'hints.FileLike' = None,
allow_cache: bool = True,
parse_mode: str = (),
formatting_entities: typing.Optional[
typing.Union[
typing.List[types.TypeMessageEntity], typing.List[typing.List[types.TypeMessageEntity]]
]
] = None,
voice_note: bool = False,
video_note: bool = False,
buttons: 'hints.MarkupLike' = None,
buttons: typing.Optional['hints.MarkupLike'] = None,
silent: bool = None,
background: bool = None,
supports_streaming: bool = False,
schedule: 'hints.DateLike' = None,
**kwargs) -> 'types.Message':
comment_to: 'typing.Union[int, types.Message]' = None,
ttl: int = None,
nosound_video: bool = None,
send_as: typing.Optional['hints.EntityLike'] = None,
message_effect_id: typing.Optional[int] = None,
**kwargs) -> typing.Union[typing.List[typing.Any], typing.Any]:
"""
Sends message with the given file to the specified entity.
@ -152,6 +190,10 @@ class UploadMethods:
* A handle to an uploaded file (from `upload_file`).
* A :tl:`InputMedia` instance. For example, if you want to
send a dice use :tl:`InputMediaDice`, or if you want to
send a contact use :tl:`InputMediaContact`.
To send an album, you should provide a list in this parameter.
If a list or similar is provided, the files in it will be
@ -168,6 +210,23 @@ class UploadMethods:
the extension of an image file or a video file, it will be
sent as such. Otherwise always as a document.
mime_type (`str`, optional):
Custom mime type to use for the file to be sent (for example,
``audio/mpeg``, ``audio/x-vorbis+ogg``, etc.).
It can change the type of files displayed.
If not set to any value, the mime type will be determined
automatically based on the file's extension.
file_size (`int`, optional):
The size of the file to be uploaded if it needs 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.
clear_draft (`bool`, optional):
Whether the existing draft should be cleared or not.
progress_callback (`callable`, optional):
A callback function accepting two parameters:
``(sent bytes, total)``.
@ -183,9 +242,14 @@ class UploadMethods:
Optional JPEG thumbnail (for documents). **Telegram will
ignore this parameter** unless you pass a ``.jpg`` file!
The file must also be small in dimensions and in-disk size.
Successful thumbnails were files below 20kb and 200x200px.
The file must also be small in dimensions and in disk size.
Successful thumbnails were files below 20kB and 320x320px.
Width/height and dimensions/size ratios may be important.
For Telegram to accept a thumbnail, you must provide the
dimensions of the underlying media through ``attributes=``
with :tl:`DocumentAttributesVideo` or by installing the
optional ``hachoir`` dependency.
allow_cache (`bool`, optional):
This parameter currently does nothing, but is kept for
@ -198,6 +262,13 @@ class UploadMethods:
property for allowed values. Markdown parsing will be used by
default.
formatting_entities (`list`, optional):
Optional formatting entities for the sent media message. When sending an album,
`formatting_entities` can be a list of lists, where each inner list contains
`types.TypeMessageEntity`. Each inner list will be assigned to the corresponding
file in a pairwise manner with the caption. If provided, the ``parse_mode``
parameter will be ignored.
voice_note (`bool`, optional):
If `True` the audio will be sent as a voice note.
@ -217,6 +288,9 @@ class UploadMethods:
the person has the chat muted). Set it to `True` to alter
this behaviour.
background (`bool`, optional):
Whether the message should be send in background.
supports_streaming (`bool`, optional):
Whether the sent video supports streaming or not. Note that
Telegram only recognizes as streamable some formats like MP4,
@ -229,6 +303,45 @@ class UploadMethods:
it will be scheduled to be automatically sent at a later
time.
comment_to (`int` | `Message <telethon.tl.custom.message.Message>`, optional):
Similar to ``reply_to``, but replies in the linked group of a
broadcast channel instead (effectively leaving a "comment to"
the specified message).
This parameter takes precedence over ``reply_to``. If there is
no linked chat, `telethon.errors.sgIdInvalidError` is raised.
ttl (`int`. optional):
The Time-To-Live of the file (also known as "self-destruct timer"
or "self-destructing media"). If set, files can only be viewed for
a short period of time before they disappear from the message
history automatically.
The value must be at least 1 second, and at most 60 seconds,
otherwise Telegram will ignore this parameter.
Not all types of media can be used with this parameter, such
as text documents, which will fail with ``TtlMediaInvalidError``.
nosound_video (`bool`, optional):
Only applicable when sending a video file without an audio
track. If set to ``True``, the video will be displayed in
Telegram as a video. If set to ``False``, Telegram will attempt
to display the video as an animated gif. (It may still display
as a video due to other factors.) The value is ignored if set
on non-video files. This is set to ``True`` for albums, as gifs
cannot be sent in albums.
send_as (`entity`):
Unique identifier (int) or username (str) of the chat or channel to send the message as.
You can use this to send the message on behalf of a chat or channel where you have appropriate permissions.
Use the GetSendAs to return the list of message sender identifiers, which can be used to send messages in the chat,
This setting applies to the current message and will remain effective for future messages unless explicitly changed.
To set this behavior permanently for all messages, use SaveDefaultSendAs.
message_effect_id (`int`, optional):
Unique identifier of the message effect to be added to the message; for private chats only
Returns
The `Message <telethon.tl.custom.message.Message>` (or messages)
containing the sent file, or messages if a list of them was passed.
@ -257,6 +370,26 @@ class UploadMethods:
'/my/photos/holiday2.jpg',
'/my/drawings/portrait.png'
])
# Printing upload progress
def callback(current, total):
print('Uploaded', current, 'out of', total,
'bytes: {:.2%}'.format(current / total))
await client.send_file(chat, file, progress_callback=callback)
# Dices, including dart and other future emoji
from telethon.tl import types
await client.send_file(chat, types.InputMediaDice(''))
await client.send_file(chat, types.InputMediaDice('🎯'))
# Contacts
await client.send_file(chat, types.InputMediaContact(
phone_number='+34 123 456 789',
first_name='Example',
last_name='',
vcard=''
))
"""
# TODO Properly implement allow_cache to reuse the sha256 of the file
# i.e. `None` was used
@ -266,70 +399,72 @@ class UploadMethods:
if not caption:
caption = ''
if not formatting_entities:
formatting_entities = []
entity = await self.get_input_entity(entity)
if comment_to is not None:
entity, reply_to = await self._get_comment_data(entity, comment_to)
else:
reply_to = utils.get_message_id(reply_to)
# First check if the user passed an iterable, in which case
# we may want to send as an album if all are photo files.
# we may want to send grouped.
if utils.is_list_like(file):
image_captions = []
document_captions = []
sent_count = 0
used_callback = None if not progress_callback else (
lambda s, t: progress_callback(sent_count + s, len(file))
)
if utils.is_list_like(caption):
captions = caption
else:
captions = [caption]
# TODO Fix progress_callback
images = []
if force_document:
documents = file
# Check that formatting_entities list is valid
if all(utils.is_list_like(obj) for obj in formatting_entities):
formatting_entities = formatting_entities
elif utils.is_list_like(formatting_entities):
formatting_entities = [formatting_entities]
else:
documents = []
for doc, cap in itertools.zip_longest(file, captions):
if utils.is_image(doc):
images.append(doc)
image_captions.append(cap)
else:
documents.append(doc)
document_captions.append(cap)
raise TypeError('The formatting_entities argument must be a list or a sequence of lists')
# Check that all entities in all lists are of the correct type
if not all(isinstance(ent, types.TypeMessageEntity) for sublist in formatting_entities for ent in sublist):
raise TypeError('All entities must be instances of <types.TypeMessageEntity>')
result = []
while images:
while file:
result += await self._send_album(
entity, images[:10], caption=image_captions[:10],
progress_callback=progress_callback, reply_to=reply_to,
parse_mode=parse_mode, silent=silent, schedule=schedule
entity, file[:10], caption=captions[:10], formatting_entities=formatting_entities[:10],
progress_callback=used_callback, reply_to=reply_to,
parse_mode=parse_mode, silent=silent, schedule=schedule,
supports_streaming=supports_streaming, clear_draft=clear_draft,
force_document=force_document, background=background,
send_as=send_as, message_effect_id=message_effect_id
)
images = images[10:]
image_captions = image_captions[10:]
for doc, cap in zip(documents, captions):
result.append(await self.send_file(
entity, doc, allow_cache=allow_cache,
caption=cap, force_document=force_document,
progress_callback=progress_callback, reply_to=reply_to,
attributes=attributes, thumb=thumb, voice_note=voice_note,
video_note=video_note, buttons=buttons, silent=silent,
supports_streaming=supports_streaming, schedule=schedule,
**kwargs
))
file = file[10:]
captions = captions[10:]
formatting_entities = formatting_entities[10:]
sent_count += 10
return result
entity = await self.get_input_entity(entity)
reply_to = utils.get_message_id(reply_to)
# Not document since it's subject to change.
# Needed when a Message is passed to send_message and it has media.
if 'entities' in kwargs:
msg_entities = kwargs['entities']
if formatting_entities:
msg_entities = formatting_entities
else:
caption, msg_entities =\
await self._parse_message_text(caption, parse_mode)
file_handle, media, image = await self._file_to_media(
file, force_document=force_document,
mime_type=mime_type,
file_size=file_size,
progress_callback=progress_callback,
attributes=attributes, allow_cache=allow_cache, thumb=thumb,
attributes=attributes, allow_cache=allow_cache, thumb=thumb,
voice_note=voice_note, video_note=video_note,
supports_streaming=supports_streaming
supports_streaming=supports_streaming, ttl=ttl,
nosound_video=nosound_video,
)
# e.g. invalid cast from :tl:`MessageMediaWebPage`
@ -337,19 +472,25 @@ class UploadMethods:
raise TypeError('Cannot use {!r} as file'.format(file))
markup = self.build_reply_markup(buttons)
reply_to = None if reply_to is None else types.InputReplyToMessage(reply_to)
request = functions.messages.SendMediaRequest(
entity, media, reply_to_msg_id=reply_to, message=caption,
entity, media, reply_to=reply_to, message=caption,
entities=msg_entities, reply_markup=markup, silent=silent,
schedule_date=schedule
schedule_date=schedule, clear_draft=clear_draft,
background=background,
send_as=await self.get_input_entity(send_as) if send_as else None,
effect=message_effect_id
)
msg = self._get_response_message(request, await self(request), entity)
await self._cache_media(msg, file, file_handle, image=image)
return msg
return self._get_response_message(request, await self(request), entity)
async def _send_album(self: 'TelegramClient', entity, files, caption='',
formatting_entities=None,
progress_callback=None, reply_to=None,
parse_mode=(), silent=None, schedule=None):
parse_mode=(), silent=None, schedule=None,
supports_streaming=None, clear_draft=None,
force_document=False, background=None, ttl=None,
send_as: typing.Optional['hints.EntityLike'] = None,
message_effect_id: typing.Optional[int] = 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
@ -357,35 +498,57 @@ class UploadMethods:
# 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
# In theory documents can be sent inside the albums, but they appear
# as different messages (not inside the album), and the logic to set
# the attributes/avoid cache is already written in .send_file().
entity = await self.get_input_entity(entity)
if not utils.is_list_like(caption):
caption = (caption,)
if not all(isinstance(obj, list) for obj in formatting_entities):
formatting_entities = (formatting_entities,)
captions = []
for c in reversed(caption): # Pop from the end (so reverse)
captions.append(await self._parse_message_text(c or '', parse_mode))
# If the formatting_entities argument is provided, we don't use parse_mode
if formatting_entities:
# Pop from the end (so reverse)
capt_with_ent = itertools.zip_longest(reversed(caption), reversed(formatting_entities), fillvalue=None)
for msg_caption, msg_entities in capt_with_ent:
captions.append((msg_caption, msg_entities))
else:
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)
used_callback = None if not progress_callback else (
# use an integer when sent matches total, to easily determine a file has been fully sent
lambda s, t: progress_callback(sent_count + 1 if s == t else sent_count + s / t, len(files))
)
# Need to upload the media first, but only if they're not cached yet
media = []
for file in files:
for sent_count, file in enumerate(files):
# Albums want :tl:`InputMedia` which, in theory, includes
# :tl:`InputMediaUploadedPhoto`. However using that will
# :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 self._file_to_media(file)
if isinstance(fm, types.InputMediaUploadedPhoto):
fh, fm, _ = await self._file_to_media(
file, supports_streaming=supports_streaming,
force_document=force_document, ttl=ttl,
progress_callback=used_callback, nosound_video=True)
if isinstance(fm, (types.InputMediaUploadedPhoto, types.InputMediaPhotoExternal)):
r = await self(functions.messages.UploadMediaRequest(
entity, media=fm
))
self.session.cache_file(
fh.md5, fh.size, utils.get_input_photo(r.photo))
fm = utils.get_input_media(r.photo)
elif isinstance(fm, (types.InputMediaUploadedDocument, types.InputMediaDocumentExternal)):
r = await self(functions.messages.UploadMediaRequest(
entity, media=fm
))
fm = utils.get_input_media(
r.document, supports_streaming=supports_streaming)
if captions:
caption, msg_entities = captions.pop()
@ -400,8 +563,11 @@ class UploadMethods:
# Now we can construct the multi-media request
request = functions.messages.SendMultiMediaRequest(
entity, reply_to_msg_id=reply_to, multi_media=media,
silent=silent, schedule_date=schedule
entity, reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to), multi_media=media,
silent=silent, schedule_date=schedule, clear_draft=clear_draft,
background=background,
send_as=await self.get_input_entity(send_as) if send_as else None,
effect=message_effect_id
)
result = await self(request)
@ -413,12 +579,19 @@ class UploadMethods:
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) -> 'types.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).
@ -438,6 +611,13 @@ class UploadMethods:
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``
@ -448,10 +628,23 @@ class UploadMethods:
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)``.
When sending an album, the callback will receive a number
between 0 and the amount of files as the "sent" parameter,
and the amount of files as the "total". Note that the first
parameter will be a floating point number to indicate progress
within a file (e.g. ``2.5`` means it has sent 50% of the third
file, because it's between 2 and 3).
Returns
:tl:`InputFileBig` if the file size is larger than 10MB,
`InputSizedFile <telethon.tl.custom.inputsizedfile.InputSizedFile>`
@ -476,91 +669,74 @@ class UploadMethods:
if isinstance(file, (types.InputFile, types.InputFileBig)):
return file # Already uploaded
if not file_name and getattr(file, 'name', None):
file_name = file.name
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 isinstance(file, str):
file_size = os.path.getsize(file)
elif isinstance(file, bytes):
file_size = len(file)
else:
# `aiofiles` shouldn't base `IOBase` because they change the
# methods' definition. `seekable` would be `async` but since
# we won't get to check that, there's no need to maybe-await.
if isinstance(file, io.IOBase) and file.seekable():
pos = file.tell()
else:
pos = None
if not part_size_kb:
part_size_kb = utils.get_appropriated_part_size(file_size)
# TODO Don't load the entire file in memory always
data = file.read()
if inspect.isawaitable(data):
data = await data
if part_size_kb > 512:
raise ValueError('The part size must be less or equal to 512KB')
if pos is not None:
file.seek(pos)
part_size = int(part_size_kb * 1024)
if part_size % 1024 != 0:
raise ValueError(
'The part size must be evenly divisible by 1024')
if not isinstance(data, bytes):
raise TypeError(
'file descriptor returned {}, not bytes (you must '
'open the file in bytes mode)'.format(type(data)))
# 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)
file = data
file_size = len(file)
# 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)
# File will now either be a string or bytes
if not part_size_kb:
part_size_kb = utils.get_appropriated_part_size(file_size)
# 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()
if part_size_kb > 512:
raise ValueError('The part size must be less or equal to 512KB')
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)
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:
if isinstance(file, str):
file_name = os.path.basename(file)
else:
file_name = 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(file)
# Determine whether the file is too big (over 10MB) or not
# Telegram does make a distinction between smaller or larger files
is_large = file_size > 10 * 1024 * 1024
hash_md5 = hashlib.md5()
if not is_large:
# Calculate the MD5 hash before anything else.
# As this needs to be done always for small files,
# might as well do it before anything else and
# check the cache.
if isinstance(file, str):
with open(file, 'rb') as stream:
file = stream.read()
hash_md5.update(file)
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)
with open(file, 'rb') if isinstance(file, str) else BytesIO(file)\
as stream:
pos = 0
for part_index in range(part_count):
# Read the file by in chunks of size part_size
part = stream.read(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 SavePartRequest is different depending on whether
# the file is too large or not (over or less than 10MB)
if is_large:
if is_big:
request = functions.upload.SaveBigFilePartRequest(
file_id, part_index, part_count, part)
else:
@ -572,14 +748,12 @@ class UploadMethods:
self._log[__name__].debug('Uploaded %d/%d',
part_index + 1, part_count)
if progress_callback:
r = progress_callback(stream.tell(), file_size)
if inspect.isawaitable(r):
await r
await helpers._maybe_await(progress_callback(pos, file_size))
else:
raise RuntimeError(
'Failed to upload file part {}.'.format(part_index))
if is_large:
if is_big:
return types.InputFileBig(file_id, part_count, file_name)
else:
return custom.InputSizedFile(
@ -589,22 +763,25 @@ class UploadMethods:
# endregion
async def _file_to_media(
self, file, force_document=False,
self, file, force_document=False, file_size=None,
progress_callback=None, attributes=None, thumb=None,
allow_cache=True, voice_note=False, video_note=False,
supports_streaming=False, mime_type=None, as_image=None):
supports_streaming=False, mime_type=None, as_image=None,
ttl=None, nosound_video=None):
if not file:
return None, None, None
if isinstance(file, pathlib.Path):
file = str(file.absolute())
is_image = utils.is_image(file)
if as_image is None:
as_image = utils.is_image(file) and not force_document
as_image = is_image and not force_document
# `aiofiles` do not base `io.IOBase` but do have `read`, so we
# just check for the read attribute to see if it's file-like.
if not isinstance(file, (str, bytes)) and not hasattr(file, 'read'):
if not isinstance(file, (str, bytes, types.InputFile, types.InputFileBig)) \
and not hasattr(file, 'read'):
# The user may pass a Message containing media (or the media,
# or anything similar) that should be treated as a file. Try
# getting the input media for whatever they passed and send it.
@ -619,7 +796,8 @@ class UploadMethods:
force_document=force_document,
voice_note=voice_note,
video_note=video_note,
supports_streaming=supports_streaming
supports_streaming=supports_streaming,
ttl=ttl
), as_image)
except TypeError:
# Can't turn whatever was given into media
@ -627,22 +805,24 @@ class UploadMethods:
media = None
file_handle = None
if not isinstance(file, str) or os.path.isfile(file):
if isinstance(file, (types.InputFile, types.InputFileBig)):
file_handle = file
elif not isinstance(file, str) or os.path.isfile(file):
file_handle = await self.upload_file(
_resize_photo_if_needed(file, as_image),
file_size=file_size,
progress_callback=progress_callback
)
elif re.match('https?://', file):
if as_image:
media = types.InputMediaPhotoExternal(file)
elif not force_document and utils.is_gif(file):
media = types.InputMediaGifExternal(file, '')
media = types.InputMediaPhotoExternal(file, ttl_seconds=ttl)
else:
media = types.InputMediaDocumentExternal(file)
media = types.InputMediaDocumentExternal(file, ttl_seconds=ttl)
else:
bot_file = utils.resolve_bot_file_id(file)
if bot_file:
media = utils.get_input_media(bot_file)
media = utils.get_input_media(bot_file, ttl=ttl)
if media:
pass # Already have media, don't check the rest
@ -652,42 +832,39 @@ class UploadMethods:
'an HTTP URL or a valid bot-API-like file ID'.format(file)
)
elif as_image:
media = types.InputMediaUploadedPhoto(file_handle)
media = types.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl)
else:
attributes, mime_type = utils.get_attributes(
file,
mime_type=mime_type,
attributes=attributes,
force_document=force_document,
force_document=force_document and not is_image,
voice_note=voice_note,
video_note=video_note,
supports_streaming=supports_streaming
supports_streaming=supports_streaming,
thumb=thumb
)
input_kw = {}
if thumb:
if not thumb:
thumb = None
else:
if isinstance(thumb, pathlib.Path):
thumb = str(thumb.absolute())
input_kw['thumb'] = await self.upload_file(thumb)
thumb = await self.upload_file(thumb, file_size=file_size)
# setting `nosound_video` to `True` doesn't affect videos with sound
# instead it prevents sending silent videos as GIFs
nosound_video = nosound_video if mime_type.split("/")[0] == 'video' else None
media = types.InputMediaUploadedDocument(
file=file_handle,
mime_type=mime_type,
attributes=attributes,
**input_kw
thumb=thumb,
force_file=force_document and not is_image,
ttl_seconds=ttl,
nosound_video=nosound_video
)
return file_handle, media, as_image
async def _cache_media(self: 'TelegramClient', msg, file, file_handle, image):
if file and msg and isinstance(file_handle,
custom.InputSizedFile):
# There was a response message and we didn't use cached
# version, so cache whatever we just sent to the database.
md5, size = file_handle.md5, file_handle.size
if image:
to_cache = utils.get_input_photo(msg.media.photo)
else:
to_cache = utils.get_input_document(msg.media.document)
self.session.cache_file(md5, size, to_cache)
# endregion

View File

@ -26,9 +26,19 @@ def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta):
class UserMethods:
async def __call__(self: 'TelegramClient', request, ordered=False):
requests = (request if utils.is_list_like(request) else (request,))
for r in requests:
async def __call__(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None):
return await self._call(self._sender, request, ordered=ordered)
async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None):
if self._loop is not None and self._loop != helpers.get_running_loop():
raise RuntimeError('The asyncio event loop must not change after connection (see the FAQ for details)')
# if the loop is None it will fail with a connection error later on
if flood_sleep_threshold is None:
flood_sleep_threshold = self.flood_sleep_threshold
requests = list(request) if utils.is_list_like(request) else [request]
request = list(request) if utils.is_list_like(request) else request
for i, r in enumerate(requests):
if not isinstance(r, TLRequest):
raise _NOT_A_REQUEST()
await r.resolve(self, utils)
@ -39,18 +49,27 @@ class UserMethods:
diff = round(due - time.time())
if diff <= 3: # Flood waits below 3 seconds are "ignored"
self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None)
elif diff <= self.flood_sleep_threshold:
elif diff <= flood_sleep_threshold:
self._log[__name__].info(*_fmt_flood(diff, r, early=True))
await asyncio.sleep(diff, loop=self._loop)
await asyncio.sleep(diff)
self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None)
else:
raise errors.FloodWaitError(request=r, capture=diff)
if self._no_updates:
if utils.is_list_like(request):
request[i] = functions.InvokeWithoutUpdatesRequest(r)
else:
# This should only run once as requests should be a list of 1 item
request = functions.InvokeWithoutUpdatesRequest(r)
request_index = 0
last_error = None
self._last_request = time.time()
for attempt in retry_range(self._request_retries):
try:
future = self._sender.send(request, ordered=ordered)
future = sender.send(request, ordered=ordered)
if isinstance(future, list):
results = []
exceptions = []
@ -61,8 +80,7 @@ class UserMethods:
exceptions.append(e)
results.append(None)
continue
self.session.process_entities(result)
self._entity_cache.add(result)
await utils.maybe_async(self.session.process_entities(result))
exceptions.append(None)
results.append(result)
request_index += 1
@ -72,30 +90,42 @@ class UserMethods:
return results
else:
result = await future
self.session.process_entities(result)
self._entity_cache.add(result)
await utils.maybe_async(self.session.process_entities(result))
return result
except (errors.ServerError, errors.RpcCallFailError,
errors.RpcMcgetFailError) as e:
errors.RpcMcgetFailError, errors.InterdcCallErrorError,
errors.TimedOutError,
errors.InterdcCallRichErrorError) 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 (errors.FloodWaitError, errors.SlowModeWaitError, errors.FloodTestPhoneWaitError) as e:
except (errors.FloodWaitError, errors.FloodPremiumWaitError,
errors.SlowModeWaitError, errors.FloodTestPhoneWaitError) as e:
last_error = e
if utils.is_list_like(request):
request = request[request_index]
self._flood_waited_requests\
[request.CONSTRUCTOR_ID] = time.time() + e.seconds
# SLOW_MODE_WAIT is chat-specific, not request-specific
if not isinstance(e, errors.SlowModeWaitError):
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, loop=self._loop)
await asyncio.sleep(e.seconds)
else:
raise
except (errors.PhoneMigrateError, errors.NetworkMigrateError,
errors.UserMigrateError) as e:
last_error = e
self._log[__name__].info('Phone migrated to %d', e.new_dc)
should_raise = isinstance(e, (
errors.PhoneMigrateError, errors.NetworkMigrateError
@ -104,6 +134,8 @@ class UserMethods:
raise
await self._switch_dc(e.new_dc)
if self._raise_last_call_error and last_error is not None:
raise last_error
raise ValueError('Request was unsuccessful {} time(s)'
.format(attempt))
@ -131,23 +163,30 @@ class UserMethods:
me = await client.get_me()
print(me.username)
"""
if input_peer and self._self_input_peer:
return self._self_input_peer
if input_peer and self._mb_entity_cache.self_id:
return self._mb_entity_cache.get(self._mb_entity_cache.self_id)._as_input_peer()
try:
me = (await self(
functions.users.GetUsersRequest([types.InputUserSelf()])))[0]
self._bot = me.bot
if not self._self_input_peer:
self._self_input_peer = utils.get_input_peer(
me, allow_self=False
)
if not self._mb_entity_cache.self_id:
self._mb_entity_cache.set_self_user(me.id, me.bot, me.access_hash)
return self._self_input_peer if input_peer else me
return utils.get_input_peer(me, allow_self=False) if input_peer else me
except errors.UnauthorizedError:
return None
@property
def _self_id(self: 'TelegramClient') -> typing.Optional[int]:
"""
Returns the ID of the logged-in user, if known.
This property is used in every update, and some like `updateLoginToken`
occur prior to login, so it gracefully handles when no ID is known yet.
"""
return self._mb_entity_cache.self_id
async def is_bot(self: 'TelegramClient') -> bool:
"""
Return `True` if the signed-in user is a bot, `False` otherwise.
@ -160,10 +199,10 @@ class UserMethods:
else:
print('Hello')
"""
if self._bot is None:
self._bot = (await self.get_me()).bot
if self._mb_entity_cache.self_bot is None:
await self.get_me(input_peer=True)
return self._bot
return self._mb_entity_cache.self_bot
async def is_user_authorized(self: 'TelegramClient') -> bool:
"""
@ -189,7 +228,7 @@ class UserMethods:
async def get_entity(
self: 'TelegramClient',
entity: 'hints.EntitiesLike') -> 'hints.Entity':
entity: 'hints.EntitiesLike') -> typing.Union['hints.Entity', typing.List['hints.Entity']]:
"""
Turns the given entity into a valid Telegram :tl:`User`, :tl:`Chat`
or :tl:`Channel`. You can also pass a list or iterable of entities,
@ -281,14 +320,16 @@ class UserMethods:
users = tmp
if chats: # TODO Handle chats slice?
chats = (await self(
functions.messages.GetChatsRequest(chats))).chats
functions.messages.GetChatsRequest([x.chat_id for x in chats]))).chats
if channels:
channels = (await self(
functions.channels.GetChannelsRequest(channels))).chats
# Merge users, chats and channels into a single dictionary
id_entity = {
utils.get_peer_id(x): x
# `get_input_entity` might've guessed the type from a non-marked ID,
# so the only way to match that with the input is by not using marks here.
utils.get_peer_id(x, add_mark=False): x
for x in itertools.chain(users, chats, channels)
}
@ -301,7 +342,7 @@ class UserMethods:
if isinstance(x, str):
result.append(await self._get_entity_from_string(x))
elif not isinstance(x, types.InputPeerSelf):
result.append(id_entity[utils.get_peer_id(x)])
result.append(id_entity[utils.get_peer_id(x, add_mark=False)])
else:
result.append(next(
u for u in id_entity.values()
@ -384,8 +425,8 @@ class UserMethods:
try:
# 0x2d45687 == crc32(b'Peer')
if isinstance(peer, int) or peer.SUBCLASS_OF_ID == 0x2d45687:
return self._entity_cache[peer]
except (AttributeError, KeyError):
return self._mb_entity_cache.get(utils.get_peer_id(peer, add_mark=False))._as_input_peer()
except AttributeError:
pass
# Then come known strings that take precedence
@ -394,7 +435,8 @@ class UserMethods:
# No InputPeer, cached peer, or known string. Fetch from disk cache
try:
return self.session.get_input_entity(peer)
input_entity = await utils.maybe_async(self.session.get_input_entity(peer))
return input_entity
except ValueError:
pass
@ -431,12 +473,16 @@ class UserMethods:
pass
raise ValueError(
'Could not find the input entity for {!r}. Please read https://'
'docs.telethon.dev/en/latest/concepts/entities.html to'
'Could not find the input entity for {} ({}). Please read https://'
'docs.telethon.dev/en/stable/concepts/entities.html to'
' find out more details.'
.format(peer)
.format(peer, type(peer).__name__)
)
async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'):
i, cls = utils.resolve_id(await self.get_peer_id(peer))
return cls(i)
async def get_peer_id(
self: 'TelegramClient',
peer: 'hints.EntityLike',
@ -529,8 +575,8 @@ class UserMethods:
pass
try:
# Nobody with this username, maybe it's an exact name/title
return await self.get_entity(
self.session.get_input_entity(string))
input_entity = await utils.maybe_async(self.session.get_input_entity(string))
return await self.get_entity(input_entity)
except ValueError:
pass
@ -567,6 +613,8 @@ class UserMethods:
notify.peer = await self.get_input_entity(notify.peer)
return notify
except AttributeError:
return types.InputNotifyPeer(await self.get_input_entity(notify))
pass
return types.InputNotifyPeer(await self.get_input_entity(notify))
# endregion

View File

@ -13,6 +13,8 @@ class Factorization:
"""
Factorizes the given large integer.
Implementation from https://comeoncodeon.wordpress.com/2010/09/18/pollard-rho-brent-integer-factorization/.
:param pq: the prime pair pq.
:return: a tuple containing the two factors p and q.
"""

View File

@ -3,6 +3,8 @@ Helper module around the system's libssl library if available for IGE mode.
"""
import ctypes
import ctypes.util
import platform
import sys
try:
import ctypes.macholib.dyld
except ImportError:
@ -15,6 +17,22 @@ __log__ = logging.getLogger(__name__)
def _find_ssl_lib():
lib = ctypes.util.find_library('ssl')
# macOS 10.15 segfaults on unversioned crypto libraries.
# We therefore pin the current stable version here
# Credit for fix goes to Sarah Harvey (@worldwise001)
# https://www.shh.sh/2020/01/04/python-abort-trap-6.html
if sys.platform == 'darwin':
release, _version_info, _machine = platform.mac_ver()
ver, major, *_ = release.split('.')
# macOS 10.14 "mojave" is the last known major release
# to support unversioned libssl.dylib. Anything above
# needs specific versions
if int(ver) > 10 or int(ver) == 10 and int(major) > 14:
lib = (
ctypes.util.find_library('libssl.46') or
ctypes.util.find_library('libssl.44') or
ctypes.util.find_library('libssl.42')
)
if not lib:
raise OSError('no library called "ssl" found')

1
telethon/custom.py Normal file
View File

@ -0,0 +1 @@
from .tl.custom import *

View File

@ -1,141 +0,0 @@
import inspect
import itertools
from . import utils
from .tl import types
# Which updates have the following fields?
_has_field = {
('user_id', int): [],
('chat_id', int): [],
('channel_id', int): [],
('peer', 'TypePeer'): [],
('peer', 'TypeDialogPeer'): [],
('message', 'TypeMessage'): [],
}
# Note: We don't bother checking for some rare:
# * `UpdateChatParticipantAdd.inviter_id` integer.
# * `UpdateNotifySettings.peer` dialog peer.
# * `UpdatePinnedDialogs.order` list of dialog peers.
# * `UpdateReadMessagesContents.messages` list of messages.
# * `UpdateChatParticipants.participants` list of participants.
#
# There are also some uninteresting `update.message` of type string.
def _fill():
for name in dir(types):
update = getattr(types, name)
if getattr(update, 'SUBCLASS_OF_ID', None) == 0x9f89304e:
cid = update.CONSTRUCTOR_ID
sig = inspect.signature(update.__init__)
for param in sig.parameters.values():
vec = _has_field.get((param.name, param.annotation))
if vec is not None:
vec.append(cid)
# Future-proof check: if the documentation format ever changes
# then we won't be able to pick the update types we are interested
# in, so we must make sure we have at least an update for each field
# which likely means we are doing it right.
if not all(_has_field.values()):
raise RuntimeError('FIXME: Did the init signature or updates change?')
# We use a function to avoid cluttering the globals (with name/update/cid/doc)
_fill()
class EntityCache:
"""
In-memory input entity cache, defaultdict-like behaviour.
"""
def add(self, entities):
"""
Adds the given entities to the cache, if they weren't saved before.
"""
if not utils.is_list_like(entities):
# Invariant: all "chats" and "users" are always iterables,
# and "user" never is (so we wrap it inside a list).
entities = itertools.chain(
getattr(entities, 'chats', []),
getattr(entities, 'users', []),
(hasattr(entities, 'user') and [entities.user]) or []
)
for entity in entities:
try:
pid = utils.get_peer_id(entity)
if pid not in self.__dict__:
# Note: `get_input_peer` already checks for `access_hash`
self.__dict__[pid] = utils.get_input_peer(entity)
except TypeError:
pass
def __getitem__(self, item):
"""
Gets the corresponding :tl:`InputPeer` for the given ID or peer,
or raises ``KeyError`` on any error (i.e. cannot be found).
"""
if not isinstance(item, int) or item < 0:
try:
return self.__dict__[utils.get_peer_id(item)]
except TypeError:
raise KeyError('Invalid key will not have entity') from None
for cls in (types.PeerUser, types.PeerChat, types.PeerChannel):
result = self.__dict__.get(utils.get_peer_id(cls(item)))
if result:
return result
raise KeyError('No cached entity for the given key')
def ensure_cached(
self,
update,
has_user_id=frozenset(_has_field[('user_id', int)]),
has_chat_id=frozenset(_has_field[('chat_id', int)]),
has_channel_id=frozenset(_has_field[('channel_id', int)]),
has_peer=frozenset(_has_field[('peer', 'TypePeer')] + _has_field[('peer', 'TypeDialogPeer')]),
has_message=frozenset(_has_field[('message', 'TypeMessage')])
):
"""
Ensures that all the relevant entities in the given update are cached.
"""
# This method is called pretty often and we want it to have the lowest
# overhead possible. For that, we avoid `isinstance` and constantly
# getting attributes out of `types.` by "caching" the constructor IDs
# in sets inside the arguments, and using local variables.
dct = self.__dict__
cid = update.CONSTRUCTOR_ID
if cid in has_user_id and \
update.user_id not in dct:
return False
if cid in has_chat_id and \
utils.get_peer_id(types.PeerChat(update.chat_id)) not in dct:
return False
if cid in has_channel_id and \
utils.get_peer_id(types.PeerChannel(update.channel_id)) not in dct:
return False
if cid in has_peer and \
utils.get_peer_id(update.peer) not in dct:
return False
if cid in has_message:
x = update.message
y = getattr(x, 'to_id', None) # handle MessageEmpty
if y and utils.get_peer_id(y) not in dct:
return False
y = getattr(x, 'from_id', None)
if y and y not in dct:
return False
# We don't quite worry about entities anywhere else.
# This is enough.
return True

View File

@ -6,7 +6,7 @@ import re
from .common import (
ReadCancelledError, TypeNotFoundError, InvalidChecksumError,
InvalidBufferError, SecurityError, CdnFileTamperedError,
InvalidBufferError, AuthKeyNotFound, SecurityError, CdnFileTamperedError,
AlreadyInConversationError, BadMessageError, MultiError
)
@ -24,7 +24,8 @@ def rpc_message_to_error(rpc_error, request):
:return: the RPCError as a Python exception that represents this error.
"""
# Try to get the error by direct look-up, otherwise regex
cls = rpc_errors_dict.get(rpc_error.error_message, None)
# Case-insensitive, for things like "timeout" which don't conform.
cls = rpc_errors_dict.get(rpc_error.error_message.upper(), None)
if cls:
return cls(request=request)

View File

@ -1,5 +1,6 @@
"""Errors not related to the Telegram API itself"""
import struct
import textwrap
from ..tl import TLRequest
@ -18,8 +19,8 @@ class TypeNotFoundError(Exception):
def __init__(self, invalid_constructor_id, remaining):
super().__init__(
'Could not find a matching Constructor ID for the TLObject '
'that was supposed to be read with ID {:08x}. Most likely, '
'a TLObject was trying to be read when it should not be read. '
'that was supposed to be read with ID {:08x}. See the FAQ '
'for more details. '
'Remaining bytes: {!r}'.format(invalid_constructor_id, remaining))
self.invalid_constructor_id = invalid_constructor_id
@ -58,6 +59,22 @@ class InvalidBufferError(BufferError):
'Invalid response buffer (too short {})'.format(self.payload))
class AuthKeyNotFound(Exception):
"""
The server claims it doesn't know about the authorization key (session
file) currently being used. This might be because it either has never
seen this authorization key, or it used to know about the authorization
key but has forgotten it, either temporarily or permanently (possibly
due to server errors).
If the issue persists, you may need to recreate the session file and login
again. This is not done automatically because it is not possible to know
if the issue is temporary or permanent.
"""
def __init__(self):
super().__init__(textwrap.dedent(self.__class__.__doc__))
class SecurityError(Exception):
"""
Generic security error, mostly used when generating a new AuthKey.

View File

@ -1,3 +1,15 @@
from ..tl import functions
_NESTS_QUERY = (
functions.InvokeAfterMsgRequest,
functions.InvokeAfterMsgsRequest,
functions.InitConnectionRequest,
functions.InvokeWithLayerRequest,
functions.InvokeWithoutUpdatesRequest,
functions.InvokeWithMessagesRangeRequest,
functions.InvokeWithTakeoutRequest,
)
class RPCError(Exception):
"""Base class for all Remote Procedure Call errors."""
code = None
@ -7,15 +19,24 @@ class RPCError(Exception):
super().__init__('RPCError {}: {}{}'.format(
code or self.code, message, self._fmt_request(request)))
self.request = request
self.code = code
self.message = message
@staticmethod
def _fmt_request(request):
return ' (caused by {})'.format(request.__class__.__name__)
n = 0
reason = ''
while isinstance(request, _NESTS_QUERY):
n += 1
reason += request.__class__.__name__ + '('
request = request.query
reason += request.__class__.__name__ + ')' * n
return ' (caused by {})'.format(reason)
def __reduce__(self):
return type(self), (self.code, self.message)
return type(self), (self.request, self.message, self.code)
class InvalidDCError(RPCError):

View File

@ -1,4 +1,6 @@
import asyncio
import time
import weakref
from .common import EventBuilder, EventCommon, name_inner_event
from .. import utils
@ -14,12 +16,79 @@ _IGNORE_MAX_AGE = 5 # seconds
_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 = client.loop.time() + _HACK_DELAY
client.loop.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 = client.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 - client.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)
@name_inner_event
class Album(EventBuilder):
"""
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.
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__(
@ -28,8 +97,10 @@ class Album(EventBuilder):
@classmethod
def build(cls, update, others=None, self_id=None):
if not others:
return # We only care about albums which come inside the same Updates
# TODO normally we'd only check updates if they come with other updates
# but MessageBox is not designed for this so others will always be None.
# In essence we always rely on AlbumHack rather than returning early if not others.
others = [update]
if isinstance(update,
(types.UpdateNewMessage, types.UpdateNewChannelMessage)):
@ -47,6 +118,7 @@ class Album(EventBuilder):
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]:
@ -65,6 +137,11 @@ class Album(EventBuilder):
and u.message.grouped_id == group)
])
def filter(self, event):
# Albums with less than two messages require a few hacks to work.
if len(event.messages) > 1:
return super().filter(event)
class Event(EventCommon, SenderGetter):
"""
Represents the event of a new album.
@ -75,27 +152,27 @@ class Album(EventBuilder):
"""
def __init__(self, messages):
message = messages[0]
if not message.out and isinstance(message.to_id, types.PeerUser):
# Incoming message (e.g. from a bot) has to_id=us, and
# from_id=bot (the actual "chat" from a user's perspective).
chat_peer = types.PeerUser(message.from_id)
else:
chat_peer = message.to_id
super().__init__(chat_peer=chat_peer,
super().__init__(chat_peer=message.peer_id,
msg_id=message.id, broadcast=bool(message.post))
SenderGetter.__init__(self, message.sender_id)
self.messages = messages
def _set_client(self, client):
super()._set_client(client)
self._sender, self._input_sender = utils._get_entity_pair(
self.sender_id, self._entities, client._entity_cache)
self.sender_id, self._entities, client._mb_entity_cache)
for msg in self.messages:
msg._finish_init(client, self._entities, None)
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):
"""
@ -177,7 +254,6 @@ class Album(EventBuilder):
"""
if self._client:
kwargs['messages'] = self.messages
kwargs['as_album'] = True
kwargs['from_peer'] = await self.get_input_chat()
return await self._client.forward_messages(*args, **kwargs)
@ -240,7 +316,7 @@ class Album(EventBuilder):
`telethon.client.messages.MessageMethods.pin_message`
with both ``entity`` and ``message`` already set.
"""
await self.messages[0].pin(notify=notify)
return await self.messages[0].pin(notify=notify)
def __len__(self):
"""

View File

@ -31,6 +31,29 @@ class CallbackQuery(EventBuilder):
against the payload data, a callable function that returns `True`
if a the payload data is acceptable, or a compiled regex pattern.
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')
])
"""
def __init__(
self, chats=None, *, blacklist_chats=False, func=None, data=None, pattern=None):
@ -95,8 +118,10 @@ class CallbackQuery(EventBuilder):
elif event.query.data != self.match:
return
if not self.func or self.func(event):
return event
if self.func:
# Return the result of func directly as it may need to be awaited
return self.func(event)
return True
class Event(EventCommon, SenderGetter):
"""
@ -110,7 +135,7 @@ class CallbackQuery(EventBuilder):
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``.
"""
@ -126,7 +151,7 @@ class CallbackQuery(EventBuilder):
def _set_client(self, client):
super()._set_client(client)
self._sender, self._input_sender = utils._get_entity_pair(
self.sender_id, self._entities, client._entity_cache)
self.sender_id, self._entities, client._mb_entity_cache)
@property
def id(self):
@ -183,8 +208,9 @@ class CallbackQuery(EventBuilder):
if not getattr(self._input_sender, 'access_hash', True):
# getattr with True to handle the InputPeerSelf() case
try:
self._input_sender = self._client._entity_cache[self._sender_id]
except KeyError:
self._input_sender = self._client._mb_entity_cache.get(
utils.resolve_id(self._sender_id)[0])._as_input_peer()
except AttributeError:
m = await self.get_message()
if m:
self._sender = m._sender
@ -274,7 +300,7 @@ class CallbackQuery(EventBuilder):
"""
Edits the message. Shorthand for
`telethon.client.messages.MessageMethods.edit_message` with
the ``entity`` set to the correct :tl:`InputBotInlineMessageID`.
the ``entity`` set to the correct :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64`.
Returns `True` if the edit was successful.
@ -287,7 +313,7 @@ class CallbackQuery(EventBuilder):
since the message object is normally not present.
"""
self._client.loop.create_task(self.answer())
if isinstance(self.query.msg_id, types.InputBotInlineMessageID):
if isinstance(self.query.msg_id, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)):
return await self._client.edit_message(
self.query.msg_id, *args, **kwargs
)
@ -312,6 +338,8 @@ class CallbackQuery(EventBuilder):
This method will likely fail if `via_inline` is `True`.
"""
self._client.loop.create_task(self.answer())
if isinstance(self.query.msg_id, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)):
raise TypeError('Inline messages cannot be deleted as there is no API request available to do so')
return await self._client.delete_messages(
await self.get_input_chat(), [self.query.msg_id],
*args, **kwargs

View File

@ -1,6 +1,6 @@
from .common import EventBuilder, EventCommon, name_inner_event
from .. import utils
from ..tl import types, functions
from ..tl import types
@name_inner_event
@ -11,21 +11,40 @@ class ChatAction(EventBuilder):
* 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.
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, update, others=None, self_id=None):
if isinstance(update, types.UpdateChannelPinnedMessage) and update.id == 0:
# Telegram does not always send
# UpdateChannelPinnedMessage for new pins
# but always for unpin, with update.id = 0
# 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, types.UpdatePinnedChannelMessages) and not update.pinned:
return cls.Event(types.PeerChannel(update.channel_id),
unpin=True)
pin_ids=update.messages,
pin=update.pinned)
elif isinstance(update, types.UpdatePinnedMessages) and not update.pinned:
return cls.Event(update.peer,
pin_ids=update.messages,
pin=update.pinned)
elif isinstance(update, types.UpdateChatParticipantAdd):
return cls.Event(types.PeerChat(update.chat_id),
@ -37,20 +56,9 @@ class ChatAction(EventBuilder):
kicked_by=True,
users=update.user_id)
elif isinstance(update, types.UpdateChannel):
# We rely on the fact that update._entities is set by _process_update
# This update only has the channel ID, and Telegram *should* have sent
# the entity in the Updates.chats list. If it did, check Channel.left
# to determine what happened.
peer = types.PeerChannel(update.channel_id)
channel = update._entities.get(utils.get_peer_id(peer))
if channel is not None:
if isinstance(channel, types.ChannelForbidden) or channel.left:
return cls.Event(peer,
kicked_by=True)
else:
return cls.Event(peer,
added_by=True)
# 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, (
types.UpdateNewMessage, types.UpdateNewChannelMessage))
@ -62,14 +70,14 @@ class ChatAction(EventBuilder):
added_by=True,
users=msg.from_id)
elif isinstance(action, types.MessageActionChatAddUser):
# If a user adds itself, it means they joined
added_by = ([msg.from_id] == action.users) or msg.from_id
# 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
return cls.Event(msg,
added_by=added_by,
users=action.users)
elif isinstance(action, types.MessageActionChatDeleteUser):
return cls.Event(msg,
kicked_by=msg.from_id or True,
kicked_by=utils.get_peer_id(msg.from_id) if msg.from_id else True,
users=action.user_id)
elif isinstance(action, types.MessageActionChatCreate):
return cls.Event(msg,
@ -93,11 +101,23 @@ class ChatAction(EventBuilder):
return cls.Event(msg,
users=msg.from_id,
new_photo=True)
elif isinstance(action, types.MessageActionPinMessage):
# Telegram always sends this service message for new pins
elif isinstance(action, types.MessageActionPinMessage) and msg.reply_to:
return cls.Event(msg,
users=msg.from_id,
new_pin=msg.reply_to_msg_id)
pin_ids=[msg.reply_to_msg_id])
elif isinstance(action, types.MessageActionGameScore):
return cls.Event(msg,
new_score=action.score)
elif isinstance(update, types.UpdateChannelParticipant) \
and bool(update.new_participant) != bool(update.prev_participant):
# If members are hidden, bots will receive this update instead,
# as there won't be a service message. Promotions and demotions
# seem to have both new and prev participant, which are ignored
# by this event.
return cls.Event(types.PeerChannel(update.channel_id),
users=update.user_id,
added_by=update.actor_id if update.new_participant else None,
kicked_by=update.actor_id if update.prev_participant else None)
class Event(EventCommon):
"""
@ -134,22 +154,29 @@ class ChatAction(EventBuilder):
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.
"""
def __init__(self, where, new_pin=None, new_photo=None,
def __init__(self, where, new_photo=None,
added_by=None, kicked_by=None, created=None,
users=None, new_title=None, unpin=None):
users=None, new_title=None, pin_ids=None, pin=None, new_score=None):
if isinstance(where, types.MessageService):
self.action_message = where
where = where.to_id
where = where.peer_id
else:
self.action_message = None
super().__init__(chat_peer=where, msg_id=new_pin)
# TODO needs some testing (can there be more than one id, and do they follow pin order?)
# same in get_pinned_message
super().__init__(chat_peer=where, msg_id=pin_ids[0] if pin_ids else None)
self.new_pin = isinstance(new_pin, int)
self._pinned_message = new_pin
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 = \
@ -175,11 +202,19 @@ class ChatAction(EventBuilder):
self._kicked_by = kicked_by
self.created = bool(created)
self._user_peers = users if isinstance(users, list) else [users]
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.unpin = unpin
self.new_score = new_score
self.unpin = not pin
def _set_client(self, client):
super()._set_client(client)
@ -233,25 +268,26 @@ class ChatAction(EventBuilder):
If ``new_pin`` is `True`, this returns the `Message
<telethon.tl.custom.message.Message>` object that was pinned.
"""
if self._pinned_message == 0:
return None
if self._pinned_messages is None:
await self.get_pinned_messages()
if isinstance(self._pinned_message, int)\
and await self.get_input_chat():
r = await self._client(functions.channels.GetMessagesRequest(
self._input_chat, [self._pinned_message]
))
try:
self._pinned_message = next(
x for x in r.messages
if isinstance(x, types.Message)
and x.id == self._pinned_message
)
except StopIteration:
pass
if self._pinned_messages:
return self._pinned_messages[0]
if isinstance(self._pinned_message, types.Message):
return self._pinned_message
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):
@ -298,7 +334,7 @@ class ChatAction(EventBuilder):
@property
def user(self):
"""
The first user that takes part in this action (e.g. joined).
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.
@ -333,25 +369,25 @@ class ChatAction(EventBuilder):
"""
Returns the marked signed ID of the first user, if any.
"""
if self._user_peers:
return utils.get_peer_id(self._user_peers[0])
if self._user_ids:
return self._user_ids[0]
@property
def users(self):
"""
A list of users that take part in this action (e.g. joined).
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_peers:
if not self._user_ids:
return []
if self._users is None:
self._users = [
self._entities[utils.get_peer_id(peer)]
for peer in self._user_peers
if utils.get_peer_id(peer) in self._entities
self._entities[user_id]
for user_id in self._user_ids
if user_id in self._entities
]
return self._users
@ -360,10 +396,11 @@ class ChatAction(EventBuilder):
"""
Returns `users` but will make an API call if necessary.
"""
if not self._user_peers:
if not self._user_ids:
return []
if self._users is None or len(self._users) != len(self._user_peers):
# 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
@ -376,22 +413,35 @@ class ChatAction(EventBuilder):
"""
Input version of the ``self.users`` property.
"""
if self._input_users is None and self._user_peers:
if self._input_users is None and self._user_ids:
self._input_users = []
for peer in self._user_peers:
for user_id in self._user_ids:
# First try to get it from our entities
try:
self._input_users.append(self._client._entity_cache[peer])
except KeyError:
self._input_users.append(utils.get_input_peer(self._entities[user_id]))
continue
except (KeyError, TypeError):
pass
# If missing, try from the entity cache
try:
self._input_users.append(self._client._mb_entity_cache.get(
utils.resolve_id(user_id)[0])._as_input_peer())
continue
except AttributeError:
pass
return self._input_users or []
async def get_input_users(self):
"""
Returns `input_users` but will make an API call if necessary.
"""
self._input_users = None
if self._input_users is None:
await self.action_message._reload_message()
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
@ -404,5 +454,5 @@ class ChatAction(EventBuilder):
"""
Returns the marked signed ID of the users, if any.
"""
if self._user_peers:
return [utils.get_peer_id(u) for u in self._user_peers]
if self._user_ids:
return self._user_ids[:]

View File

@ -1,10 +1,9 @@
import abc
import asyncio
import itertools
import warnings
from .. import utils
from ..tl import TLObject, types, functions
from ..tl import TLObject, types
from ..tl.custom.chatgetter import ChatGetter
@ -55,7 +54,7 @@ class EventBuilder(abc.ABC):
which will be ignored if ``blacklist_chats=True``.
func (`callable`, optional):
A callable function that should accept the event as input
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:
@ -93,7 +92,7 @@ class EventBuilder(abc.ABC):
return
if not self._resolve_lock:
self._resolve_lock = asyncio.Lock(loop=client.loop)
self._resolve_lock = asyncio.Lock()
async with self._resolve_lock:
if not self.resolved:
@ -105,13 +104,13 @@ class EventBuilder(abc.ABC):
def filter(self, event):
"""
If the ID of ``event._chat_peer`` isn't in the chats set (or it is
but the set is a blacklist) returns `None`, otherwise the event.
Returns a truthy value if the event passed the filter and should be
used, or falsy otherwise. The return value may need to be awaited.
The events must have been resolved before this can be called.
"""
if not self.resolved:
return None
return
if self.chats is not None:
# Note: the `event.chat_id` property checks if it's `None` for us
@ -119,10 +118,13 @@ class EventBuilder(abc.ABC):
if inside == self.blacklist_chats:
# If this chat matches but it's a blacklist ignore.
# If it doesn't match but it's a whitelist ignore.
return None
return
if not self.func or self.func(event):
return event
if not self.func:
return True
# Return the result of func directly as it may need to be awaited
return self.func(event)
class EventCommon(ChatGetter, abc.ABC):
@ -152,7 +154,7 @@ class EventCommon(ChatGetter, abc.ABC):
self._client = client
if self._chat_peer:
self._chat, self._input_chat = utils._get_entity_pair(
self.chat_id, self._entities, client._entity_cache)
self.chat_id, self._entities, client._mb_entity_cache)
else:
self._chat = self._input_chat = None

View File

@ -4,7 +4,7 @@ import re
import asyncio
from .common import EventBuilder, EventCommon, name_inner_event
from .. import utils
from .. import utils, helpers
from ..tl import types, functions, custom
from ..tl.custom.sendergetter import SenderGetter
@ -31,6 +31,21 @@ class InlineQuery(EventBuilder):
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.
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()),
])
"""
def __init__(
self, users=None, *, blacklist_users=False, func=None, pattern=None):
@ -64,11 +79,11 @@ class InlineQuery(EventBuilder):
Represents the event of a new callback query.
Members:
query (:tl:`UpdateBotCallbackQuery`):
The original :tl:`UpdateBotCallbackQuery`.
query (:tl:`UpdateBotInlineQuery`):
The original :tl:`UpdateBotInlineQuery`.
Make sure to access the `text` of the query if
that's what you want instead working with this.
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``
@ -84,7 +99,7 @@ class InlineQuery(EventBuilder):
def _set_client(self, client):
super()._set_client(client)
self._sender, self._input_sender = utils._get_entity_pair(
self.sender_id, self._entities, client._entity_cache)
self.sender_id, self._entities, client._mb_entity_cache)
@property
def id(self):
@ -115,7 +130,7 @@ class InlineQuery(EventBuilder):
and the user's device is able to send it, this will return
the :tl:`GeoPoint` with the position of the user.
"""
return
return self.query.geo
@property
def builder(self):
@ -132,6 +147,9 @@ class InlineQuery(EventBuilder):
"""
Answers the inline query with the given results.
See the documentation for `builder` to know what kind of answers
can be given.
Args:
results (`list`, optional):
A list of :tl:`InputBotInlineResult` to use.
@ -156,9 +174,9 @@ class InlineQuery(EventBuilder):
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
The offset the client will send when the user scrolls the
results and it repeats the request.
private (`bool`, optional):
@ -191,10 +209,9 @@ class InlineQuery(EventBuilder):
return
if results:
futures = [self._as_future(x, self._client.loop)
for x in results]
futures = [self._as_future(x) for x in results]
await asyncio.wait(futures, loop=self._client.loop)
await asyncio.wait(futures)
# All futures will be in the `done` *set* that `wait` returns.
#
@ -221,10 +238,10 @@ class InlineQuery(EventBuilder):
)
@staticmethod
def _as_future(obj, loop):
def _as_future(obj):
if inspect.isawaitable(obj):
return asyncio.ensure_future(obj, loop=loop)
return asyncio.ensure_future(obj)
f = loop.create_future()
f = helpers.get_running_loop().create_future()
f.set_result(obj)
return f

View File

@ -23,6 +23,17 @@ class MessageDeleted(EventBuilder):
This means that the ``chats=`` parameter will not work reliably,
unless you intend on working with channels and super-groups only.
Example
.. code-block:: python
from telethon import events
@client.on(events.MessageDeleted)
async def handler(event):
# Log all deleted message IDs
for msg_id in event.deleted_ids:
print('Message', msg_id, 'was deleted in', event.chat_id)
"""
@classmethod
def build(cls, update, others=None, self_id=None):

View File

@ -31,6 +31,16 @@ class MessageEdited(NewMessage):
Instead, consider using ``from_users='me'`` (it won't work in
broadcast channels at all since the sender is the channel and
not you).
Example
.. code-block:: python
from telethon import events
@client.on(events.MessageEdited)
async def handler(event):
# Log the date of new edits
print('Message', event.id, 'changed at', event.date)
"""
@classmethod
def build(cls, update, others=None, self_id=None):

View File

@ -13,6 +13,21 @@ class MessageRead(EventBuilder):
If this argument is `True`, then when you read someone else's
messages the event will be fired. By default (`False`) only
when messages you sent are read by someone else will fire it.
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, chats=None, *, blacklist_chats=False, func=None, inbox=False):

View File

@ -1,7 +1,7 @@
import asyncio
import re
from .common import EventBuilder, EventCommon, name_inner_event, _into_id_set
from .. import utils
from ..tl import types
@ -37,6 +37,24 @@ class NewMessage(EventBuilder):
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.
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])
"""
def __init__(self, chats=None, *, blacklist_chats=False, func=None,
incoming=None, outgoing=None,
@ -89,16 +107,15 @@ class NewMessage(EventBuilder):
media_unread=update.media_unread,
silent=update.silent,
id=update.id,
# Note that to_id/from_id complement each other in private
# messages, depending on whether the message was outgoing.
to_id=types.PeerUser(update.user_id if update.out else self_id),
from_id=self_id if update.out else update.user_id,
peer_id=types.PeerUser(update.user_id),
from_id=types.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_msg_id=update.reply_to_msg_id,
entities=update.entities
reply_to=update.reply_to,
entities=update.entities,
ttl_period=update.ttl_period
))
elif isinstance(update, types.UpdateShortChatMessage):
event = cls.Event(types.Message(
@ -107,25 +124,19 @@ class NewMessage(EventBuilder):
media_unread=update.media_unread,
silent=update.silent,
id=update.id,
from_id=update.from_id,
to_id=types.PeerChat(update.chat_id),
from_id=types.PeerUser(self_id if update.out else update.from_id),
peer_id=types.PeerChat(update.chat_id),
message=update.message,
date=update.date,
fwd_from=update.fwd_from,
via_bot_id=update.via_bot_id,
reply_to_msg_id=update.reply_to_msg_id,
entities=update.entities
reply_to=update.reply_to,
entities=update.entities,
ttl_period=update.ttl_period
))
else:
return
# Make messages sent to ourselves outgoing unless they're forwarded.
# This makes it consistent with official client's appearance.
ori = event.message
if isinstance(ori.to_id, types.PeerUser):
if ori.from_id == ori.to_id.user_id and not ori.fwd_from:
event.message.out = True
return event
def filter(self, event):
@ -141,7 +152,7 @@ class NewMessage(EventBuilder):
return
if self.from_users is not None:
if event.message.from_id not in self.from_users:
if event.message.sender_id not in self.from_users:
return
if self.pattern:
@ -187,14 +198,7 @@ class NewMessage(EventBuilder):
"""
def __init__(self, message):
self.__dict__['_init'] = False
if not message.out and isinstance(message.to_id, types.PeerUser):
# Incoming message (e.g. from a bot) has to_id=us, and
# from_id=bot (the actual "chat" from a user's perspective).
chat_peer = types.PeerUser(message.from_id)
else:
chat_peer = message.to_id
super().__init__(chat_peer=chat_peer,
super().__init__(chat_peer=message.peer_id,
msg_id=message.id, broadcast=bool(message.post))
self.pattern_match = None

View File

@ -12,6 +12,16 @@ class Raw(EventBuilder):
types (`list` | `tuple` | `type`, optional):
The type or types that the :tl:`Update` instance must be.
Equivalent to ``if not isinstance(update, types): return``.
Example
.. code-block:: python
from telethon import events
@client.on(events.Raw)
async def handler(update):
# Print all incoming updates
print(update.stringify())
"""
def __init__(self, types=None, *, func=None):
super().__init__(func=func)
@ -19,12 +29,12 @@ class Raw(EventBuilder):
self.types = None
elif not utils.is_list_like(types):
if not isinstance(types, type):
raise TypeError('Invalid input type given %s', types)
raise TypeError('Invalid input type given: {}'.format(types))
self.types = types
else:
if not all(isinstance(x, type) for x in types):
raise TypeError('Invalid input types given %s', types)
raise TypeError('Invalid input types given: {}'.format(types))
self.types = tuple(types)
@ -36,6 +46,8 @@ class Raw(EventBuilder):
return update
def filter(self, event):
if ((not self.types or isinstance(event, self.types))
and (not self.func or self.func(event))):
if not self.types or isinstance(event, self.types):
if self.func:
# Return the result of func directly as it may need to be awaited
return self.func(event)
return event

View File

@ -14,7 +14,7 @@ from ..tl.custom.sendergetter import SenderGetter
# 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
# TODO Handle UpdateUserBlocked, UpdateUserName, UpdateUserPhone, UpdateUser
def _requires_action(function):
@functools.wraps(function)
@ -36,16 +36,30 @@ def _requires_status(function):
class UserUpdate(EventBuilder):
"""
Occurs whenever a user goes online, starts typing, etc.
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, update, others=None, self_id=None):
if isinstance(update, types.UpdateUserStatus):
return cls.Event(update.user_id,
return cls.Event(types.PeerUser(update.user_id),
status=update.status)
elif isinstance(update, types.UpdateChannelUserTyping):
return cls.Event(update.from_id,
chat_peer=types.PeerChannel(update.channel_id),
typing=update.action)
elif isinstance(update, types.UpdateChatUserTyping):
# Unfortunately, we can't know whether `chat_id`'s type
return cls.Event(update.user_id,
chat_id=update.chat_id,
return cls.Event(update.from_id,
chat_peer=types.PeerChat(update.chat_id),
typing=update.action)
elif isinstance(update, types.UpdateUserTyping):
return cls.Event(update.user_id,
@ -71,38 +85,17 @@ class UserUpdate(EventBuilder):
of the typing properties, since they will all be `None`
if the action is not set.
"""
def __init__(self, user_id, *, status=None, chat_id=None, typing=None):
if chat_id is None:
super().__init__(types.PeerUser(user_id))
else:
# Temporarily set the chat_peer to the ID until ._set_client.
# We need the client to actually figure out its type.
super().__init__(chat_id)
SenderGetter.__init__(self, user_id)
def __init__(self, peer, *, status=None, chat_peer=None, typing=None):
super().__init__(chat_peer or peer)
SenderGetter.__init__(self, utils.get_peer_id(peer))
self.status = status
self.action = typing
def _set_client(self, client):
if isinstance(self._chat_peer, int):
try:
chat = client._entity_cache[self._chat_peer]
if isinstance(chat, types.InputPeerChat):
self._chat_peer = types.PeerChat(self._chat_peer)
elif isinstance(chat, types.InputPeerChannel):
self._chat_peer = types.PeerChannel(self._chat_peer)
else:
# Should not happen
self._chat_peer = types.PeerUser(self._chat_peer)
except KeyError:
# Hope for the best. We don't know where this event
# occurred but it was most likely in a channel.
self._chat_peer = types.PeerChannel(self._chat_peer)
super()._set_client(client)
self._sender, self._input_sender = utils._get_entity_pair(
self.sender_id, self._entities, client._entity_cache)
self.sender_id, self._entities, client._mb_entity_cache)
@property
def user(self):
@ -143,6 +136,7 @@ class UserUpdate(EventBuilder):
"""
return isinstance(self.action, (
types.SendMessageChooseContactAction,
types.SendMessageChooseStickerAction,
types.SendMessageUploadAudioAction,
types.SendMessageUploadDocumentAction,
types.SendMessageUploadPhotoAction,
@ -235,6 +229,14 @@ class UserUpdate(EventBuilder):
"""
return isinstance(self.action, types.SendMessageUploadDocumentAction)
@property
@_requires_action
def sticker(self):
"""
`True` if what's being uploaded is a sticker.
"""
return isinstance(self.action, types.SendMessageChooseStickerAction)
@property
@_requires_action
def photo(self):
@ -244,7 +246,7 @@ class UserUpdate(EventBuilder):
return isinstance(self.action, types.SendMessageUploadPhotoAction)
@property
@_requires_action
@_requires_status
def last_seen(self):
"""
Exact `datetime.datetime` when the user was last seen if known.

View File

@ -1,6 +1,6 @@
"""
Several extensions Python is missing, such as a proper class to handle a TCP
communication with support for cancelling the operation, and an utility class
communication with support for cancelling the operation, and a utility class
to read arbitrary binary data in a more comfortable way, with int/strings/etc.
"""
from .binaryreader import BinaryReader

View File

@ -1,11 +1,9 @@
"""
This module contains the BinaryReader utility class.
"""
import os
import struct
import time
from datetime import datetime, timezone, timedelta
from io import BytesIO
from struct import unpack
from datetime import datetime, timedelta, timezone
from ..errors import TypeNotFoundError
from ..tl.alltlobjects import tlobjects
@ -21,7 +19,8 @@ class BinaryReader:
"""
def __init__(self, data):
self.stream = BytesIO(data)
self.stream = data or b''
self.position = 0
self._last = None # Should come in handy to spot -404 errors
# region Reading
@ -30,23 +29,35 @@ class BinaryReader:
# https://core.telegram.org/mtproto
def read_byte(self):
"""Reads a single byte value."""
return self.read(1)[0]
value, = struct.unpack_from("<B", self.stream, self.position)
self.position += 1
return value
def read_int(self, signed=True):
"""Reads an integer (4 bytes) value."""
return int.from_bytes(self.read(4), byteorder='little', signed=signed)
fmt = '<i' if signed else '<I'
value, = struct.unpack_from(fmt, self.stream, self.position)
self.position += 4
return value
def read_long(self, signed=True):
"""Reads a long integer (8 bytes) value."""
return int.from_bytes(self.read(8), byteorder='little', signed=signed)
fmt = '<q' if signed else '<Q'
value, = struct.unpack_from(fmt, self.stream, self.position)
self.position += 8
return value
def read_float(self):
"""Reads a real floating point (4 bytes) value."""
return unpack('<f', self.read(4))[0]
value, = struct.unpack_from("<f", self.stream, self.position)
self.position += 4
return value
def read_double(self):
"""Reads a real floating point (8 bytes) value."""
return unpack('<d', self.read(8))[0]
value, = struct.unpack_from("<d", self.stream, self.position)
self.position += 8
return value
def read_large_int(self, bits, signed=True):
"""Reads a n-bits long integer value."""
@ -55,7 +66,12 @@ class BinaryReader:
def read(self, length=-1):
"""Read the given amount of bytes, or -1 to read all remaining."""
result = self.stream.read(length)
if length >= 0:
result = self.stream[self.position:self.position + length]
self.position += length
else:
result = self.stream[self.position:]
self.position += len(result)
if (length >= 0) and (len(result) != length):
raise BufferError(
'No more data left to read (need {}, got {}: {}); last read {}'
@ -67,7 +83,7 @@ class BinaryReader:
def get_bytes(self):
"""Gets the byte array representing the current buffer as a whole."""
return self.stream.getvalue()
return self.stream
# endregion
@ -153,24 +169,24 @@ class BinaryReader:
def close(self):
"""Closes the reader, freeing the BytesIO stream."""
self.stream.close()
self.stream = b''
# region Position related
def tell_position(self):
"""Tells the current position on the stream."""
return self.stream.tell()
return self.position
def set_position(self, position):
"""Sets the current position on the stream."""
self.stream.seek(position)
self.position = position
def seek(self, offset):
"""
Seeks the stream position given an offset from the current position.
The offset may be negative.
"""
self.stream.seek(offset, os.SEEK_CUR)
self.position += offset
# endregion

View File

@ -1,34 +1,22 @@
"""
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 typing import Iterable, Tuple, List
from .. import helpers
from ..helpers import add_surrogate, del_surrogate, within_surrogate, strip_text
from ..tl import TLObject
from ..tl.types import (
MessageEntityBold, MessageEntityItalic, MessageEntityCode,
MessageEntityPre, MessageEntityEmail, MessageEntityUrl,
MessageEntityTextUrl, MessageEntityMentionName,
MessageEntityUnderline, MessageEntityStrike, MessageEntityBlockquote,
TypeMessageEntity
MessageEntityCustomEmoji, TypeMessageEntity
)
# 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__()
@ -86,11 +74,19 @@ class HTMLToTelegramParser(HTMLParser):
EntityType = MessageEntityUrl
else:
EntityType = MessageEntityTextUrl
args['url'] = url
args['url'] = del_surrogate(url)
url = None
self._open_tags_meta.popleft()
self._open_tags_meta.appendleft(url)
elif tag == 'tg-emoji':
try:
emoji_id = int(attrs['emoji-id'])
except (KeyError, ValueError):
return
EntityType = MessageEntityCustomEmoji
args['document_id'] = emoji_id
if EntityType and tag not in self._building_entities:
self._building_entities[tag] = EntityType(
offset=len(self.text),
@ -133,13 +129,36 @@ def parse(html: str) -> Tuple[str, List[TypeMessageEntity]]:
return html, []
parser = HTMLToTelegramParser()
parser.feed(_add_surrogate(html))
text = helpers.strip_text(parser.text, parser.entities)
return _del_surrogate(text), parser.entities
parser.feed(add_surrogate(html))
text = strip_text(parser.text, parser.entities)
parser.entities.reverse()
parser.entities.sort(key=lambda entity: entity.offset)
return del_surrogate(text), parser.entities
def unparse(text: str, entities: Iterable[TypeMessageEntity], _offset: int = 0,
_length: Optional[int] = None) -> str:
ENTITY_TO_FORMATTER = {
MessageEntityBold: ('<strong>', '</strong>'),
MessageEntityItalic: ('<em>', '</em>'),
MessageEntityCode: ('<code>', '</code>'),
MessageEntityUnderline: ('<u>', '</u>'),
MessageEntityStrike: ('<del>', '</del>'),
MessageEntityBlockquote: ('<blockquote>', '</blockquote>'),
MessageEntityPre: lambda e, _: (
"<pre>\n"
" <code class='language-{}'>\n"
" ".format(e.language), "{}\n"
" </code>\n"
"</pre>"
),
MessageEntityEmail: lambda _, t: ('<a href="mailto:{}">'.format(t), '</a>'),
MessageEntityUrl: lambda _, t: ('<a href="{}">'.format(t), '</a>'),
MessageEntityTextUrl: lambda e, _: ('<a href="{}">'.format(escape(e.url)), '</a>'),
MessageEntityMentionName: lambda e, _: ('<a href="tg://user?id={}">'.format(e.user_id), '</a>'),
MessageEntityCustomEmoji: lambda e, _: ('<tg-emoji emoji-id="{}">'.format(e.document_id), '</tg-emoji>'),
}
def unparse(text: str, entities: Iterable[TypeMessageEntity]) -> str:
"""
Performs the reverse operation to .parse(), effectively returning HTML
given a normal text and its MessageEntity's.
@ -153,77 +172,32 @@ def unparse(text: str, entities: Iterable[TypeMessageEntity], _offset: int = 0,
elif not entities:
return escape(text)
text = _add_surrogate(text)
if _length is None:
_length = len(text)
html = []
last_offset = 0
if isinstance(entities, TLObject):
entities = (entities,)
text = add_surrogate(text)
insert_at = []
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
s = entity.offset
e = entity.offset + entity.length
delimiter = ENTITY_TO_FORMATTER.get(type(entity), None)
if delimiter:
if callable(delimiter):
delimiter = delimiter(entity, text[s:e])
insert_at.append((s, i, delimiter[0]))
insert_at.append((e, -i, delimiter[1]))
skip_entity = False
length = entity.length
insert_at.sort(key=lambda t: (t[0], t[1]))
next_escape_bound = len(text)
while insert_at:
# Same logic as markdown.py
at, _, what = insert_at.pop()
while within_surrogate(text, at):
at += 1
# 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
if '\ud800' <= text[relative_offset] <= '\udfff':
relative_offset += 1
text = text[:at] + what + escape(text[at:next_escape_bound]) + text[next_escape_bound:]
next_escape_bound = at
if '\ud800' <= text[relative_offset + length] <= '\udfff':
length += 1
text = escape(text[:next_escape_bound]) + text[next_escape_bound:]
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 == MessageEntityBold:
html.append('<strong>{}</strong>'.format(entity_text))
elif entity_type == MessageEntityItalic:
html.append('<em>{}</em>'.format(entity_text))
elif entity_type == MessageEntityCode:
html.append('<code>{}</code>'.format(entity_text))
elif entity_type == MessageEntityUnderline:
html.append('<u>{}</u>'.format(entity_text))
elif entity_type == MessageEntityStrike:
html.append('<del>{}</del>'.format(entity_text))
elif entity_type == MessageEntityBlockquote:
html.append('<blockquote>{}</blockquote>'.format(entity_text))
elif entity_type == 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 == MessageEntityEmail:
html.append('<a href="mailto:{0}">{0}</a>'.format(entity_text))
elif entity_type == MessageEntityUrl:
html.append('<a href="{0}">{0}</a>'.format(entity_text))
elif entity_type == MessageEntityTextUrl:
html.append('<a href="{}">{}</a>'
.format(escape(entity.url), entity_text))
elif entity_type == 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)
if last_offset < len(text) and '\ud800' <= text[last_offset] <= '\udfff':
last_offset += 1
html.append(escape(text[last_offset:]))
return _del_surrogate(''.join(html))
return del_surrogate(text)

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