spluspy 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. spluspy/__init__.py +20 -0
  2. spluspy/_updates/__init__.py +3 -0
  3. spluspy/_updates/entitycache.py +59 -0
  4. spluspy/_updates/messagebox.py +826 -0
  5. spluspy/_updates/session.py +195 -0
  6. spluspy/client/__init__.py +25 -0
  7. spluspy/client/account.py +243 -0
  8. spluspy/client/auth.py +721 -0
  9. spluspy/client/bots.py +72 -0
  10. spluspy/client/buttons.py +101 -0
  11. spluspy/client/chats.py +1336 -0
  12. spluspy/client/dialogs.py +606 -0
  13. spluspy/client/downloads.py +1092 -0
  14. spluspy/client/messageparse.py +233 -0
  15. spluspy/client/messages.py +1524 -0
  16. spluspy/client/soroushclient.py +19 -0
  17. spluspy/client/telegrambaseclient.py +1015 -0
  18. spluspy/client/updates.py +719 -0
  19. spluspy/client/uploads.py +867 -0
  20. spluspy/client/users.py +623 -0
  21. spluspy/crypto/__init__.py +10 -0
  22. spluspy/crypto/aes.py +111 -0
  23. spluspy/crypto/aesctr.py +42 -0
  24. spluspy/crypto/authkey.py +63 -0
  25. spluspy/crypto/cdndecrypter.py +109 -0
  26. spluspy/crypto/factorization.py +67 -0
  27. spluspy/crypto/libssl.py +138 -0
  28. spluspy/crypto/rsa.py +268 -0
  29. spluspy/custom.py +1 -0
  30. spluspy/errors/__init__.py +46 -0
  31. spluspy/errors/common.py +180 -0
  32. spluspy/errors/rpcbaseerrors.py +135 -0
  33. spluspy/errors/rpcerrorlist.py +5356 -0
  34. spluspy/events/__init__.py +140 -0
  35. spluspy/events/album.py +343 -0
  36. spluspy/events/callbackquery.py +345 -0
  37. spluspy/events/chataction.py +458 -0
  38. spluspy/events/common.py +186 -0
  39. spluspy/events/inlinequery.py +247 -0
  40. spluspy/events/messagedeleted.py +57 -0
  41. spluspy/events/messageedited.py +52 -0
  42. spluspy/events/messageread.py +143 -0
  43. spluspy/events/newmessage.py +223 -0
  44. spluspy/events/raw.py +53 -0
  45. spluspy/events/userupdate.py +310 -0
  46. spluspy/extensions/__init__.py +6 -0
  47. spluspy/extensions/binaryreader.py +207 -0
  48. spluspy/extensions/html.py +203 -0
  49. spluspy/extensions/markdown.py +193 -0
  50. spluspy/extensions/messagepacker.py +122 -0
  51. spluspy/functions.py +1 -0
  52. spluspy/helpers.py +434 -0
  53. spluspy/hints.py +72 -0
  54. spluspy/network/__init__.py +14 -0
  55. spluspy/network/authenticator.py +212 -0
  56. spluspy/network/connection/__init__.py +13 -0
  57. spluspy/network/connection/connection.py +455 -0
  58. spluspy/network/connection/http.py +39 -0
  59. spluspy/network/connection/tcpabridged.py +33 -0
  60. spluspy/network/connection/tcpfull.py +58 -0
  61. spluspy/network/connection/tcpintermediate.py +46 -0
  62. spluspy/network/connection/tcpmtproxy.py +165 -0
  63. spluspy/network/connection/tcpobfuscated.py +62 -0
  64. spluspy/network/connection/websocket.py +275 -0
  65. spluspy/network/mtprotoplainsender.py +56 -0
  66. spluspy/network/mtprotosender.py +932 -0
  67. spluspy/network/mtprotostate.py +285 -0
  68. spluspy/network/requeststate.py +19 -0
  69. spluspy/password.py +194 -0
  70. spluspy/requestiter.py +134 -0
  71. spluspy/sessions/__init__.py +4 -0
  72. spluspy/sessions/abstract.py +172 -0
  73. spluspy/sessions/memory.py +263 -0
  74. spluspy/sessions/sqlite.py +388 -0
  75. spluspy/sessions/string.py +75 -0
  76. spluspy/sync.py +74 -0
  77. spluspy/tl/__init__.py +1 -0
  78. spluspy/tl/alltlobjects.py +1696 -0
  79. spluspy/tl/core/__init__.py +26 -0
  80. spluspy/tl/core/gzippacked.py +48 -0
  81. spluspy/tl/core/messagecontainer.py +47 -0
  82. spluspy/tl/core/rpcresult.py +35 -0
  83. spluspy/tl/core/tlmessage.py +34 -0
  84. spluspy/tl/custom/__init__.py +14 -0
  85. spluspy/tl/custom/adminlogevent.py +475 -0
  86. spluspy/tl/custom/button.py +346 -0
  87. spluspy/tl/custom/chatgetter.py +150 -0
  88. spluspy/tl/custom/conversation.py +529 -0
  89. spluspy/tl/custom/dialog.py +161 -0
  90. spluspy/tl/custom/draft.py +191 -0
  91. spluspy/tl/custom/file.py +146 -0
  92. spluspy/tl/custom/forward.py +51 -0
  93. spluspy/tl/custom/inlinebuilder.py +450 -0
  94. spluspy/tl/custom/inlineresult.py +176 -0
  95. spluspy/tl/custom/inlineresults.py +83 -0
  96. spluspy/tl/custom/inputsizedfile.py +9 -0
  97. spluspy/tl/custom/message.py +1243 -0
  98. spluspy/tl/custom/messagebutton.py +154 -0
  99. spluspy/tl/custom/participantpermissions.py +138 -0
  100. spluspy/tl/custom/qrlogin.py +119 -0
  101. spluspy/tl/custom/sendergetter.py +102 -0
  102. spluspy/tl/custom/types.py +38 -0
  103. spluspy/tl/functions/__init__.py +452 -0
  104. spluspy/tl/functions/account.py +1581 -0
  105. spluspy/tl/functions/auth.py +593 -0
  106. spluspy/tl/functions/bots.py +112 -0
  107. spluspy/tl/functions/channels.py +1672 -0
  108. spluspy/tl/functions/chatlists.py +325 -0
  109. spluspy/tl/functions/conference.py +437 -0
  110. spluspy/tl/functions/contacts.py +476 -0
  111. spluspy/tl/functions/folders.py +44 -0
  112. spluspy/tl/functions/help.py +259 -0
  113. spluspy/tl/functions/langpack.py +146 -0
  114. spluspy/tl/functions/messages.py +5469 -0
  115. spluspy/tl/functions/payments.py +292 -0
  116. spluspy/tl/functions/phone.py +809 -0
  117. spluspy/tl/functions/photos.py +282 -0
  118. spluspy/tl/functions/premium.py +152 -0
  119. spluspy/tl/functions/stats.py +300 -0
  120. spluspy/tl/functions/stories.py +765 -0
  121. spluspy/tl/functions/thirdParty.py +67 -0
  122. spluspy/tl/functions/updates.py +139 -0
  123. spluspy/tl/functions/upload.py +168 -0
  124. spluspy/tl/functions/users.py +170 -0
  125. spluspy/tl/patched/__init__.py +20 -0
  126. spluspy/tl/tlobject.py +222 -0
  127. spluspy/tl/types/__init__.py +40015 -0
  128. spluspy/tl/types/account.py +1096 -0
  129. spluspy/tl/types/auth.py +809 -0
  130. spluspy/tl/types/bots.py +43 -0
  131. spluspy/tl/types/channels.py +232 -0
  132. spluspy/tl/types/chatlists.py +273 -0
  133. spluspy/tl/types/conference.py +69 -0
  134. spluspy/tl/types/contacts.py +478 -0
  135. spluspy/tl/types/help.py +1206 -0
  136. spluspy/tl/types/messages.py +3000 -0
  137. spluspy/tl/types/payments.py +633 -0
  138. spluspy/tl/types/phone.py +313 -0
  139. spluspy/tl/types/photos.py +135 -0
  140. spluspy/tl/types/premium.py +209 -0
  141. spluspy/tl/types/stats.py +368 -0
  142. spluspy/tl/types/stickers.py +35 -0
  143. spluspy/tl/types/storage.py +197 -0
  144. spluspy/tl/types/stories.py +317 -0
  145. spluspy/tl/types/thirdParty.py +94 -0
  146. spluspy/tl/types/update.py +39 -0
  147. spluspy/tl/types/updates.py +447 -0
  148. spluspy/tl/types/upload.py +196 -0
  149. spluspy/tl/types/users.py +131 -0
  150. spluspy/types.py +1 -0
  151. spluspy/utils.py +1593 -0
  152. spluspy/version.py +3 -0
  153. spluspy-1.0.0.dist-info/METADATA +86 -0
  154. spluspy-1.0.0.dist-info/RECORD +156 -0
  155. spluspy-1.0.0.dist-info/WHEEL +5 -0
  156. spluspy-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,826 @@
1
+ """
2
+ This module deals with correct handling of updates, including gaps, and knowing when the code
3
+ should "get difference" (the set of updates that the client should know by now minus the set
4
+ of updates that it actually knows).
5
+
6
+ Each chat has its own [`Entry`] in the [`MessageBox`] (this `struct` is the "entry point").
7
+ At any given time, the message box may be either getting difference for them (entry is in
8
+ [`MessageBox::getting_diff_for`]) or not. If not getting difference, a possible gap may be
9
+ found for the updates (entry is in [`MessageBox::possible_gaps`]). Otherwise, the entry is
10
+ on its happy path.
11
+
12
+ Gaps are cleared when they are either resolved on their own (by waiting for a short time)
13
+ or because we got the difference for the corresponding entry.
14
+
15
+ While there are entries for which their difference must be fetched,
16
+ [`MessageBox::check_deadlines`] will always return [`Instant::now`], since "now" is the time
17
+ to get the difference.
18
+ """
19
+ import asyncio
20
+ import datetime
21
+ import time
22
+ import logging
23
+ from enum import Enum
24
+ from .session import SessionState, ChannelState
25
+ from ..tl import types as tl, functions as fn
26
+ from ..helpers import get_running_loop
27
+
28
+
29
+ # Telegram sends `seq` equal to `0` when "it doesn't matter", so we use that value too.
30
+ NO_SEQ = 0
31
+
32
+ # See https://core.telegram.org/method/updates.getChannelDifference.
33
+ BOT_CHANNEL_DIFF_LIMIT = 100000
34
+ USER_CHANNEL_DIFF_LIMIT = 100
35
+
36
+ # > It may be useful to wait up to 0.5 seconds
37
+ POSSIBLE_GAP_TIMEOUT = 0.5
38
+
39
+ # After how long without updates the client will "timeout".
40
+ #
41
+ # When this timeout occurs, the client will attempt to fetch updates by itself, ignoring all the
42
+ # updates that arrive in the meantime. After all updates are fetched when this happens, the
43
+ # client will resume normal operation, and the timeout will reset.
44
+ #
45
+ # Documentation recommends 15 minutes without updates (https://core.telegram.org/api/updates).
46
+ NO_UPDATES_TIMEOUT = 15 * 60
47
+
48
+ # object() but with a tag to make it easier to debug
49
+ class Sentinel:
50
+ __slots__ = ('tag',)
51
+
52
+ def __init__(self, tag=None):
53
+ self.tag = tag or '_'
54
+
55
+ def __repr__(self):
56
+ return self.tag
57
+
58
+ # Entry "enum".
59
+ # Account-wide `pts` includes private conversations (one-to-one) and small group chats.
60
+ ENTRY_ACCOUNT = Sentinel('ACCOUNT')
61
+ # Account-wide `qts` includes only "secret" one-to-one chats.
62
+ ENTRY_SECRET = Sentinel('SECRET')
63
+ # Integers will be Channel-specific `pts`, and includes "megagroup", "broadcast" and "supergroup" channels.
64
+
65
+ # Python's logging doesn't define a TRACE level. Pick halfway between DEBUG and NOTSET.
66
+ # We don't define a name for this as libraries shouldn't do that though.
67
+ LOG_LEVEL_TRACE = (logging.DEBUG - logging.NOTSET) // 2
68
+
69
+ _sentinel = Sentinel()
70
+
71
+ def next_updates_deadline():
72
+ return get_running_loop().time() + NO_UPDATES_TIMEOUT
73
+
74
+ def epoch():
75
+ return datetime.datetime(*time.gmtime(0)[:6]).replace(tzinfo=datetime.timezone.utc)
76
+
77
+ class GapError(ValueError):
78
+ def __repr__(self):
79
+ return 'GapError()'
80
+
81
+
82
+ class PrematureEndReason(Enum):
83
+ TEMPORARY_SERVER_ISSUES = 'tmp'
84
+ BANNED = 'ban'
85
+
86
+
87
+ # Represents the information needed to correctly handle a specific `tl::enums::Update`.
88
+ class PtsInfo:
89
+ __slots__ = ('pts', 'pts_count', 'entry')
90
+
91
+ def __init__(
92
+ self,
93
+ pts: int,
94
+ pts_count: int,
95
+ entry: object
96
+ ):
97
+ self.pts = pts
98
+ self.pts_count = pts_count
99
+ self.entry = entry
100
+
101
+ @classmethod
102
+ def from_update(cls, update):
103
+ pts = getattr(update, 'pts', None)
104
+ if pts:
105
+ pts_count = getattr(update, 'pts_count', None) or 0
106
+ try:
107
+ entry = update.message.peer_id.channel_id
108
+ except AttributeError:
109
+ entry = getattr(update, 'channel_id', None) or ENTRY_ACCOUNT
110
+ return cls(pts=pts, pts_count=pts_count, entry=entry)
111
+
112
+ qts = getattr(update, 'qts', None)
113
+ if qts:
114
+ return cls(pts=qts, pts_count=1, entry=ENTRY_SECRET)
115
+
116
+ return None
117
+
118
+ def __repr__(self):
119
+ return f'PtsInfo(pts={self.pts}, pts_count={self.pts_count}, entry={self.entry})'
120
+
121
+
122
+ # The state of a particular entry in the message box.
123
+ class State:
124
+ __slots__ = ('pts', 'deadline')
125
+
126
+ def __init__(
127
+ self,
128
+ # Current local persistent timestamp.
129
+ pts: int,
130
+ # Next instant when we would get the update difference if no updates arrived before then.
131
+ deadline: float
132
+ ):
133
+ self.pts = pts
134
+ self.deadline = deadline
135
+
136
+ def __repr__(self):
137
+ return f'State(pts={self.pts}, deadline={self.deadline})'
138
+
139
+
140
+ # > ### Recovering gaps
141
+ # > […] Manually obtaining updates is also required in the following situations:
142
+ # > • Loss of sync: a gap was found in `seq` / `pts` / `qts` (as described above).
143
+ # > It may be useful to wait up to 0.5 seconds in this situation and abort the sync in case a new update
144
+ # > arrives, that fills the gap.
145
+ #
146
+ # This is really easy to trigger by spamming messages in a channel (with as little as 3 members works), because
147
+ # the updates produced by the RPC request take a while to arrive (whereas the read update comes faster alone).
148
+ class PossibleGap:
149
+ __slots__ = ('deadline', 'updates')
150
+
151
+ def __init__(
152
+ self,
153
+ deadline: float,
154
+ # Pending updates (those with a larger PTS, producing the gap which may later be filled).
155
+ updates: list # of updates
156
+ ):
157
+ self.deadline = deadline
158
+ self.updates = updates
159
+
160
+ def __repr__(self):
161
+ return f'PossibleGap(deadline={self.deadline}, update_count={len(self.updates)})'
162
+
163
+
164
+ # Represents a "message box" (event `pts` for a specific entry).
165
+ #
166
+ # See https://core.telegram.org/api/updates#message-related-event-sequences.
167
+ class MessageBox:
168
+ __slots__ = ('_log', 'map', 'date', 'seq', 'next_deadline', 'possible_gaps', 'getting_diff_for')
169
+
170
+ def __init__(
171
+ self,
172
+ log,
173
+ # Map each entry to their current state.
174
+ map: dict = _sentinel, # entry -> state
175
+
176
+ # Additional fields beyond PTS needed by `ENTRY_ACCOUNT`.
177
+ date: datetime.datetime = epoch() + datetime.timedelta(seconds=1),
178
+ seq: int = NO_SEQ,
179
+
180
+ # Holds the entry with the closest deadline (optimization to avoid recalculating the minimum deadline).
181
+ next_deadline: object = None, # entry
182
+
183
+ # Which entries have a gap and may soon trigger a need to get difference.
184
+ #
185
+ # If a gap is found, stores the required information to resolve it (when should it timeout and what updates
186
+ # should be held in case the gap is resolved on its own).
187
+ #
188
+ # Not stored directly in `map` as an optimization (else we would need another way of knowing which entries have
189
+ # a gap in them).
190
+ possible_gaps: dict = _sentinel, # entry -> possiblegap
191
+
192
+ # For which entries are we currently getting difference.
193
+ getting_diff_for: set = _sentinel, # entry
194
+ ):
195
+ self._log = log
196
+ self.map = {} if map is _sentinel else map
197
+ self.date = date
198
+ self.seq = seq
199
+ self.next_deadline = next_deadline
200
+ self.possible_gaps = {} if possible_gaps is _sentinel else possible_gaps
201
+ self.getting_diff_for = set() if getting_diff_for is _sentinel else getting_diff_for
202
+
203
+ if __debug__:
204
+ self._trace('MessageBox initialized')
205
+
206
+ def _trace(self, msg, *args, **kwargs):
207
+ # Calls to trace can't really be removed beforehand without some dark magic.
208
+ # So every call to trace is prefixed with `if __debug__`` instead, to remove
209
+ # it when using `python -O`. Probably unnecessary, but it's nice to avoid
210
+ # paying the cost for something that is not used.
211
+ self._log.log(LOG_LEVEL_TRACE, 'Current MessageBox state: seq = %r, date = %s, map = %r',
212
+ self.seq, self.date.isoformat(), self.map)
213
+ self._log.log(LOG_LEVEL_TRACE, msg, *args, **kwargs)
214
+
215
+ # region Creation, querying, and setting base state.
216
+
217
+ def load(self, session_state, channel_states):
218
+ """
219
+ Create a [`MessageBox`] from a previously known update state.
220
+ """
221
+ if __debug__:
222
+ self._trace('Loading MessageBox with session_state = %r, channel_states = %r', session_state, channel_states)
223
+
224
+ deadline = next_updates_deadline()
225
+
226
+ self.map.clear()
227
+ if session_state.pts != NO_SEQ:
228
+ self.map[ENTRY_ACCOUNT] = State(pts=session_state.pts, deadline=deadline)
229
+ if session_state.qts != NO_SEQ:
230
+ self.map[ENTRY_SECRET] = State(pts=session_state.qts, deadline=deadline)
231
+ self.map.update((s.channel_id, State(pts=s.pts, deadline=deadline)) for s in channel_states)
232
+
233
+ self.date = datetime.datetime.fromtimestamp(session_state.date, tz=datetime.timezone.utc)
234
+ self.seq = session_state.seq
235
+ self.next_deadline = ENTRY_ACCOUNT
236
+
237
+ def session_state(self):
238
+ """
239
+ Return the current state.
240
+
241
+ This should be used for persisting the state.
242
+ """
243
+ return dict(
244
+ pts=self.map[ENTRY_ACCOUNT].pts if ENTRY_ACCOUNT in self.map else NO_SEQ,
245
+ qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ,
246
+ date=self.date,
247
+ seq=self.seq,
248
+ ), {id: state.pts for id, state in self.map.items() if isinstance(id, int)}
249
+
250
+ def is_empty(self) -> bool:
251
+ """
252
+ Return true if the message box is empty and has no state yet.
253
+ """
254
+ return ENTRY_ACCOUNT not in self.map
255
+
256
+ def check_deadlines(self):
257
+ """
258
+ Return the next deadline when receiving updates should timeout.
259
+
260
+ If a deadline expired, the corresponding entries will be marked as needing to get its difference.
261
+ While there are entries pending of getting their difference, this method returns the current instant.
262
+ """
263
+ now = get_running_loop().time()
264
+
265
+ if self.getting_diff_for:
266
+ return now
267
+
268
+ deadline = next_updates_deadline()
269
+
270
+ # Most of the time there will be zero or one gap in flight so finding the minimum is cheap.
271
+ if self.possible_gaps:
272
+ deadline = min(deadline, *(gap.deadline for gap in self.possible_gaps.values()))
273
+ elif self.next_deadline in self.map:
274
+ deadline = min(deadline, self.map[self.next_deadline].deadline)
275
+
276
+ # asyncio's loop time precision only seems to be about 3 decimal places, so it's possible that
277
+ # we find the same number again on repeated calls. Without the "or equal" part we would log the
278
+ # timeout for updates several times (it also makes sense to get difference if now is the deadline).
279
+ if now >= deadline:
280
+ # Check all expired entries and add them to the list that needs getting difference.
281
+ self.getting_diff_for.update(entry for entry, gap in self.possible_gaps.items() if now >= gap.deadline)
282
+ self.getting_diff_for.update(entry for entry, state in self.map.items() if now >= state.deadline)
283
+
284
+ if __debug__:
285
+ self._trace('Deadlines met, now getting diff for %r', self.getting_diff_for)
286
+
287
+ # When extending `getting_diff_for`, it's important to have the moral equivalent of
288
+ # `begin_get_diff` (that is, clear possible gaps if we're now getting difference).
289
+ for entry in self.getting_diff_for:
290
+ self.possible_gaps.pop(entry, None)
291
+
292
+ return deadline
293
+
294
+ # Reset the deadline for the periods without updates for the given entries.
295
+ #
296
+ # It also updates the next deadline time to reflect the new closest deadline.
297
+ def reset_deadlines(self, entries, deadline):
298
+ if not entries:
299
+ return
300
+ for entry in entries:
301
+ if entry not in self.map:
302
+ raise RuntimeError('Called reset_deadline on an entry for which we do not have state')
303
+ self.map[entry].deadline = deadline
304
+
305
+ if self.next_deadline in entries:
306
+ # If the updated deadline was the closest one, recalculate the new minimum.
307
+ self.next_deadline = min(self.map.items(), key=lambda entry_state: entry_state[1].deadline)[0]
308
+ elif self.next_deadline in self.map and deadline < self.map[self.next_deadline].deadline:
309
+ # If the updated deadline is smaller than the next deadline, change the next deadline to be the new one.
310
+ # Any entry will do, so the one from the last iteration is fine.
311
+ self.next_deadline = entry
312
+ # else an unrelated deadline was updated, so the closest one remains unchanged.
313
+
314
+ # Convenience to reset a channel's deadline, with optional timeout.
315
+ def reset_channel_deadline(self, channel_id, timeout):
316
+ self.reset_deadlines({channel_id}, get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT))
317
+
318
+ # Sets the update state.
319
+ #
320
+ # Should be called right after login if [`MessageBox::new`] was used, otherwise undesirable
321
+ # updates will be fetched.
322
+ def set_state(self, state, reset=True):
323
+ if __debug__:
324
+ self._trace('Setting state %s', state)
325
+
326
+ deadline = next_updates_deadline()
327
+
328
+ if state.pts != NO_SEQ or not reset:
329
+ self.map[ENTRY_ACCOUNT] = State(pts=state.pts, deadline=deadline)
330
+ else:
331
+ self.map.pop(ENTRY_ACCOUNT, None)
332
+
333
+ # Telegram seems to use the `qts` for bot accounts, but while applying difference,
334
+ # it might be reset back to 0. See issue #3873 for more details.
335
+ #
336
+ # During login, a value of zero would mean the `pts` is unknown,
337
+ # so the map shouldn't contain that entry.
338
+ # But while applying difference, if the value is zero, it (probably)
339
+ # truly means that's what should be used (hence the `reset` flag).
340
+ if state.qts != NO_SEQ or not reset:
341
+ self.map[ENTRY_SECRET] = State(pts=state.qts, deadline=deadline)
342
+ else:
343
+ self.map.pop(ENTRY_SECRET, None)
344
+
345
+ self.date = state.date
346
+ self.seq = state.seq
347
+
348
+ # Like [`MessageBox::set_state`], but for channels. Useful when getting dialogs.
349
+ #
350
+ # The update state will only be updated if no entry was known previously.
351
+ def try_set_channel_state(self, id, pts):
352
+ if __debug__:
353
+ self._trace('Trying to set channel state for %r: %r', id, pts)
354
+
355
+ if id not in self.map:
356
+ self.map[id] = State(pts=pts, deadline=next_updates_deadline())
357
+
358
+ # Try to begin getting difference for the given entry.
359
+ # Fails if the entry does not have a previously-known state that can be used to get its difference.
360
+ #
361
+ # Clears any previous gaps.
362
+ def try_begin_get_diff(self, entry, reason):
363
+ if entry not in self.map:
364
+ # Won't actually be able to get difference for this entry if we don't have a pts to start off from.
365
+ if entry in self.possible_gaps:
366
+ raise RuntimeError('Should not have a possible_gap for an entry not in the state map')
367
+
368
+ if __debug__:
369
+ self._trace('Should get difference for %r because %s but cannot due to missing hash', entry, reason)
370
+ return
371
+
372
+ if __debug__:
373
+ self._trace('Marking %r as needing difference because %s', entry, reason)
374
+ self.getting_diff_for.add(entry)
375
+ self.possible_gaps.pop(entry, None)
376
+
377
+ # Finish getting difference for the given entry.
378
+ #
379
+ # It also resets the deadline.
380
+ def end_get_diff(self, entry):
381
+ try:
382
+ self.getting_diff_for.remove(entry)
383
+ except KeyError:
384
+ raise RuntimeError('Called end_get_diff on an entry which was not getting diff for')
385
+
386
+ self.reset_deadlines({entry}, next_updates_deadline())
387
+ assert entry not in self.possible_gaps, "gaps shouldn't be created while getting difference"
388
+
389
+ # endregion Creation, querying, and setting base state.
390
+
391
+ # region "Normal" updates flow (processing and detection of gaps).
392
+
393
+ # Process an update and return what should be done with it.
394
+ #
395
+ # Updates corresponding to entries for which their difference is currently being fetched
396
+ # will be ignored. While according to the [updates' documentation]:
397
+ #
398
+ # > Implementations [have] to postpone updates received via the socket while
399
+ # > filling gaps in the event and `Update` sequences, as well as avoid filling
400
+ # > gaps in the same sequence.
401
+ #
402
+ # In practice, these updates should have also been retrieved through getting difference.
403
+ #
404
+ # [updates documentation] https://core.telegram.org/api/updates
405
+ def process_updates(
406
+ self,
407
+ updates,
408
+ chat_hashes,
409
+ result, # out list of updates; returns list of user, chat, or raise if gap
410
+ ):
411
+
412
+ # v1 has never sent updates produced by the client itself to the handlers.
413
+ # However proper update handling requires those to be processed.
414
+ # This is an ugly workaround for that.
415
+ self_outgoing = getattr(updates, '_self_outgoing', False)
416
+ real_result = result
417
+ result = []
418
+
419
+ date = getattr(updates, 'date', None)
420
+ seq = getattr(updates, 'seq', None)
421
+ seq_start = getattr(updates, 'seq_start', None)
422
+ users = getattr(updates, 'users', None) or []
423
+ chats = getattr(updates, 'chats', None) or []
424
+
425
+ if __debug__:
426
+ self._trace('Processing updates with seq = %r, seq_start = %r, date = %s: %s',
427
+ seq, seq_start, date.isoformat() if date else None, updates)
428
+
429
+ if date is None:
430
+ # updatesTooLong is the only one with no date (we treat it as a gap)
431
+ self.try_begin_get_diff(ENTRY_ACCOUNT, 'received updatesTooLong')
432
+ raise GapError
433
+ if seq is None:
434
+ seq = NO_SEQ
435
+ if seq_start is None:
436
+ seq_start = seq
437
+
438
+ # updateShort is the only update which cannot be dispatched directly but doesn't have 'updates' field
439
+ updates = getattr(updates, 'updates', None) or [updates.update if isinstance(updates, tl.UpdateShort) else updates]
440
+
441
+ for u in updates:
442
+ u._self_outgoing = self_outgoing
443
+
444
+ # > For all the other [not `updates` or `updatesCombined`] `Updates` type constructors
445
+ # > there is no need to check `seq` or change a local state.
446
+ if seq_start != NO_SEQ:
447
+ if self.seq + 1 > seq_start:
448
+ # Skipping updates that were already handled
449
+ if __debug__:
450
+ self._trace('Skipping updates as they should have already been handled')
451
+ return (users, chats)
452
+ elif self.seq + 1 < seq_start:
453
+ # Gap detected
454
+ self.try_begin_get_diff(ENTRY_ACCOUNT, 'detected gap')
455
+ raise GapError
456
+ # else apply
457
+
458
+ def _sort_gaps(update):
459
+ pts = PtsInfo.from_update(update)
460
+ return pts.pts - pts.pts_count if pts else 0
461
+
462
+ reset_deadlines = set() # temporary buffer
463
+
464
+ result.extend(filter(None, (
465
+ self.apply_pts_info(u, reset_deadlines=reset_deadlines)
466
+ # Telegram can send updates out of order (e.g. ReadChannelInbox first
467
+ # and then NewChannelMessage, both with the same pts, but the count is
468
+ # 0 and 1 respectively), so we sort them first.
469
+ for u in sorted(updates, key=_sort_gaps))))
470
+
471
+ self.reset_deadlines(reset_deadlines, next_updates_deadline())
472
+
473
+ if self.possible_gaps:
474
+ if __debug__:
475
+ self._trace('Trying to re-apply %r possible gaps', len(self.possible_gaps))
476
+
477
+ # For each update in possible gaps, see if the gap has been resolved already.
478
+ for key in list(self.possible_gaps.keys()):
479
+ self.possible_gaps[key].updates.sort(key=_sort_gaps)
480
+
481
+ for _ in range(len(self.possible_gaps[key].updates)):
482
+ update = self.possible_gaps[key].updates.pop(0)
483
+
484
+ # If this fails to apply, it will get re-inserted at the end.
485
+ # All should fail, so the order will be preserved (it would've cycled once).
486
+ update = self.apply_pts_info(update, reset_deadlines=None)
487
+ if update:
488
+ result.append(update)
489
+ if __debug__:
490
+ self._trace('Resolved gap with %r: %s', PtsInfo.from_update(update), update)
491
+
492
+ # Clear now-empty gaps.
493
+ self.possible_gaps = {entry: gap for entry, gap in self.possible_gaps.items() if gap.updates}
494
+
495
+ real_result.extend(u for u in result if not u._self_outgoing)
496
+
497
+ if result and not self.possible_gaps:
498
+ # > If the updates were applied, local *Updates* state must be updated
499
+ # > with `seq` (unless it's 0) and `date` from the constructor.
500
+ if __debug__:
501
+ self._trace('Updating seq as all updates were applied')
502
+ if date != epoch():
503
+ self.date = date
504
+ if seq != NO_SEQ:
505
+ self.seq = seq
506
+
507
+ return (users, chats)
508
+
509
+ # Tries to apply the input update if its `PtsInfo` follows the correct order.
510
+ #
511
+ # If the update can be applied, it is returned; otherwise, the update is stored in a
512
+ # possible gap (unless it was already handled or would be handled through getting
513
+ # difference) and `None` is returned.
514
+ def apply_pts_info(
515
+ self,
516
+ update,
517
+ *,
518
+ reset_deadlines,
519
+ ):
520
+ # This update means we need to call getChannelDifference to get the updates from the channel
521
+ if isinstance(update, tl.UpdateChannelTooLong):
522
+ self.try_begin_get_diff(update.channel_id, 'received updateChannelTooLong')
523
+ return None
524
+
525
+ pts = PtsInfo.from_update(update)
526
+ if not pts:
527
+ # No pts means that the update can be applied in any order.
528
+ if __debug__:
529
+ self._trace('No pts in update, so it can be applied in any order: %s', update)
530
+ return update
531
+
532
+ # As soon as we receive an update of any form related to messages (has `PtsInfo`),
533
+ # the "no updates" period for that entry is reset.
534
+ #
535
+ # Build the `HashSet` to avoid calling `reset_deadline` more than once for the same entry.
536
+ #
537
+ # By the time this method returns, self.map will have an entry for which we can reset its deadline.
538
+ if reset_deadlines:
539
+ reset_deadlines.add(pts.entry)
540
+
541
+ if pts.entry in self.getting_diff_for:
542
+ # Note: early returning here also prevents gap from being inserted (which they should
543
+ # not be while getting difference).
544
+ if __debug__:
545
+ self._trace('Skipping update with %r as its difference is being fetched', pts)
546
+ return None
547
+
548
+ if pts.entry in self.map:
549
+ local_pts = self.map[pts.entry].pts
550
+ if local_pts + pts.pts_count > pts.pts:
551
+ # Ignore
552
+ if __debug__:
553
+ self._trace('Skipping update since local pts %r > %r: %s', local_pts, pts, update)
554
+ return None
555
+ elif local_pts + pts.pts_count < pts.pts:
556
+ # Possible gap
557
+ # TODO store chats too?
558
+ if __debug__:
559
+ self._trace('Possible gap since local pts %r < %r: %s', local_pts, pts, update)
560
+ if pts.entry not in self.possible_gaps:
561
+ self.possible_gaps[pts.entry] = PossibleGap(
562
+ deadline=get_running_loop().time() + POSSIBLE_GAP_TIMEOUT,
563
+ updates=[]
564
+ )
565
+
566
+ self.possible_gaps[pts.entry].updates.append(update)
567
+ return None
568
+ else:
569
+ # Apply
570
+ if __debug__:
571
+ self._trace('Applying update pts since local pts %r = %r: %s', local_pts, pts, update)
572
+
573
+ # In a channel, we may immediately receive:
574
+ # * ReadChannelInbox (pts = X, pts_count = 0)
575
+ # * NewChannelMessage (pts = X, pts_count = 1)
576
+ #
577
+ # Notice how both `pts` are the same. If they were to be applied out of order, the first
578
+ # one however would've triggered a gap because `local_pts` + `pts_count` of 0 would be
579
+ # less than `remote_pts`. So there is no risk by setting the `local_pts` to match the
580
+ # `remote_pts` here of missing the new message.
581
+ #
582
+ # The message would however be lost if we initialized the pts with the first one, since
583
+ # the second one would appear "already handled". To prevent this we set the pts to be
584
+ # one less when the count is 0 (which might be wrong and trigger a gap later on, but is
585
+ # unlikely). This will prevent us from losing updates in the unlikely scenario where these
586
+ # two updates arrive in different packets (and therefore couldn't be sorted beforehand).
587
+ if pts.entry in self.map:
588
+ self.map[pts.entry].pts = pts.pts
589
+ else:
590
+ # When a chat is migrated to a megagroup, the first update can be a `ReadChannelInbox`
591
+ # with `pts = 1, pts_count = 0` followed by a `NewChannelMessage` with `pts = 2, pts_count=1`.
592
+ # Note how the `pts` for the message is 2 and not 1 unlike the case described before!
593
+ # This is likely because the `pts` cannot be 0 (or it would fail with PERSISTENT_TIMESTAMP_EMPTY),
594
+ # which forces the first update to be 1. But if we got difference with 1 and the second update
595
+ # also used 1, we would miss it, so Telegram probably uses 2 to work around that.
596
+ self.map[pts.entry] = State(
597
+ pts=(pts.pts - (0 if pts.pts_count else 1)) or 1,
598
+ deadline=next_updates_deadline()
599
+ )
600
+
601
+ return update
602
+
603
+ # endregion "Normal" updates flow (processing and detection of gaps).
604
+
605
+ # region Getting and applying account difference.
606
+
607
+ # Return the request that needs to be made to get the difference, if any.
608
+ def get_difference(self):
609
+ for entry in (ENTRY_ACCOUNT, ENTRY_SECRET):
610
+ if entry in self.getting_diff_for:
611
+ if entry not in self.map:
612
+ raise RuntimeError('Should not try to get difference for an entry without known state')
613
+
614
+ gd = fn.updates.GetDifferenceRequest(
615
+ pts=self.map[ENTRY_ACCOUNT].pts,
616
+ pts_total_limit=None,
617
+ date=self.date,
618
+ qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ,
619
+ )
620
+ if __debug__:
621
+ self._trace('Requesting account difference %s', gd)
622
+ return gd
623
+
624
+ return None
625
+
626
+ # Similar to [`MessageBox::process_updates`], but using the result from getting difference.
627
+ def apply_difference(
628
+ self,
629
+ diff,
630
+ chat_hashes,
631
+ ):
632
+ if __debug__:
633
+ self._trace('Applying account difference %s', diff)
634
+
635
+ finish = None
636
+ result = None
637
+
638
+ if isinstance(diff, tl.updates.DifferenceEmpty):
639
+ finish = True
640
+ self.date = diff.date
641
+ self.seq = diff.seq
642
+ result = [], [], []
643
+ elif isinstance(diff, tl.updates.Difference):
644
+ finish = True
645
+ chat_hashes.extend(diff.users, diff.chats)
646
+ result = self.apply_difference_type(diff, chat_hashes)
647
+ elif isinstance(diff, tl.updates.DifferenceSlice):
648
+ finish = False
649
+ chat_hashes.extend(diff.users, diff.chats)
650
+ result = self.apply_difference_type(diff, chat_hashes)
651
+ elif isinstance(diff, tl.updates.DifferenceTooLong):
652
+ finish = True
653
+ self.map[ENTRY_ACCOUNT].pts = diff.pts # the deadline will be reset once the diff ends
654
+ result = [], [], []
655
+
656
+ if finish:
657
+ account = ENTRY_ACCOUNT in self.getting_diff_for
658
+ secret = ENTRY_SECRET in self.getting_diff_for
659
+
660
+ if not account and not secret:
661
+ raise RuntimeError('Should not be applying the difference when neither account or secret was diff was active')
662
+
663
+ # Both may be active if both expired at the same time.
664
+ if account:
665
+ self.end_get_diff(ENTRY_ACCOUNT)
666
+ if secret:
667
+ self.end_get_diff(ENTRY_SECRET)
668
+
669
+ return result
670
+
671
+ def apply_difference_type(
672
+ self,
673
+ diff,
674
+ chat_hashes,
675
+ ):
676
+ state = getattr(diff, 'intermediate_state', None) or diff.state
677
+ self.set_state(state, reset=False)
678
+
679
+ # diff.other_updates can contain things like UpdateChannelTooLong and UpdateNewChannelMessage.
680
+ # We need to process those as if they were socket updates to discard any we have already handled.
681
+ updates = []
682
+ self.process_updates(tl.Updates(
683
+ updates=diff.other_updates,
684
+ users=diff.users,
685
+ chats=diff.chats,
686
+ date=epoch(),
687
+ seq=NO_SEQ, # this way date is not used
688
+ ), chat_hashes, updates)
689
+
690
+ updates.extend(tl.UpdateNewMessage(
691
+ message=m,
692
+ pts=NO_SEQ,
693
+ pts_count=NO_SEQ,
694
+ ) for m in diff.new_messages)
695
+ updates.extend(tl.UpdateNewEncryptedMessage(
696
+ message=m,
697
+ qts=NO_SEQ,
698
+ ) for m in diff.new_encrypted_messages)
699
+
700
+ return updates, diff.users, diff.chats
701
+
702
+ def end_difference(self):
703
+ if __debug__:
704
+ self._trace('Ending account difference')
705
+
706
+ account = ENTRY_ACCOUNT in self.getting_diff_for
707
+ secret = ENTRY_SECRET in self.getting_diff_for
708
+
709
+ if not account and not secret:
710
+ raise RuntimeError('Should not be ending get difference when neither account or secret was diff was active')
711
+
712
+ # Both may be active if both expired at the same time.
713
+ if account:
714
+ self.end_get_diff(ENTRY_ACCOUNT)
715
+ if secret:
716
+ self.end_get_diff(ENTRY_SECRET)
717
+
718
+ # endregion Getting and applying account difference.
719
+
720
+ # region Getting and applying channel difference.
721
+
722
+ # Return the request that needs to be made to get a channel's difference, if any.
723
+ def get_channel_difference(
724
+ self,
725
+ chat_hashes,
726
+ ):
727
+ entry = next((id for id in self.getting_diff_for if isinstance(id, int)), None)
728
+ if not entry:
729
+ return None
730
+
731
+ packed = chat_hashes.get(entry)
732
+ if not packed:
733
+ # Cannot get channel difference as we're missing its hash
734
+ # TODO we should probably log this
735
+ self.end_get_diff(entry)
736
+ # Remove the outdated `pts` entry from the map so that the next update can correct
737
+ # it. Otherwise, it will spam that the access hash is missing.
738
+ self.map.pop(entry, None)
739
+ return None
740
+
741
+ state = self.map.get(entry)
742
+ if not state:
743
+ raise RuntimeError('Should not try to get difference for an entry without known state')
744
+
745
+ gd = fn.updates.GetChannelDifferenceRequest(
746
+ force=False,
747
+ channel=tl.InputChannel(packed.id, packed.hash),
748
+ filter=tl.ChannelMessagesFilterEmpty(),
749
+ pts=state.pts,
750
+ limit=BOT_CHANNEL_DIFF_LIMIT if chat_hashes.self_bot else USER_CHANNEL_DIFF_LIMIT
751
+ )
752
+ if __debug__:
753
+ self._trace('Requesting channel difference %s', gd)
754
+ return gd
755
+
756
+ # Similar to [`MessageBox::process_updates`], but using the result from getting difference.
757
+ def apply_channel_difference(
758
+ self,
759
+ request,
760
+ diff,
761
+ chat_hashes,
762
+ ):
763
+ entry = request.channel.channel_id
764
+ if __debug__:
765
+ self._trace('Applying channel difference for %r: %s', entry, diff)
766
+
767
+ self.possible_gaps.pop(entry, None)
768
+
769
+ if isinstance(diff, tl.updates.ChannelDifferenceEmpty):
770
+ assert diff.final
771
+ self.end_get_diff(entry)
772
+ self.map[entry].pts = diff.pts
773
+ return [], [], []
774
+ elif isinstance(diff, tl.updates.ChannelDifferenceTooLong):
775
+ self.map[entry].pts = diff.dialog.pts
776
+ chat_hashes.extend(diff.users, diff.chats)
777
+ self.reset_channel_deadline(entry, diff.timeout)
778
+ if diff.final:
779
+ self.end_get_diff(entry)
780
+ # This `diff` has the "latest messages and corresponding chats", but it would
781
+ # be strange to give the user only partial changes of these when they would
782
+ # expect all updates to be fetched. Instead, nothing is returned.
783
+ return [], [], []
784
+ elif isinstance(diff, tl.updates.ChannelDifference):
785
+ if diff.final:
786
+ self.end_get_diff(entry)
787
+
788
+ self.map[entry].pts = diff.pts
789
+ chat_hashes.extend(diff.users, diff.chats)
790
+
791
+ updates = []
792
+ self.process_updates(tl.Updates(
793
+ updates=diff.other_updates,
794
+ users=diff.users,
795
+ chats=diff.chats,
796
+ date=epoch(),
797
+ seq=NO_SEQ, # this way date is not used
798
+ ), chat_hashes, updates)
799
+
800
+ updates.extend(tl.UpdateNewChannelMessage(
801
+ message=m,
802
+ pts=NO_SEQ,
803
+ pts_count=NO_SEQ,
804
+ ) for m in diff.new_messages)
805
+ self.reset_channel_deadline(entry, None)
806
+
807
+ return updates, diff.users, diff.chats
808
+
809
+ def end_channel_difference(self, request, reason: PrematureEndReason, chat_hashes):
810
+ entry = request.channel.channel_id
811
+ if __debug__:
812
+ self._trace('Ending channel difference for %r because %s', entry, reason)
813
+
814
+ if reason == PrematureEndReason.TEMPORARY_SERVER_ISSUES:
815
+ # Temporary issues. End getting difference without updating the pts so we can retry later.
816
+ self.possible_gaps.pop(entry, None)
817
+ self.end_get_diff(entry)
818
+ elif reason == PrematureEndReason.BANNED:
819
+ # Banned in the channel. Forget its state since we can no longer fetch updates from it.
820
+ self.possible_gaps.pop(entry, None)
821
+ self.end_get_diff(entry)
822
+ del self.map[entry]
823
+ else:
824
+ raise RuntimeError('Unknown reason to end channel difference')
825
+
826
+ # endregion Getting and applying channel difference.