├── .gitignore
├── LICENSE
├── README.md
├── config.py
├── database.py
├── database_id.py
├── file_id.py
├── handlers_callback.py
├── handlers_inline.py
├── handlers_pm.py
├── message_serializer.py
├── proxy.py
├── pytest
├── test_db_ids.py
└── test_file_ids.py
├── shell.nix
├── spoilerobot.py
├── structs.py
├── telethon.nix
└── util.py
/.gitignore:
--------------------------------------------------------------------------------
1 | *.session
2 | __pycache__
3 | .*
4 | token.txt
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # spoilerobot
2 | The source code for the Spoiler-o-bot, a Telegram spoiler creation bot
3 | You can try it out by typing @spoilerobot (inline) in Telegram
4 |
5 |
6 | # Usage
7 | - Setup [PostgreSQL](https://www.postgresql.org/) on your system
8 | - Create a user and database for the bot by becoming the postgres user and executing
9 |
10 | $ createuser spoilerobot
11 | $ createdb spoilerobot
12 | $ psql
13 | postgres=# GRANT ALL PRIVILEGES ON DATABASE spoilerobot TO spoilerobot;
14 | postgres=# \c spoilerobot postgres
15 | You are now connected to database "spoilerobot" as user "postgres".
16 | postgres=# GRANT ALL ON SCHEMA public TO spoilerobot;
17 | postgres=# \password spoilerobot
18 | [enter a password for the bot's database user]
19 |
20 | - Store this password in the `db_pwd` environment variable (or modify `config.py`)
21 | - Put your bot token in a file called `token.txt`
22 | - Edit the environmental variables inside `shell.nix`
23 | - Enter development shell with `nix-shell`
24 | - Run the bot with `python spoilerobot.py`
25 |
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | # the telegram bot token
4 | BOT_TOKEN = os.environ['token']
5 |
6 | # the user_id of the administrator
7 | ADMIN_ID = int(os.environ['admin_id'])
8 |
9 | # Extra text to insert at the end of /help
10 | CONTACT_TEXT = os.environ.get('contact_text', '')
11 |
12 | # postgresql database configs
13 | DB_NAME = 'spoilerobot'
14 | DB_USERNAME = 'spoilerobot'
15 | DB_HOST = 'localhost'
16 | DB_PASSWORD = os.environ['db_pwd']
17 |
18 | # pepper is used to season the hash of the uuid so that it's harder to brute force a uuid
19 | if 'pepper' not in os.environ:
20 | print('Please add pepper={} to your environmental variables'.format(
21 | os.urandom(8).hex()
22 | ))
23 | exit(1)
24 | HASH_PEPPER = os.environ['pepper']
25 |
26 | # the time in seconds in between timestamps of the request count statistic
27 | REQUEST_COUNT_RESOLUTION = 600
28 |
29 | # how many seconds before old taps are ignored
30 | MULTIPLE_CLICK_TIMEOUT = 20
31 |
32 | # how long in seconds to cache minor spoilers on the client-side
33 | MINOR_SPOILER_CACHE_TIME = 3600
34 |
35 | # The maximum length (in bytes) for a inline query before an advanced spoilers has to be used
36 | # (this is a telegram limitation)
37 | MAX_INLINE_LENGTH = 256
38 |
39 | # Max length of a spoiler's description when created in PM
40 | MAX_DESCRIPTION_LENGTH = 1024
--------------------------------------------------------------------------------
/database.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import dataclasses
3 | import json
4 | import logging
5 |
6 | import asyncpg
7 |
8 | from cryptography.exceptions import InvalidSignature
9 | from cryptography.fernet import Fernet
10 | from cryptography.hazmat.backends import default_backend
11 | from cryptography.hazmat.primitives import hashes
12 | from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
13 |
14 | import config
15 | from structs import Spoiler
16 | from database_id import UUID
17 |
18 |
19 | pool: asyncpg.pool.Pool
20 | logger = logging.getLogger('db')
21 |
22 |
23 | def v1_derive_key(uuid, salt):
24 | """derives a key from a uuid+unique salt using scrypt"""
25 | return base64.urlsafe_b64encode(
26 | Scrypt(
27 | salt=salt,
28 | length=32,
29 | n=2**10,
30 | r=8,
31 | p=1,
32 | backend=default_backend()
33 | ).derive(uuid.encode('ascii'))
34 | )
35 |
36 |
37 | def v1_hash_uuid(uuid):
38 | """
39 | hashes a uuid using SHA256 (these are the primary keys of the database)
40 | we can't use a unique salt here because we need the hash to find the row
41 | """
42 | digest = hashes.Hash(hashes.SHA256(), backend=default_backend())
43 | digest.update((uuid + config.HASH_PEPPER).encode())
44 | return digest.finalize()
45 |
46 |
47 | def v2_hash_uuid(uuid):
48 | """
49 | Converts a (variable length) uuid into a database key and encryption key by
50 | splitting the SHA512 hash
51 | """
52 | digest = hashes.Hash(hashes.SHA512(), backend=default_backend())
53 | digest.update((uuid + config.HASH_PEPPER).encode())
54 | digest = digest.finalize()
55 | return digest[:32], base64.urlsafe_b64encode(digest[32:])
56 |
57 |
58 | async def init():
59 | global pool
60 | logger.info('Creating connection pool...')
61 | pool = await asyncpg.create_pool(
62 | database=config.DB_NAME,
63 | user=config.DB_USERNAME,
64 | host=config.DB_HOST,
65 | password=config.DB_PASSWORD
66 | )
67 |
68 | logger.info('Creating tables...')
69 | async with pool.acquire() as con:
70 | await con.execute('''
71 | CREATE TABLE IF NOT EXISTS spoilers (
72 | hash BYTEA PRIMARY KEY,
73 | timestamp INTEGER DEFAULT date_part('epoch', now()),
74 | salt BYTEA,
75 | token BYTEA
76 | )
77 | ''')
78 | await con.execute('''
79 | CREATE TABLE IF NOT EXISTS spoilers_v2 (
80 | hash BYTEA PRIMARY KEY,
81 | timestamp INTEGER DEFAULT date_part('epoch', now()),
82 | token BYTEA,
83 | owner BIGINT
84 | )
85 | ''')
86 | await pool.execute('VACUUM;')
87 | await pool.execute('TRUNCATE spoilers_v2;')
88 | logger.info('Initialized.')
89 |
90 |
91 | async def _get_spoiler_v1(uuid: str):
92 | """
93 | Gets a spoiler from the v1 schema
94 | """
95 | # try to find uuid by hash in the database
96 | db_hash = v1_hash_uuid(uuid)
97 | data = await pool.fetchrow(
98 | 'SELECT salt, token FROM spoilers WHERE hash=$1',
99 | db_hash
100 | )
101 |
102 | if not data:
103 | return None
104 |
105 | # Decrypt the data and decode it
106 | try:
107 | key = v1_derive_key(uuid, data['salt'])
108 | data = Fernet(key).decrypt(data['token'])
109 | except InvalidSignature:
110 | # this shouldn't happen unless someone messes with the database
111 | return None
112 |
113 | return Spoiler(**json.loads(data))
114 |
115 |
116 | async def get_spoiler(uuid: UUID):
117 | db_hash, key = v2_hash_uuid(uuid.db_key)
118 |
119 | data = await pool.fetchval(
120 | 'SELECT token FROM spoilers_v2 WHERE hash=$1',
121 | db_hash
122 | )
123 |
124 | if not data:
125 | return await _get_spoiler_v1(uuid.db_key)
126 |
127 | # Decrypt the data and decode it
128 | try:
129 | data = Fernet(key).decrypt(data)
130 | except InvalidSignature:
131 | # this shouldn't happen unless someone messes with the database
132 | return None
133 | return Spoiler(**json.loads(data))
134 |
135 |
136 | async def insert_spoiler(uuid: UUID, spoiler: Spoiler, owner_id: int):
137 | data = json.dumps(dataclasses.asdict(spoiler)).encode()
138 |
139 | db_hash, key = v2_hash_uuid(uuid.db_key)
140 | token = Fernet(key).encrypt(data)
141 |
142 | await pool.execute(
143 | 'INSERT INTO spoilers_v2 (hash, token, owner) VALUES ($1, $2, $3)',
144 | db_hash, token, owner_id
145 | )
--------------------------------------------------------------------------------
/database_id.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 |
4 | from construct import (
5 | BitStruct, Default, ExprAdapter, Flag, GreedyBytes, Int16ul, Padding, Struct,
6 | Transformed, obj_,
7 | )
8 | from telethon.utils import _decode_telegram_base64, _encode_telegram_base64
9 |
10 | from util import tt_from_hex, tt_to_hex
11 |
12 |
13 | class UUID:
14 | Flags = Transformed(
15 | BitStruct(
16 | Padding(4),
17 | 'is_major' / Default(Flag, False),
18 | 'skip_saving' / Default(Flag, False),
19 | 'timestamp' / Default(Flag, True),
20 | '_unused' / Default(Flag, False),
21 | ),
22 | lambda v: v.translate(tt_from_hex), 1,
23 | lambda v: v.translate(tt_to_hex), 1
24 | )
25 | DBKey = Struct(
26 | 'timestamp' / Default(
27 | ExprAdapter(Int16ul, obj_ << 16, (obj_ >> 16) & 0xFFFF),
28 | lambda this: int(time.time())
29 | ),
30 | 'random' / Default(GreedyBytes, lambda this: os.urandom(45)),
31 | )
32 |
33 | def __init__(self, old=''):
34 | flag_byte = old[0].encode('utf8') if old else UUID.Flags.build({})
35 | self.flags = UUID.Flags.parse(flag_byte)
36 | if old:
37 | self.db_key = old[1:]
38 | else:
39 | self.db_key = _encode_telegram_base64(UUID.DBKey.build({}))
40 |
41 | @property
42 | def is_major(self):
43 | return self.flags.is_major
44 |
45 | def read_timestamp(self):
46 | if not self.flags.timestamp:
47 | return
48 | parsed = UUID.DBKey.parse(_decode_telegram_base64(self.db_key))
49 | return parsed.timestamp
50 |
51 | def get_str(self, is_major=None, skip_saving=None):
52 | if is_major is not None:
53 | self.flags.is_major = is_major
54 | if skip_saving is not None:
55 | self.flags.skip_saving = skip_saving
56 | flag = UUID.Flags.build(self.flags)
57 | return flag.decode('ascii') + self.db_key
58 |
--------------------------------------------------------------------------------
/file_id.py:
--------------------------------------------------------------------------------
1 | import enum
2 |
3 | from construct import (
4 | Aligned, BitStruct, Byte, Bytes, BytesInteger, Const, Default, Enum, ExprValidator,
5 | Flag, GreedyBytes, If, IfThenElse, Indexing, Int32sl, Int64sl, OffsettedEnd, Peek, Pointer,
6 | Prefixed, Select, Sequence, StopIf, Struct, ValidationError, obj_, this,
7 | )
8 | from telethon import utils
9 | from telethon.types import InputDocument, InputPhoto
10 |
11 |
12 | class StrictEnum(Enum):
13 | def _decode(self, obj, context, path):
14 | try:
15 | return self.decmapping[obj]
16 | except KeyError:
17 | pass
18 | raise ValidationError(f'{obj} is not a valid enum value for {path}')
19 |
20 |
21 | class MajorVersion(enum.IntEnum):
22 | OLD = 2
23 | NEW = 4
24 |
25 |
26 | # https://github.com/tdlib/td/blob/master/td/telegram/files/FileType.h
27 | class FileType(enum.IntEnum):
28 | Thumbnail = 0
29 | ProfilePhoto = enum.auto()
30 | Photo = enum.auto()
31 | VoiceNote = enum.auto()
32 | Video = enum.auto()
33 | Document = enum.auto()
34 | Encrypted = enum.auto()
35 | Temp = enum.auto()
36 | Sticker = enum.auto()
37 | Audio = enum.auto()
38 | Animation = enum.auto()
39 | EncryptedThumbnail = enum.auto()
40 | Wallpaper = enum.auto()
41 | VideoNote = enum.auto()
42 | SecureDecrypted = enum.auto()
43 | SecureEncrypted = enum.auto()
44 | Background = enum.auto()
45 | DocumentAsFile = enum.auto()
46 | Ringtone = enum.auto()
47 | CallLog = enum.auto()
48 | PhotoStory = enum.auto()
49 | VideoStory = enum.auto()
50 | Size = enum.auto()
51 | None_ = enum.auto()
52 |
53 |
54 | # https://github.com/tdlib/td/blob/master/td/telegram/Version.h
55 | class TDVersion(enum.IntEnum):
56 | SupportRepliesInOtherChats = 51
57 | Next = enum.auto()
58 |
59 |
60 | PhotoFileTypes = {
61 | FileType.Photo,
62 | FileType.ProfilePhoto,
63 | FileType.Thumbnail,
64 | FileType.EncryptedThumbnail,
65 | FileType.Wallpaper,
66 | FileType.PhotoStory
67 | }
68 | Int24sl = BytesInteger(3, signed=True, swapped=True)
69 |
70 | TLString = Aligned(4, Prefixed(
71 | Select(
72 | ExprValidator(Byte, obj_ < 254),
73 | Indexing(Sequence(Const(254, Byte), Int24sl), 2, 1, empty=254),
74 | ),
75 | GreedyBytes
76 | ))
77 |
78 | _WebLocation = Struct(
79 | 'url' / TLString,
80 | 'access_hash' / Int64sl
81 | )
82 |
83 | _FileIDData = Struct(
84 | 'version' / Default(Pointer(-1, Byte), TDVersion.Next - 1),
85 | # Flags only occur in 4th byte, assume type is intended to be 3 bytes
86 | 'type' / StrictEnum(Int24sl, FileType),
87 | 'flags' / BitStruct(
88 | '_unused_1' / Default(Flag, False),
89 | '_unused_2' / Default(Flag, False),
90 | '_unused_3' / Default(Flag, False),
91 | '_unused_4' / Default(Flag, False),
92 | '_unused_5' / Default(Flag, False),
93 | '_unused_6' / Default(Flag, False),
94 | 'file_reference' / Default(Flag, False),
95 | 'web_location' / Default(Flag, False),
96 | ),
97 | 'dc_id' / Int32sl,
98 | 'file_reference' / IfThenElse(this.flags.file_reference, TLString, Bytes(0)),
99 |
100 | 'web_location' / If(this.flags.web_location, _WebLocation),
101 | StopIf(this.web_location),
102 |
103 | 'id' / Int64sl,
104 | 'access_hash' / Int64sl,
105 |
106 | # TODO: parse photo_size
107 | 'photo_size' / If(
108 | lambda this: int(this.type) in PhotoFileTypes,
109 | OffsettedEnd(-1, GreedyBytes)
110 | ),
111 | 'version' / Default(Byte, TDVersion.Next - 1)
112 | )
113 |
114 | FileID = Struct(
115 | 'major_version' / Default(Pointer(-1, StrictEnum(Byte, MajorVersion)), MajorVersion.NEW),
116 | 'data' / IfThenElse(
117 | lambda this: int(this.major_version) == MajorVersion.NEW,
118 | OffsettedEnd(-1, _FileIDData),
119 | _FileIDData,
120 | ),
121 | 'major_version' / If(this.major_version == MajorVersion.NEW, Byte)
122 | )
123 |
124 |
125 | def file_id_to_input_media(file_id):
126 | data = utils._rle_decode(utils._decode_telegram_base64(file_id))
127 | parsed = FileID.parse(data)
128 |
129 | if parsed.data.web_location:
130 | raise ValueError(f'Can\'t get input media from web location file_id: {file_id} url={parsed.data.web_location.url}')
131 |
132 | if parsed.data.photo_size:
133 | return InputPhoto(
134 | id=parsed.data.id,
135 | access_hash=parsed.data.access_hash,
136 | file_reference=parsed.data.file_reference
137 | )
138 |
139 | return InputDocument(
140 | id=parsed.data.id,
141 | access_hash=parsed.data.access_hash,
142 | file_reference=parsed.data.file_reference
143 | )
144 |
--------------------------------------------------------------------------------
/handlers_callback.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import time
3 |
4 | from telethon import events
5 | from telethon.errors import PeerIdInvalidError, UserIsBlockedError
6 |
7 | import database
8 | from config import MINOR_SPOILER_CACHE_TIME, MULTIPLE_CLICK_TIMEOUT
9 | from message_serializer import deserialize_to_params, extract_contents
10 | from proxy import client, logger, me
11 | from util import format_exception
12 |
13 |
14 | # TODO: gc old taps
15 | pending_double_taps = {}
16 |
17 |
18 | @client.on(events.CallbackQuery())
19 | async def on_callback(event: events.CallbackQuery.Event):
20 | uuid = database.UUID(event.data.decode('ascii'))
21 | tap_id = (event.message_id, event.query.user_id)
22 |
23 | if uuid.is_major and int(time.monotonic()) - pending_double_taps.get(tap_id, 0) > MULTIPLE_CLICK_TIMEOUT:
24 | await event.answer(message='Please tap again to see the spoiler')
25 | pending_double_taps[tap_id] = int(time.monotonic())
26 | return
27 |
28 | pending_double_taps.pop(tap_id, None)
29 | spoiler = await database.get_spoiler(uuid)
30 | if not spoiler:
31 | logger.error(f"{event.query.user_id} requested missing spoiler uuid={uuid.get_str()} major={uuid.is_major} t={uuid.read_timestamp()}")
32 | await event.answer(message='Spoiler not found. Too old?', cache_time=60)
33 | return
34 | if spoiler.type == 'Text' and len(spoiler.content) <= 200:
35 | logger.info(f"{event.query.user_id} requested {spoiler.type} major={uuid.is_major}")
36 | await event.answer(
37 | message=spoiler.content,
38 | alert=True,
39 | cache_time=0 if uuid.is_major else MINOR_SPOILER_CACHE_TIME
40 | )
41 | return
42 |
43 | logger.info(f"deeplinking {event.query.user_id} for {spoiler.type} major={uuid.is_major} qid={event.id}")
44 | params = deserialize_to_params(spoiler)
45 | try:
46 | await asyncio.wait_for(client.send_message(event.query.user_id, **params), timeout=3.0)
47 | await event.answer(url=f't.me/{me.username}?start=ignore')
48 | # TODO: there might be more exceptions
49 | except (UserIsBlockedError, PeerIdInvalidError, asyncio.TimeoutError) as e:
50 | logger.info(f"failed to deeplink qid={event.id} ({format_exception(e)})")
51 | await event.answer(url=f't.me/{me.username}?start={uuid.get_str()}')
--------------------------------------------------------------------------------
/handlers_inline.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from telethon import custom, events, types
4 | from telethon.extensions import html
5 | from telethon.types import DocumentAttributeFilename, InputWebDocument
6 | import validators
7 |
8 | import database
9 | from config import MAX_INLINE_LENGTH
10 | from proxy import client, logger
11 | from structs import Spoiler
12 |
13 |
14 | make_webdoc_png = lambda url: InputWebDocument(
15 | url,
16 | size=0,
17 | mime_type='image/png',
18 | attributes=[DocumentAttributeFilename('image.png')]
19 | )
20 |
21 | IMAGE_MINOR = make_webdoc_png('https://i.imgur.com/qrViKOz.png')
22 | IMAGE_MAJOR = make_webdoc_png('https://i.imgur.com/6oSoT16.png')
23 |
24 |
25 | def is_url(url):
26 | try:
27 | return validators.url(url)
28 | except validators.ValidationError:
29 | return False
30 |
31 |
32 | def spoiler_from_query(query):
33 | m = re.match(r'(?s)(?P.*(?::::))?(?P.*)', query)
34 | return Spoiler('Text', (m['desc'] or '').removesuffix(':::'), m['text'])
35 |
36 |
37 | def get_url_button(text, uuid, spoiler):
38 | return custom.Button.url(text, spoiler.content)
39 |
40 |
41 | def get_normal_button(text, uuid, spoiler):
42 | return custom.Button.inline(text, uuid)
43 |
44 |
45 | def get_inline_results(event, uuid: database.UUID, spoiler: Spoiler):
46 | get_button = get_normal_button
47 | if spoiler.type == 'Text' and is_url(spoiler.content):
48 | uuid.flags.skip_saving = True
49 | spoiler.type = 'URL'
50 | get_button = get_url_button
51 |
52 | # modify the inline description and reply text of the result if a custom description is set
53 | if spoiler.description:
54 | description_fmt = f'{spoiler.type}, custom title, {{}}'
55 | text_fmt = '<{tag}>{inner}:{tag}> ' + f'{html.escape(spoiler.description)}
'
56 | else:
57 | description_fmt = f'{spoiler.type}, {{}}'
58 | text_fmt = '<{tag}>{inner}{inner_suf}{tag}>'
59 |
60 | results = []
61 | uuid_str = uuid.get_str(is_major=True)
62 | results.append(event.builder.article(
63 | title='Major Spoiler',
64 | description=description_fmt.format('double tap'),
65 | thumb=IMAGE_MAJOR,
66 | text=text_fmt.format(tag='b', inner='Major Spoiler', inner_suf='!'),
67 | buttons=get_button('Double tap to show spoiler', uuid_str, spoiler),
68 | id=uuid_str
69 | ))
70 | uuid_str = uuid.get_str(is_major=False)
71 | results.append(event.builder.article(
72 | title='Minor Spoiler',
73 | description=description_fmt.format('single tap'),
74 | thumb=IMAGE_MINOR,
75 | text=text_fmt.format(tag='i', inner='Minor Spoiler', inner_suf=''),
76 | buttons=get_button('Show spoiler', uuid_str, spoiler),
77 | id=uuid_str
78 | ))
79 | return results
80 |
81 |
82 | @client.on(events.InlineQuery(pattern='id:(.+)'))
83 | async def on_inline_id(event: events.InlineQuery.Event):
84 | uuid = database.UUID(event.pattern_match[1])
85 | spoiler = await database.get_spoiler(uuid)
86 | results = []
87 | switch_pm_text = 'Spoiler not found'
88 | if spoiler:
89 | logger.info(f"{event.query.user_id} requested {spoiler.type}")
90 | uuid.flags.skip_saving = True
91 | results = get_inline_results(event, uuid, spoiler)
92 | switch_pm_text = 'Advanced spoiler (media etc.)…'
93 |
94 | await event.answer(
95 | results=results,
96 | cache_time=1,
97 | switch_pm=switch_pm_text,
98 | switch_pm_param='inline'
99 | )
100 |
101 | raise events.StopPropagation
102 |
103 |
104 | @client.on(events.InlineQuery())
105 | async def on_inline_text(event: events.InlineQuery.Event):
106 | uuid = database.UUID()
107 |
108 | results = []
109 | if len(event.text) >= MAX_INLINE_LENGTH:
110 | switch_pm_text = 'Too long! Use an advanced spoiler!'
111 | else:
112 | switch_pm_text = 'Advanced spoiler (media etc.)…'
113 | spoiler = spoiler_from_query(event.text)
114 | if spoiler.content:
115 | results = get_inline_results(event, uuid, spoiler)
116 |
117 | await event.answer(
118 | results=results,
119 | cache_time=1,
120 | switch_pm=switch_pm_text,
121 | switch_pm_param='inline'
122 | )
123 |
124 | @client.on(events.Raw(types=types.UpdateBotInlineSend))
125 | async def on_inline_chosen(event):
126 | uuid = database.UUID(event.id)
127 | if uuid.flags.skip_saving:
128 | return
129 | spoiler = spoiler_from_query(event.query)
130 | logger.info(f'{event.user_id} created {spoiler.type}')
131 | await database.insert_spoiler(
132 | uuid=uuid,
133 | spoiler=spoiler,
134 | owner_id=event.user_id
135 | )
136 |
--------------------------------------------------------------------------------
/handlers_pm.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from telethon import custom, events
4 | from telethon.errors import UserIsBlockedError
5 | from telethon.types import PeerChannel
6 | from telethon.tl.functions.channels import LeaveChannelRequest
7 |
8 | import database
9 | from config import MAX_DESCRIPTION_LENGTH, CONTACT_TEXT
10 | from database import UUID
11 | from message_serializer import deserialize_to_params, serialize_messages
12 | from proxy import client, logger
13 | from util import suppress_exceptions
14 |
15 |
16 | # Basic conversation handler based on Telethon's (now deprecated) custom.Conversation
17 | class Conversation:
18 | def __init__(self, chat_id):
19 | self.chat_id = chat_id
20 | self._responses = asyncio.Queue()
21 |
22 | def put_response(self, response):
23 | self._responses.put_nowait(response)
24 |
25 | async def get_response(self, timeout):
26 | return await asyncio.wait_for(self._responses.get(), timeout)
27 |
28 | def __enter__(self):
29 | if self.chat_id in active_conversations:
30 | raise RuntimeError(f'Already waiting for response in chat {self.chat_id}')
31 | active_conversations[self.chat_id] = self
32 | return self
33 |
34 | def __exit__(self, exc_type, exc_val, exc_tb):
35 | del active_conversations[self.chat_id]
36 |
37 |
38 | active_conversations: dict[int, Conversation] = {}
39 |
40 |
41 | # This has to come before the conversation handler so it works while we're in a conversation
42 | @client.on(events.NewMessage(incoming=True, forwards=False, pattern='^/start (.{64})$'))
43 | @suppress_exceptions(logger, UserIsBlockedError)
44 | async def on_start_id(event: events.NewMessage.Event):
45 | uuid = database.UUID(event.pattern_match.group(1))
46 | spoiler = await database.get_spoiler(uuid)
47 | if not spoiler:
48 | logger.error(f"{event.chat_id} requested missing spoiler uuid={uuid.get_str()} t={uuid.read_timestamp()}")
49 | await event.respond('Spoiler not found. Too old?')
50 | raise events.StopPropagation
51 | logger.info(f"{event.chat_id} requested {spoiler.type}")
52 | params = deserialize_to_params(spoiler)
53 | await event.respond(**params)
54 | raise events.StopPropagation
55 |
56 |
57 | @client.on(events.NewMessage(incoming=True, func=lambda e: isinstance(e.peer_id, PeerChannel)))
58 | async def on_channel_msg(event: events.NewMessage.Event):
59 | logger.error(f'Leaving channel {event.peer_id}')
60 | await client(LeaveChannelRequest(event.peer_id))
61 | raise events.StopPropagation
62 |
63 |
64 | @client.on(events.NewMessage(incoming=True))
65 | async def on_conversation_message(event: events.NewMessage.Event):
66 | if event.chat_id in active_conversations:
67 | active_conversations[event.chat_id].put_response(event.message)
68 | raise events.StopPropagation
69 |
70 |
71 | class CancelledError(Exception):
72 | pass
73 |
74 |
75 | async def get_cancellable_response(conv: Conversation):
76 | while 1:
77 | response = await conv.get_response(timeout=6*50)
78 | if not response.media and response.raw_text.startswith('/cancel'):
79 | raise CancelledError
80 | if response.raw_text.startswith('/'):
81 | continue
82 | return response
83 |
84 |
85 | async def wait_for_album(conv: Conversation, message):
86 | """
87 | Waits for more messages if message is an album, because
88 | telegram sends them separately
89 | Thanks to @lonami for this code
90 | """
91 | if not message.grouped_id:
92 | return [message]
93 | items = [message]
94 |
95 | try:
96 | while 1:
97 | item = await conv.get_response(timeout=0.1)
98 | if item.grouped_id != items[0].grouped_id:
99 | break
100 | items.append(item)
101 | except asyncio.TimeoutError:
102 | pass
103 | return items
104 |
105 |
106 | @client.on(events.NewMessage(incoming=True, forwards=False, pattern='^/start( inline)?$'))
107 | @client.on(events.NewMessage(incoming=True, func=lambda e: e.media))
108 | @suppress_exceptions(logger, UserIsBlockedError)
109 | async def on_media_or_start(event: events.NewMessage.Event):
110 | try:
111 | with Conversation(event.chat_id) as conv:
112 | content_msg = event.message
113 | if event.pattern_match:
114 | await event.respond(
115 | 'Preparing a spoiler. To cancel, type /cancel.\n\n'
116 | 'First send the content to be spoiled. It can be text, photo, or any other media.'
117 | )
118 | content_msg = await get_cancellable_response(conv)
119 | spoiler = serialize_messages(await wait_for_album(conv, content_msg))
120 | content_msg = None
121 |
122 | await event.respond(
123 | f'Now send a title for the spoiler (up to {MAX_DESCRIPTION_LENGTH} characters). '
124 | 'It will be immediately visible and can be used to add a small description '
125 | 'for your spoiler.\n'
126 | 'Type a dash (-) now if you do not want a title for your spoiler.'
127 | )
128 | description = ''
129 | while not description:
130 | response = await get_cancellable_response(conv)
131 | if response.fwd_from or response.media:
132 | continue
133 | description = response.raw_text
134 | if description == '-':
135 | description = ''
136 | break
137 | if len(description) > MAX_DESCRIPTION_LENGTH:
138 | await event.respond(
139 | f'The given title is too long (up to {MAX_DESCRIPTION_LENGTH} characters).\n'
140 | 'Please try again.'
141 | )
142 | description = ''
143 | spoiler.description = description
144 |
145 | logger.info(f'{event.chat_id} created {spoiler.type}')
146 | uuid = UUID()
147 | await database.insert_spoiler(uuid, spoiler, event.chat_id)
148 | await event.respond(
149 | 'Done! Your advanced spoiler is ready.',
150 | buttons=custom.Button.switch_inline(
151 | 'Send it!',
152 | f'id:{uuid.get_str()}'
153 | )
154 | )
155 | except (asyncio.TimeoutError, CancelledError):
156 | from_inline = event.pattern_match and event.pattern_match.group(1)
157 | await event.respond(
158 | 'The spoiler preparation has been cancelled.',
159 | buttons=custom.Button.switch_inline('OK') if from_inline else None
160 | )
161 | except UserIsBlockedError:
162 | raise
163 | except Exception as e:
164 | logger.exception(f'Error processing media')
165 | await event.respond(
166 | f'There was an error processing the request: {e}\n'
167 | 'The spoiler preparation has been cancelled.'
168 | )
169 |
170 |
171 | @client.on(events.NewMessage(incoming=True, forwards=False, pattern='^/start ignore$'))
172 | async def on_start_ignored(event: events.NewMessage.Event):
173 | await event.delete()
174 |
175 |
176 | @client.on(events.NewMessage(incoming=True, pattern='^/help$'))
177 | async def on_help(event: events.NewMessage.Event):
178 | await event.respond(
179 | 'Send me media or /start to prepare an advanced spoiler with a custom title.\n'
180 | '\n'
181 | 'You can type quick spoilers by using @spoilerobot in inline mode:\n'
182 | '@spoilerobot spoiler here…
\n'
183 | '\n'
184 | 'Custom titles can also be used from inline mode as follows:\n'
185 | '@spoilerobot title for the spoiler:::contents of the spoiler
\n'
186 | '\n'
187 | 'Note that the title will be immediately visible!\n'
188 | '\n'
189 | + (CONTACT_TEXT if CONTACT_TEXT else ''),
190 | link_preview=False
191 | )
192 |
193 |
194 | @client.on(events.NewMessage(incoming=True, pattern='^/ping$'))
195 | async def on_ping(event: events.NewMessage.Event):
196 | await event.reply('pong')
197 |
--------------------------------------------------------------------------------
/message_serializer.py:
--------------------------------------------------------------------------------
1 | """
2 | Handles serializing and deserializing of messages
3 | """
4 | import json
5 | import logging
6 | from typing import Any, List
7 |
8 | from telethon import types
9 | from telethon.extensions import html
10 | from telethon.tl.custom import Message
11 |
12 | from structs import ContentGeneric, Spoiler
13 | from util import pack_bot_file_id
14 | from file_id import file_id_to_input_media
15 |
16 |
17 | logger = logging.getLogger('serializer')
18 | FILE_ID_KEYS = (
19 | 'file',
20 | # Old spoilers used these keys to store the file_id
21 | 'photo', 'audio', 'document', 'video', 'voice', 'sticker', 'video_note'
22 | )
23 |
24 |
25 | def serialize_messages(message_list: List[Message]):
26 | """
27 | Groups the content from multiple spoilers into one album spoiler
28 | """
29 | if len(message_list) == 1:
30 | return serialize_message(message_list[0])
31 | contents = [serialize_message(msg).content for msg in message_list]
32 | return Spoiler(type='Album', description='', content=contents)
33 |
34 |
35 | def serialize_message(message: Message) -> Spoiler:
36 | """
37 | Serializes a message for storing in the database
38 | """
39 | data = {}
40 | content_type = None
41 | if message.text:
42 | data['text'] = message.text
43 | content_type = 'HTML'
44 | if message.photo:
45 | data['file'] = pack_bot_file_id(message.media)
46 | content_type = 'Photo'
47 | if message.document:
48 | data['file'] = pack_bot_file_id(message.document)
49 | content_type = 'Document'
50 | if message.contact:
51 | content_type = 'Contact'
52 | data['phone_number'] = message.contact.phone_number
53 | data['first_name'] = message.contact.first_name
54 | data['last_name'] = message.contact.last_name
55 | if message.geo:
56 | content_type = 'Location'
57 | # TODO: test empty geopoint and fix venue
58 | data['lat'] = message.geo.lat
59 | data['long'] = message.geo.long
60 | data['rad'] = message.geo.accuracy_radius
61 | if message.poll:
62 | raise ValueError('resending polls is not possible.')
63 | if not content_type:
64 | logger.error(f'Failed to serialise: {message.stringify()}')
65 | raise ValueError('not implemented yet.')
66 | # Turn plain text's content into a string
67 | if content_type == 'HTML':
68 | data = data['text']
69 | return Spoiler(type=content_type, description='', content=data)
70 |
71 |
72 | def _extract_content(content_type, content) -> ContentGeneric:
73 | """
74 | Converts a database message into a ContentGeneric
75 | """
76 | if isinstance(content, str):
77 | is_media = content_type not in {'Text', 'HTML'}
78 | # old spoilers used a json string as the content, so attempt to decode it
79 | # (putting json inside json was not the best idea)
80 | decoded_content = None
81 | if is_media:
82 | try:
83 | decoded_content = json.loads(content)
84 | except json.JSONDecodeError:
85 | logger.error(f'Failed to decode nested JSON content: "{content}"')
86 | # This interprets media json that fails to decode as text
87 | # which is probably not desirable
88 | if not decoded_content:
89 | return ContentGeneric(
90 | html.escape(content) if content_type == 'Text' else content,
91 | file_id=None
92 | )
93 | content = decoded_content
94 |
95 | text = content.get('text', '') or content.get('caption', '')
96 | file_id: Any = None
97 | if content_type == 'Contact':
98 | file_id = types.InputMediaContact(
99 | phone_number=content['phone_number'],
100 | first_name=content['first_name'],
101 | last_name=content['last_name'],
102 | vcard=''
103 | )
104 | if content_type == 'Location':
105 | file_id = types.InputMediaGeoPoint(
106 | types.InputGeoPoint(
107 | lat=content['lat'],
108 | long=content['long'],
109 | accuracy_radius=content.get('rad')
110 | )
111 | )
112 | if not file_id:
113 | file_id = next((content[k] for k in FILE_ID_KEYS if k in content), None)
114 |
115 | return ContentGeneric(text, file_id)
116 |
117 |
118 | def extract_contents(spoiler: Spoiler) -> ContentGeneric | List[ContentGeneric]:
119 | """
120 | Extracts one or more ContentGeneric from a database spoiler
121 | """
122 | if isinstance(spoiler.content, list):
123 | return [_extract_content(spoiler.type, content) for content in spoiler.content]
124 |
125 | return _extract_content(spoiler.type, spoiler.content)
126 |
127 |
128 | def deserialize_to_params(spoiler: Spoiler):
129 | """
130 | Deserializes a database spoiler to a dict of params for sending
131 | """
132 | contents = extract_contents(spoiler)
133 | if isinstance(contents, list):
134 | return {
135 | 'message': [content.text for content in contents],
136 | 'file': [file_id_to_input_media(content.file_id) for content in contents if content.file_id],
137 | }
138 | return {
139 | 'message': contents.text,
140 | 'file': file_id_to_input_media(contents.file_id) if contents.file_id else None
141 | }
--------------------------------------------------------------------------------
/proxy.py:
--------------------------------------------------------------------------------
1 | """
2 | Acts as a proxy so handler modules can import the parent's client and logger.
3 | Another way of achieving this is to inject the variables with importlib, but
4 | static type checkers don't like that.
5 | """
6 | from logging import Logger
7 |
8 | from telethon import TelegramClient, types
9 |
10 |
11 | client: TelegramClient
12 | me: types.User
13 | logger: Logger
--------------------------------------------------------------------------------
/pytest/test_db_ids.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from pathlib import Path
3 |
4 | sys.path.append(str(Path(__file__).resolve().parent.parent))
5 | from database_id import UUID
6 |
7 | import pytest
8 |
9 |
10 | def test_setting_major_flag():
11 | uuid = UUID()
12 | as_major = uuid.get_str(is_major=True)
13 | as_minor = uuid.get_str(is_major=False)
14 | assert as_major != as_minor
15 | assert UUID(as_major).is_major == True
16 | assert UUID(as_minor).is_major == False
17 |
18 |
19 | def test_timestamp():
20 | # new IDs should have a timestamp
21 | uuid = UUID()
22 | assert type(uuid.read_timestamp()) is int
23 |
24 | # while old ones do not
25 | uuid = UUID('0abcdef')
26 | assert uuid.read_timestamp() is None
27 |
28 |
29 | def test_db_key():
30 | uuid = UUID()
31 | assert uuid.db_key == uuid.get_str()[1:]
32 |
33 | uuid = UUID('0abcdef')
34 | assert uuid.db_key == 'abcdef'
35 |
36 |
--------------------------------------------------------------------------------
/pytest/test_file_ids.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | from pathlib import Path
4 | from collections import namedtuple
5 |
6 | from telethon.types import InputDocument, InputPhoto
7 |
8 | sys.path.append(str(Path(__file__).resolve().parent.parent))
9 | from file_id import file_id_to_input_media, FileType
10 |
11 | import pytest
12 |
13 |
14 | FileIDInfo = namedtuple('FileIDInfo', ['version', 'type', 'decoded_len'])
15 |
16 | test_file_ids = [
17 | (FileIDInfo(2, 10, 25), 'CgADBAADEAADdp70UmWxYjGRAAHhcwI', InputDocument(id=5977576835070820368, access_hash=8349955807720419685, file_reference=b'')),
18 | (FileIDInfo(2, 2, 45), 'AgADBAADW64xG0pNUVKGYfLXAAE4KpsYOiIbAARXp3r_Oo_2VgMAAQMAAQI', InputPhoto(id=5931607164902813275, access_hash=-7265933472534732410, file_reference=b'')),
19 | (FileIDInfo(2, 3, 25), 'AwADBAAD7QMAAjUTQFMcIxOKzOdaBgI', InputDocument(id=5998815822011696109, access_hash=457933177807381276, file_reference=b'')),
20 | (FileIDInfo(2, 4, 25), 'BAADBAADogcAArdgAAFQnUehAV5hW28C', InputDocument(id=5764713862129518498, access_hash=8024114217472837533, file_reference=b'')),
21 | (FileIDInfo(2, 5, 25), 'BQADBAAD6AYAAvpXQFEAAZ1EoVr_OlEC', InputDocument(id=5854776246835087080, access_hash=5853271430439148800, file_reference=b'')),
22 | (FileIDInfo(2, 8, 25), 'CAADBAAD7gYAAlhKAVAzGvvp7hW3mAI', InputDocument(id=5764970739828524782, access_hash=-7442455743334507981, file_reference=b'')),
23 | (FileIDInfo(22, 2, 50), 'AgADBAADRbIxGyLLmVIAAVaXdB60ZIi0rrgaAAQBAAMCAAN5AAOhwgIAARYE', InputPhoto(id=5952011729892389445, access_hash=-8618565743982193152, file_reference=b'')),
24 | (FileIDInfo(22, 3, 26), 'AwADBAADGwUAAs172VOFbh3cxY7eVRYE', InputDocument(id=6041996495492744475, access_hash=6187539918506258053, file_reference=b'')),
25 | (FileIDInfo(22, 4, 26), 'BAADBAADTAcAAox3AAFTHCz4AVQe8hMWBA', InputDocument(id=5980911748327147340, access_hash=1437244577243737116, file_reference=b'')),
26 | (FileIDInfo(22, 5, 26), 'BQADBAADgQgAArT-GVO_uOpInaPq4RYE', InputDocument(id=5988097228613355649, access_hash=-2167740374696937281, file_reference=b'')),
27 | (FileIDInfo(22, 8, 26), 'CAADBAADnAYAAsZccVPmhdoPM3IwZxYE', InputDocument(id=6012688982989604508, access_hash=7435568548423566822, file_reference=b'')),
28 | (FileIDInfo(24, 2, 78), 'AgACAgQAAxkBAAEWkmFelteQLlgeHQI-YsWvDHZr9Gma5gACM7IxGxVWuVB_bPpCFFqGSFUWthsABAEAAwIAA3gAA5h_BQABGAQ', InputPhoto(id=5816775042376249907, access_hash=5225963460679593087, file_reference=b'\x01\x00\x16\x92a^\x96\xd7\x90.X\x1e\x1d\x02>b\xc5\xaf\x0cvk\xf4i\x9a\xe6')),
29 | (FileIDInfo(24, 3, 54), 'AwACAgQAAxkBAAEW7fFenM9YPwLwuoob3BZtmvb1CoW16QACRAcAAgv22FAE84X7W-PJtRgE', InputDocument(id=5825676645108811588, access_hash=-5347493098324364540, file_reference=b'\x01\x00\x16\xed\xf1^\x9c\xcfX?\x02\xf0\xba\x8a\x1b\xdc\x16m\x9a\xf6\xf5\n\x85\xb5\xe9')),
30 | (FileIDInfo(24, 4, 54), 'BAACAgQAAxkBAAEVh6xehNi07vJ1AAHLuCBj3RCSIf-tiLQAAvoGAAJLRyhQTEgBYjEDQ1kYBA', InputDocument(id=5775944909550782202, access_hash=6431988203447732300, file_reference=b'\x01\x00\x15\x87\xac^\x84\xd8\xb4\xee\xf2u\x00\xcb\xb8 c\xdd\x10\x92!\xff\xad\x88\xb4')),
31 | (FileIDInfo(24, 5, 54), 'BQACAgQAAxkBAAEWDCRejcQWXd0F2lHCsgABS1b7la5JZbsAAgwHAALxIWlQ6j6EfzGfkXYYBA', InputDocument(id=5794199714559690508, access_hash=8543785003040128746, file_reference=b'\x01\x00\x16\x0c$^\x8d\xc4\x16]\xdd\x05\xdaQ\xc2\xb2\x00KV\xfb\x95\xaeIe\xbb')),
32 | (FileIDInfo(24, 8, 54), 'CAACAgEAAxkBAAETN49eP3231peoKKhOXMunBHvqMHV64AACrgAD0d1kKcH99k4MNkUAARgE', InputDocument(id=2982752742944014510, access_hash=19481199885352385, file_reference=b'\x01\x00\x137\x8f^?}\xb7\xd6\x97\xa8(\xa8N\\\xcb\xa7\x04{\xea0uz\xe0')),
33 | (FileIDInfo(24, 9, 54), 'CQACAgQAAxkBAAEWYkVek3aDM2HolMZ0J1JCR94_9Rg3qQACmAUAAnJb6VN7PHXqnMXfxBgE', InputDocument(id=6046464519906002328, access_hash=-4260469444730078085, file_reference=b"\x01\x00\x16bE^\x93v\x833a\xe8\x94\xc6t'RBG\xde?\xf5\x187\xa9")),
34 | (FileIDInfo(25, 2, 78), 'AgACAgQAAxkBAAEX9Nherz8QY6I9NGXVjwz7MAM1uyFqPQACk7MxGy6reFGyxGqfpG7r106ntBsABAEAAwIAA3kAA9RMBgABGQQ', InputPhoto(id=5870630328790528915, access_hash=-2888093082699774798, file_reference=b'\x01\x00\x17\xf4\xd8^\xaf?\x10c\xa2=4e\xd5\x8f\x0c\xfb0\x035\xbb!j=')),
35 | (FileIDInfo(25, 4, 54), 'BAACAgIAAxkBAAEZgqZeyXW_c5Ir7ZGaaaR5BSmk72zXcQAChQYAAuTvSEq-9s1Y_m8meBkE', InputDocument(id=5352791919661418117, access_hash=8657730471868626622, file_reference=b'\x01\x00\x19\x82\xa6^\xc9u\xbfs\x92+\xed\x91\x9ai\xa4y\x05)\xa4\xefl\xd7q')),
36 | (FileIDInfo(25, 5, 54), 'BQACAgQAAxkBAAEYOBletDtxNlCEdRADmKB-A_irYG05pwACNAcAAuplqVFVCcoQU-QAAYcZBA', InputDocument(id=5884346443833018164, access_hash=-8718717833174185643, file_reference=b'\x01\x00\x188\x19^\xb4;q6P\x84u\x10\x03\x98\xa0~\x03\xf8\xab`m9\xa7')),
37 | (FileIDInfo(25, 8, 54), 'CAACAgQAAxkBAAEYY95etsHr8vg36Rm4fVvvOQyf4XfxFgACk6UdAAGV22Ivo9AwCJnCDVgZBA', InputDocument(id=3414532900498810259, access_hash=6344941412558098595, file_reference=b'\x01\x00\x18c\xde^\xb6\xc1\xeb\xf2\xf87\xe9\x19\xb8}[\xef9\x0c\x9f\xe1w\xf1\x16')),
38 | (FileIDInfo(26, 2, 78), 'AgACAgQAAxkBAAEdAxRe_m21jjH1ZVS9Ligj5XZEZdCPeQACSbMxG5sP8VP07cNGfxd05E6XtxsABAEAAwIAA3kAAyp2CAABGgQ', InputPhoto(id=6048632933385876297, access_hash=-1984935700348015116, file_reference=b'\x01\x00\x1d\x03\x14^\xfem\xb5\x8e1\xf5eT\xbd.(#\xe5vDe\xd0\x8fy')),
39 | (FileIDInfo(26, 3, 54), 'AwACAgQAAxkBAAEbKXde42mCUUjOSfHdT6v5n1r-gQZ_kgACGQYAAjcZIFPQN3le-6afqRoE', InputDocument(id=5989815228416656921, access_hash=-6224072561450731568, file_reference=b'\x01\x00\x1b)w^\xe3i\x82QH\xceI\xf1\xddO\xab\xf9\x9fZ\xfe\x81\x06\x7f\x92')),
40 | (FileIDInfo(26, 4, 54), 'BAACAgEAAxkBAAEfo_9fJRNw4J2y2clIqfS2qhx3lZAjTgACtwEAAi23KEUbnvfjwKYAAdsaBA', InputDocument(id=4983434391586865591, access_hash=-2665947632014746085, file_reference=b'\x01\x00\x1f\xa3\xff_%\x13p\xe0\x9d\xb2\xd9\xc9H\xa9\xf4\xb6\xaa\x1cw\x95\x90#N')),
41 | (FileIDInfo(26, 5, 54), 'BQACAgQAAxkBAAEgZl5fMXOi0utBfzDnwuVuSeM8ci_HaAACwAcAAgZHiFF5VR0AAViU3k0aBA', InputDocument(id=5875023805000189888, access_hash=5611085291430172025, file_reference=b'\x01\x00 f^_1s\xa2\xd2\xebA\x7f0\xe7\xc2\xe5nI\xe3`w\r\xa2\x16D>>(\xb9\xb7@bP\xa6h\xf5\xf1XU')),
64 | (FileIDInfo(32, 13, 54), 'DQACAgQAAxkBAAFXpdVhKUnzQyGB8egMJT-KO1XwV2ElnQACPw0AAi7DSVEsTAgcIGAgniAE', InputDocument(id=5857427392707956031, access_hash=-7052531325436670932, file_reference=b'\x01\x00W\xa5\xd5a)I\xf3C!\x81\xf1\xe8\x0c%?\x8a;U\xf0Wa%\x9d')),
65 | (FileIDInfo(32, 2, 66), 'AgACAgQAAxkBAAFZ8JphQhM119MsfE9nvQABgNMAAW3upqltAAIauDEbayoIUv24iqRDQKc9AQADAgADcwADIAQ', InputPhoto(id=5911021150429886490, access_hash=4442590216691824893, file_reference=b'\x01\x00Y\xf0\x9aaB\x135\xd7\xd3,|Og\xbd\x00\x80\xd3\x00m\xee\xa6\xa9m')),
66 | (FileIDInfo(32, 3, 54), 'AwACAgQAAxkBAAFPMgxg3FhOGg-T_c0ycS18ILmnIbpq3QACMggAAtPz4VJUvjpXbgrmICAE', InputDocument(id=5972322668433639474, access_hash=2370593722883292756, file_reference=b'\x01\x00O2\x0c`\xdcXN\x1a\x0f\x93\xfd\xcd2q-| \xb9\xa7!\xbaj\xdd')),
67 | (FileIDInfo(32, 4, 54), 'BAACAgQAAxkBAAFTEt5hAAFf_xflfyOHjoBDrIZdAzqIB48AAnELAAK7egABUA1u1d_buZzLIAQ', InputDocument(id=5764742466611710833, access_hash=-3774938033639035379, file_reference=b'\x01\x00S\x12\xdea\x00_\xff\x17\xe5\x7f#\x87\x8e\x80C\xac\x86]\x03:\x88\x07\x8f')),
68 | (FileIDInfo(32, 5, 54), 'BQACAgQAAxkBAAFXAAGyYSQKtR2WwD14s2TEimPQsO-Jv5gAAr0LAALNVyFR7MXOA_FGTSQgBA', InputDocument(id=5846050329283529661, access_hash=2615824959537071596, file_reference=b'\x01\x00W\x00\xb2a$\n\xb5\x1d\x96\xc0=x\xb3d\xc4\x8ac\xd0\xb0\xef\x89\xbf\x98')),
69 | (FileIDInfo(32, 8, 54), 'CAACAgIAAxkBAAFZtw9hP33-9B8dUozd_qJgAAHyNPEGC_kAApQSAAKWO7oXwZVRfMOACVogBA', InputDocument(id=1709744523971662484, access_hash=6487858315296609729, file_reference=b'\x01\x00Y\xb7\x0fa?}\xfe\xf4\x1f\x1dR\x8c\xdd\xfe\xa2`\x00\xf24\xf1\x06\x0b\xf9')),
70 | (FileIDInfo(32, 9, 54), 'CQACAgQAAxkBAAFYPkZhL8cwYJ6fmK-brzzRbIV-6akddAACHAwAArqPgFHyYzLZSRYd1SAE', InputDocument(id=5872851943117818908, access_hash=-3090289262873910286, file_reference=b'\x01\x00X>Fa/\xc70`\x9e\x9f\x98\xaf\x9b\xaf<\xd1l\x85~\xe9\xa9\x1dt')),
71 | (FileIDInfo(33, 13, 54), 'DQACAgEAAxkBAAFem7xhegpwxm14B91viT_2XghaKv6G3QACpgIAAhj50UfirDgAAa3vhXshBA', InputDocument(id=5175191328299942566, access_hash=8900783764879748322, file_reference=b'\x01\x00^\x9b\xbcaz\np\xc6mx\x07\xddo\x89?\xf6^\x08Z*\xfe\x86\xdd')),
72 | (FileIDInfo(33, 2, 66), 'AgACAgQAAxkBAAFbpAABYVWIYqN2wCpq31FzVWsiSwAB0-WDAAI8tDEb-_-wUh-qSy1908fFAQADAgADeQADIQQ', InputPhoto(id=5958543760969282620, access_hash=-4195151993288021473, file_reference=b'\x01\x00[\xa4\x00aU\x88b\xa3v\xc0*j\xdfQsUk"K\x00\xd3\xe5\x83')),
73 | (FileIDInfo(33, 3, 54), 'AwACAgQAAxkBAAFeGKthdCEzRlD1tO0fjRx3eTAwK8s4SAACRgsAAngPoVORwshp62IJRCEE', InputDocument(id=6026114784468929350, access_hash=4902558432601096849, file_reference=b'\x01\x00^\x18\xabat!3FP\xf5\xb4\xed\x1f\x8d\x1cwy00+\xcb8H')),
74 | (FileIDInfo(33, 4, 54), 'BAACAgQAAxkBAAFcIUlhW2kAAVDL5qsAAUNol24veJrOOu5VAAJbCwACHebYUnfNL37S34s1IQQ', InputDocument(id=5969774318308035419, access_hash=3858423600926150007, file_reference=b'\x01\x00\\!Ia[i\x00P\xcb\xe6\xab\x00Ch\x97n/x\x9a\xce:\xeeU')),
75 | (FileIDInfo(33, 5, 54), 'BQACAgUAAxkBAAFd19VhcSFn9s2KOiINGgs8-Y2NNZvyngACGQIAAmLX-VaR1kcp_nvAYiEE', InputDocument(id=6267277172369523225, access_hash=7115823742789867153, file_reference=b'\x01\x00]\xd7\xd5aq!g\xf6\xcd\x8a:"\r\x1a\x0b<\xf9\x8d\x8d5\x9b\xf2\x9e')),
76 | (FileIDInfo(33, 8, 54), 'CAACAgIAAxkBAAFa2ophTFSEC5njg7SHgeXNgAAB_NhkoQ4AAmcBAAIQGm0igOKx4pV8RP0hBA', InputDocument(id=2480667625772810599, access_hash=-196895500502179200, file_reference=b'\x01\x00Z\xda\x8aaLT\x84\x0b\x99\xe3\x83\xb4\x87\x81\xe5\xcd\x80\x00\xfc\xd8d\xa1\x0e')),
77 | (FileIDInfo(33, 9, 54), 'CQACAgQAAxkBAAFc0PhhY_4AAcBw6-4TUIdYsaaCeDSm7qoAAmgHAAJBJBBSTDMvmZMqEL8hBA', InputDocument(id=5913266172328937320, access_hash=-4679193199419378868, file_reference=b'\x01\x00\\\xd0\xf8ac\xfe\x00\xc0p\xeb\xee\x13P\x87X\xb1\xa6\x82x4\xa6\xee\xaa')),
78 | (FileIDInfo(34, 13, 54), 'DQACAgQAAxkBAAFf7WthiZ_zE6HmbqazqCO8Lq1zR6p0uAACpAoAAtxmSFDuiV6fwHcSPyIE', InputDocument(id=5784986816436243108, access_hash=4544826643161450990, file_reference=b'\x01\x00_\xedka\x89\x9f\xf3\x13\xa1\xe6n\xa6\xb3\xa8#\xbc.\xadsG\xaat\xb8')),
79 | (FileIDInfo(34, 2, 66), 'AgACAgQAAxkBAAFh3B5hn9DCQjkAAeOCaKF6vsIuGpcRRN4AAkC3MRsWVgABUafihvFNOO97AQADAgADeAADIgQ', InputPhoto(id=5836759770017675072, access_hash=8930418493514769063, file_reference=b'\x01\x00a\xdc\x1ea\x9f\xd0\xc2B9\x00\xe3\x82h\xa1z\xbe\xc2.\x1a\x97\x11D\xde')),
80 | (FileIDInfo(34, 3, 54), 'AwACAgQAAxkBAAFgyzJhk7RmGGFS_fxjpXZ0B3_5-E-3WQACzwoAAuiBoFBlt0wXe6DJ6iIE', InputDocument(id=5809786352740338383, access_hash=-1528514147983247515, file_reference=b'\x01\x00`\xcb2a\x93\xb4f\x18aR\xfd\xfcc\xa5vt\x07\x7f\xf9\xf8O\xb7Y')),
81 | (FileIDInfo(34, 4, 54), 'BAACAgEAAxkBAAFiBYBhoZmDT8g1mb3PlEo8bwABOaUkFoEAApMCAAKqVAlFmkiqd9aHmasiBA', InputDocument(id=4974600352528597651, access_hash=-6081680466586744678, file_reference=b'\x01\x00b\x05\x80a\xa1\x99\x83O\xc85\x99\xbd\xcf\x94J\x00g\x85\xb0\xca.\xa6\x9d\x0f\xae')),
90 | (FileIDInfo(35, 8, 54), 'CAACAgEAAxkBAAFmAAEjYdAlVfei7l-vcIYAAfwknJqA3nzIAAIpAQACSPWPAlJxL8OeGOtlIwQ', InputDocument(id=184635799331930409, access_hash=7343990687516291410, file_reference=b'\x01\x00f\x00#a\xd0%U\xf7\xa2\xee_\xafp\x86\x00\xfc$\x9c\x9a\x80\xde|\xc8')),
91 | (FileIDInfo(36, 10, 54), 'CgACAgQAAxkBAAFzNtFidrq1AyfSg2K8XJs2tNXN-3tpCAACGgwAAjQtsVMvhYMqwjjggyQE', InputDocument(id=6030651077387357210, access_hash=-8944086453369731793, file_reference=b"\x01\x00s6\xd1bv\xba\xb5\x03'\xd2\x83b\xbc\\\x9b6\xb4\xd5\xcd\xfb{i\x08")),
92 | (FileIDInfo(36, 13, 54), 'DQACAgIAAxkBAAFyD3JiZmJ574x_bwQ94sZHFgxHWwgV6QACExoAAqZdMEtV12KeOJUjdiQE', InputDocument(id=5417933319272667667, access_hash=8512811791068354389, file_reference=b'\x01\x00r\x0frbfby\xef\x8c\x7fo\x04=\xe2\xc6G\x16\x0cG[\x08\x15\xe9')),
93 | (FileIDInfo(36, 2, 66), 'AgACAgQAAxkBAAFy7DticYKcLYv4981eQ2BbEP-XyboAAbYAAsO4MRuWH5FTluMr1-MS5wABAQADAgADeQADJAQ', InputPhoto(id=6021628906332862659, access_hash=65041489397015446, file_reference=b'\x01\x00r\xec;bq\x82\x9c-\x8b\xf8\xf7\xcd^C`[\x10\xff\x97\xc9\xba\x00\xb6')),
94 | (FileIDInfo(36, 3, 54), 'AwACAgQAAxkBAAFxhMViXtuCh9w7Z02GWbGQoUwPlMWaSwAC-QsAAjEh-VIXOmtOU5kQzyQE', InputDocument(id=5978846474648161273, access_hash=-3526149925154113001, file_reference=b'\x01\x00q\x84\xc5b^\xdb\x82\x87\xdc;gM\x86Y\xb1\x90\xa1L\x0f\x94\xc5\x9aK')),
95 | (FileIDInfo(36, 4, 54), 'BAACAgQAAxkBAAFxlApiYBD4ss2oigYxdqFfSTj9AAFwMs8AAloKAAKeYwABU81UJ9rgv421JAQ', InputDocument(id=5980889835404003930, access_hash=-5364420608674802483, file_reference=b'\x01\x00q\x94\nb`\x10\xf8\xb2\xcd\xa8\x8a\x061v\xa1_I8\xfd\x00p2\xcf')),
96 | (FileIDInfo(36, 5, 54), 'BQACAgQAAxkBAAFzMG9idlp4j18jlzrlQujTD3CDXNZ8zQAClAsAAj2fsVPQzY__QNDFhSQE', InputDocument(id=6030776460367629204, access_hash=-8807404518669038128, file_reference=b'\x01\x00s0obvZx\x8f_#\x97:\xe5B\xe8\xd3\x0fp\x83\\\xd6|\xcd')),
97 | (FileIDInfo(36, 8, 54), 'CAACAgEAAxkBAAFyIuBiZ6uowdDWSH2LgTQmcLhFzvKOeAACvwEAApOGsUVl2c7_-jEHYSQE', InputDocument(id=5021943025413128639, access_hash=6991611900619315557, file_reference=b'\x01\x00r"\xe0bg\xab\xa8\xc1\xd0\xd6H}\x8b\x814&p\xb8E\xce\xf2\x8ex')),
98 | (FileIDInfo(40, 2, 66), 'AgACAgQAAxkBAAF41rpisCUah_uM_8qVOOY6QPET_JW7VAACQLoxGw5VgFGReEc5VAABQKIBAAMCAAN5AAMoBA', InputPhoto(id=5872787433165273664, access_hash=-6755399079317505903, file_reference=b'\x01\x00x\xd6\xbab\xb0%\x1a\x87\xfb\x8c\xff\xca\x958\xe6:@\xf1\x13\xfc\x95\xbbT')),
99 | (FileIDInfo(40, 4, 54), 'BAACAgQAAxkBAAF48X9isKgS5wABTEameXC3AAHNcghsUEn8AAIsDQACTYaJUSByT_2l_soyKAQ', InputDocument(id=5875374854152129836, access_hash=3660017636064850464, file_reference=b'\x01\x00x\xf1\x7fb\xb0\xa8\x12\xe7\x00LF\xa6yp\xb7\x00\xcdr\x08lPI\xfc')),
100 | (FileIDInfo(40, 5, 54), 'BQACAgUAAxkBAAF44FlisFztiL04wJJyYqTkotB_81ahtwACFgYAAoaxgFVFUytr_L8jqygE', InputDocument(id=6161119479326574102, access_hash=-6114832778188336315, file_reference=b'\x01\x00x\xe0Yb\xb0\\\xed\x88\xbd8\xc0\x92rb\xa4\xe4\xa2\xd0\x7f\xf3V\xa1\xb7')),
101 | (FileIDInfo(41, 13, 54), 'DQACAgIAAxkBAAGAygABYvzXkOJtRhq2gT_QE8skz-KaBzIAApceAAJwBOhLBenDjh6Cde8pBA', InputDocument(id=5469626626524323479, access_hash=-1191903458613794555, file_reference=b'\x01\x00\x80\xca\x00b\xfc\xd7\x90\xe2mF\x1a\xb6\x81?\xd0\x13\xcb$\xcf\xe2\x9a\x072')),
102 | (FileIDInfo(41, 2, 66), 'AgACAgQAAxkBAAGCpwABYw52V8nThwpbNps9E3pGS7z3AAE0AALZuzEbSVBwUCojdPS4-i8FAQADAgADeAADKQQ', InputPhoto(id=5796220995344907225, access_hash=373792966377218858, file_reference=b'\x01\x00\x82\xa7\x00c\x0evW\xc9\xd3\x87\n[6\x9b=\x13zFK\xbc\xf7\x004')),
103 | (FileIDInfo(41, 3, 54), 'AwACAgIAAxkBAAF7aAliyLWutc4DqUhuw_m-kuuepSsy4gACeCQAAuQNSUqqATorLUphEikE', InputDocument(id=5352824905010259064, access_hash=1324421323282842026, file_reference=b'\x01\x00{h\tb\xc8\xb5\xae\xb5\xce\x03\xa9Hn\xc3\xf9\xbe\x92\xeb\x9e\xa5+2\xe2')),
104 | (FileIDInfo(41, 4, 54), 'BAACAgQAAxkBAAGEKgxjG9DnxrMMTqbc6BZ_fgAB4AmmUFAAApgNAAItRuFQJwABaq-7VIl7KQQ', InputDocument(id=5828016551881608600, access_hash=8901739303553073191, file_reference=b'\x01\x00\x84*\x0cc\x1b\xd0\xe7\xc6\xb3\x0cN\xa6\xdc\xe8\x16\x7f~\x00\xe0\t\xa6PP')),
105 | (FileIDInfo(41, 5, 54), 'BQACAgQAAxkBAAGGGB1jLAaASItKxH6AbdULMz1aduMbTAACFA8AAkA6YFFJggHIHVoy6ikE', InputDocument(id=5863750761388707604, access_hash=-1571094236042788279, file_reference=b'\x01\x00\x86\x18\x1dc,\x06\x80H\x8bJ\xc4~\x80m\xd5\x0b3=Zv\xe3\x1bL')),
106 | (FileIDInfo(41, 8, 54), 'CAACAgIAAxkBAAGBi2tjBVP5Sn9wkeTq4chYmtRuKnlGVQACRMkBAAFji0YMV3qwRIdBUyIpBA', InputDocument(id=884547634143021380, access_hash=2473392669585341015, file_reference=b'\x01\x00\x81\x8bkc\x05S\xf9J\x7fp\x91\xe4\xea\xe1\xc8X\x9a\xd4n*yFU')),
107 | (FileIDInfo(41, 9, 54), 'CQACAgQAAxkBAAF6JkhiuxGxZmixRgtpoUDXZE76wgMYfAACOwwAApBv4FEl1m3_KoyijikE', InputDocument(id=5899838176121326651, access_hash=-8168812657794755035, file_reference=b'\x01\x00z&Hb\xbb\x11\xb1fh\xb1F\x0bi\xa1@\xd7dN\xfa\xc2\x03\x18|')),
108 | (FileIDInfo(42, 13, 54), 'DQACAgQAAxkBAAGIxX1jRDLY6raQAw6LUKRJ0WigtGE9hwAC0QsAAtl1IFJS7dIAAYp22agqBA', InputDocument(id=5917859485233187793, access_hash=-6279857870300058286, file_reference=b'\x01\x00\x88\xc5}cD2\xd8\xea\xb6\x90\x03\x0e\x8bP\xa4I\xd1h\xa0\xb4a=\x87')),
109 | (FileIDInfo(42, 2, 66), 'AgACAgQAAxkBAAGGk1VjMXyIinai6Rm4nhiMCQABk1zhZ1UAArO7MRv_t4hRAAHlD9G49XTIAQADAgADeQADKgQ', InputPhoto(id=5875148020205599667, access_hash=-4002303994695260928, file_reference=b'\x01\x00\x86\x93Uc1|\x88\x8av\xa2\xe9\x19\xb8\x9e\x18\x8c\t\x00\x93\\\xe1gU')),
110 | (FileIDInfo(42, 4, 54), 'BAACAgQAAxkBAAGMZcxjX3SV1LQxg4gWyQABb-wxS6vBfJMAAtALAAI8aQABU0wBk173AAEuoSoE', InputDocument(id=5980896011566975952, access_hash=-6832522522230849204, file_reference=b'\x01\x00\x8ce\xccc_t\x95\xd4\xb41\x83\x88\x16\xc9\x00o\xec1K\xab\xc1|\x93')),
111 | (FileIDInfo(42, 5, 54), 'BQACAgUAAxkBAAGMDDxjXNHw2NC8VBBm_N0JCzbAjVzihgACJAkAAh4T6VY6DE3oGQxo5ioE', InputDocument(id=6262557776405334308, access_hash=-1844210741997138886, file_reference=b'\x01\x00\x8c\x0c\xdf)\xdd')),
119 | (FileIDInfo(44, 2, 66), 'AgACAgQAAxkBAAGTAAF0Y6B0GfJCqdeZTHkFc7VzaGhFs1UAAh-9MRvlmAABUf1vwkTmaTbFAQADAgADeAADLAQ', InputPhoto(id=5836833226843340063, access_hash=-4236081961778384899, file_reference=b'\x01\x00\x93\x00tc\xa0t\x19\xf2B\xa9\xd7\x99Ly\x05s\xb5shhE\xb3U')),
120 | (FileIDInfo(44, 4, 54), 'BAACAgIAAxkBAAGTr5Fjq1y8aTZbKG_RAAEDKH_qqhUmIjsAAlwoAALbsklJo5wcJyo8oSgsBA', InputDocument(id=5280948691736209500, access_hash=2927687384510012579, file_reference=b'\x01\x00\x93\xaf\x91c\xab\\\xbci6[(o\xd1\x00\x03(\x7f\xea\xaa\x15&";')),
121 | (FileIDInfo(44, 5, 54), 'BQACAgEAAxkBAAGTRENjpO9vKEHGMxSH8YA9EOadzVA_FwACbggAAlY9sEb9pHR1fTDkpCwE', InputDocument(id=5093638618132514926, access_hash=-6565069041399716611, file_reference=b'\x01\x00\x93DCc\xa4\xefo(A\xc63\x14\x87\xf1\x80=\x10\xe6\x9d\xcdP?\x17')),
122 | (FileIDInfo(44, 8, 54), 'CAACAgIAAxkBAAGT0I9jrZOSbvIIMtqdlJ9o0eUex469LAACYQoAAhvQIUgOcE7WUesfiSwE', InputDocument(id=5197664259344960097, access_hash=-8565869229515050994, file_reference=b'\x01\x00\x93\xd0\x8fc\xad\x93\x92n\xf2\x082\xda\x9d\x94\x9fh\xd1\xe5\x1e\xc7\x8e\xbd,')),
123 | (FileIDInfo(45, 13, 54), 'DQACAgQAAxkBAAGU38tjyTI5AAG-Zt5y6gh3Gn_nTWhgtDoAAgMNAAJvuUlSK7A0hT6EC44tBA', InputDocument(id=5929474270802480387, access_hash=-8211324091522306005, file_reference=b'\x01\x00\x94\xdf\xcbc\xc929\x00\xbef\xder\xea\x08w\x1a\x7f\xe7Mh`\xb4:')),
124 | (FileIDInfo(45, 2, 66), 'AgACAgQAAxkBAAGT9zpjsB3v1YzBSN1poef9mcuResFsTgACRrkxGwABaoFR3CsFa8BQxo8BAAMCAAN5AAMtBA', InputPhoto(id=5873091937756625222, access_hash=-8086687293537702948, file_reference=b'\x01\x00\x93\xf7:c\xb0\x1d\xef\xd5\x8c\xc1H\xddi\xa1\xe7\xfd\x99\xcb\x91z\xc1lN')),
125 | (FileIDInfo(45, 3, 54), 'AwACAgQAAxkBAAGVLJhj1U5j5EgJbjqylcFBppRIjp2ZWwAC-wwAAmYpsFLd4ks8WinuMC0E', InputDocument(id=5958307825074572539, access_hash=3525801025813078749, file_reference=b'\x01\x00\x95,\x98c\xd5Nc\xe4H\tn:\xb2\x95\xc1A\xa6\x94H\x8e\x9d\x99[')),
126 | (FileIDInfo(45, 4, 54), 'BAACAgQAAxkBAAGUkw9jv-hmzZ7YjNP34HkoVTwIYf4ygQACYQ8AAp2aAAFSGE_eMYWfjo8tBA', InputDocument(id=5908892710210637665, access_hash=-8102363285176824040, file_reference=b'\x01\x00\x94\x93\x0fc\xbf\xe8f\xcd\x9e\xd8\x8c\xd3\xf7\xe0y(U<\x08a\xfe2\x81')),
127 | (FileIDInfo(45, 5, 54), 'BQACAgUAAxkBAAGVJiBj1C8CYrIaTTJVmpzJSHbqsPg7EgACDwcAAj8aoFbNsUBa-3MPNS0E', InputDocument(id=6242017941420771087, access_hash=3823402132049015245, file_reference=b'\x01\x00\x95& c\xd4/\x02b\xb2\x1aM2U\x9a\x9c\xc9Hv\xea\xb0\xf8;\x12')),
128 | (FileIDInfo(46, 13, 54), 'DQACAgIAAxkBAAGWQOtkCEns6HGcgyoHuQ6hM6JsWmlrJAACASQAAgHSQEhCcoHFYDkSpy4E', InputDocument(id=5206392070977102849, access_hash=-6407996232001555902, file_reference=b'\x01\x00\x96@\xebd\x08I\xec\xe8q\x9c\x83*\x07\xb9\x0e\xa13\xa2lZik$')),
129 | (FileIDInfo(46, 2, 66), 'AgACAgQAAxkBAAGVrGZj7Ne2l45guqYAAXqI4N-L96WbmUoAAuG6MRvaBWlTBBnq9RlJf58BAAMCAAN5AAMuBA', InputPhoto(id=6010341612019890913, access_hash=-6953758923787986684, file_reference=b'\x01\x00\x95\xacfc\xec\xd7\xb6\x97\x8e`\xba\xa6\x00z\x88\xe0\xdf\x8b\xf7\xa5\x9b\x99J')),
130 | (FileIDInfo(46, 3, 54), 'AwACAgQAAxkBAAGWOvhkB2WTlsdb-937-bb_f0ShvXtmkwACSA4AAkoWOFB0Hbn12XYNAi4E', InputDocument(id=5780394628813426248, access_hash=147905041275624820, file_reference=b'\x01\x00\x96:\xf8d\x07e\x93\x96\xc7[\xfb\xdd\xfb\xf9\xb6\xff\x7fD\xa1\xbd{f\x93')),
131 | (FileIDInfo(46, 4, 54), 'BAACAgIAAxkBAAGWGcZkAAEed5EWmaRlcXEnnPTKgegkKMoAAgYkAAKOSQFIhp8lDEgNLiAuBA', InputDocument(id=5188509119941714950, access_hash=2318805461234982790, file_reference=b"\x01\x00\x96\x19\xc6d\x00\x1ew\x91\x16\x99\xa4eqq'\x9c\xf4\xca\x81\xe8$(\xca")),
132 | (FileIDInfo(47, 13, 54), 'DQACAgQAAxkBAAGWmlBkG5X13AAB3rrJqoMivOdUX4tMPDMAAhIOAAJrgeFQ_TBKuFD1cusvBA', InputDocument(id=5828081689355619858, access_hash=-1480851600413413123, file_reference=b'\x01\x00\x96\x9aPd\x1b\x95\xf5\xdc\x00\xde\xba\xc9\xaa\x83"\xbc\xe7T_\x8bL<3')),
133 | (FileIDInfo(47, 2, 66), 'AgACAgQAAxkBAAGW1iRkJyzF93XiXaICmaC91EWYaCgGAgACDboxG3LrAAFQmh8T1gAB7cncAQADAgADcwADLwQ', InputPhoto(id=5764866398349277709, access_hash=-2537236327199203430, file_reference=b"\x01\x00\x96\xd6$d',\xc5\xf7u\xe2]\xa2\x02\x99\xa0\xbd\xd4E\x98h(\x06\x02")),
134 | (FileIDInfo(47, 3, 54), 'AwACAgEAAxkBAAGuVglkyF4QHpzpwxwT9jyp-9-qDhY0MQACRAMAAv97QUaDwTdupZQWsS8E', InputDocument(id=5062463791288025924, access_hash=-5686194041269010045, file_reference=b'\x01\x00\xaeV\td\xc8^\x10\x1e\x9c\xe9\xc3\x1c\x13\xf6<\xa9\xfb\xdf\xaa\x0e\x1641')),
135 | (FileIDInfo(47, 4, 54), 'BAACAgEAAxkBAAGaiadkZaj-CObjk80O0QABhr1mAAGRZ7ugAAL0AgACcI0wR6yUxA8AAb6dFi8E', InputDocument(id=5129755487750849268, access_hash=1629667547651806380, file_reference=b'\x01\x00\x9a\x89\xa7de\xa8\xfe\x08\xe6\xe3\x93\xcd\x0e\xd1\x00\x86\xbdf\x00\x91g\xbb\xa0')),
136 | (FileIDInfo(47, 5, 54), 'BQACAgQAAxkBAAGaBWBkZIAZv9UcHNfLtPSjbzBdiIzS1QACbyUAAomr-VIJqm6imclWFy8E', InputDocument(id=5978998585209922927, access_hash=1681753172561799689, file_reference=b'\x01\x00\x9a\x05`dd\x80\x19\xbf\xd5\x1c\x1c\xd7\xcb\xb4\xf4\xa3o0]\x88\x8c\xd2\xd5')),
137 | (FileIDInfo(48, 13, 54), 'DQACAgEAAxkBAAG6mqVk49P1LCCOIqlAphnpfE5m9L-TiQACXAMAArjOIUdfiQ0loHA7MzAE', InputDocument(id=5125605140593640284, access_hash=3691668152678975839, file_reference=b'\x01\x00\xba\x9a\xa5d\xe3\xd3\xf5, \x8e"\xa9@\xa6\x19\xe9|Nf\xf4\xbf\x93\x89')),
138 | (FileIDInfo(48, 2, 66), 'AgACAgEAAxkBAAHrQntlIAAB9P6WSodyWb7_ZyoFgIRqpi0AAsirMRtaiAABRVFChCuWPBRWAQADAgADeQADMAQ', InputPhoto(id=4972123909201701832, access_hash=6202649202468864593, file_reference=b'\x01\x00\xebB{e \x00\xf4\xfe\x96J\x87rY\xbe\xffg*\x05\x80\x84j\xa6-')),
139 | (FileIDInfo(48, 3, 54), 'AwACAgQAAxkBAAHjQGplGrhTa_QWoGFp7Sc88UtyyhJO5AACQwYAAi67OVAUBIQ6DAABaLYwBA', InputDocument(id=5780857402949633603, access_hash=-5302988508707421164, file_reference=b"\x01\x00\xe3@je\x1a\xb8Sk\xf4\x16\xa0ai\xed'<\xf1Kr\xca\x12N\xe4")),
140 | (FileIDInfo(48, 4, 54), 'BAACAgQAAxkBAAHEbdFk-egy5E4OM3KbZQABTnkWXOwAAS4TAAJyDAACR8d5UJj7HR9WS1-JMAQ', InputDocument(id=5798885102972832882, access_hash=-8548030734464582760, file_reference=b'\x01\x00\xc4m\xd1d\xf9\xe82\xe4N\x0e3r\x9be\x00Ny\x16\\\xec\x00.\x13')),
141 | (FileIDInfo(48, 5, 54), 'BQACAgQAAxkBAAH3lRhlKpX6YisxFWTDSH11S0S3bjbv7AACFxMAAuaMUVEy8qN4f3oAAeUwBA', InputDocument(id=5859619509656097559, access_hash=-1945420351120608718, file_reference=b'\x01\x00\xf7\x95\x18e*\x95\xfab+1\x15d\xc3H}uKD\xb7n6\xef\xec')),
142 | (FileIDInfo(48, 8, 54), 'CAACAgQAAxkBAAGzr-Vk1GyJGRsH4OVAKxPvScN1mAI-wgAC4AAD0TYoDGGn4KUs1uYIMAQ', InputDocument(id=876010398799626464, access_hash=641435484196743009, file_reference=b'\x01\x00\xb3\xaf\xe5d\xd4l\x89\x19\x1b\x07\xe0\xe5@+\x13\xefI\xc3u\x98\x02>\xc2')),
143 | (FileIDInfo(51, 10, 54), 'CgACAgQAAxkBAR9IF2VEh5H_AfIjvT7vKScNlB_3fkXyAAIGBwACh71AUO-TTtHkxexIMwQ', InputDocument(id=5782830309061953286, access_hash=5254792451789329391, file_reference=b"\x01\x01\x1fH\x17eD\x87\x91\xff\x01\xf2#\xbd>\xef)'\r\x94\x1f\xf7~E\xf2")),
144 | (FileIDInfo(51, 13, 54), 'DQACAgIAAxkBAUUieWVeRh7FM_C2DgxBuXluUxZp_8eFAAKEOQACTZjxStY9FUf7e5stMwQ', InputDocument(id=5400264884673853828, access_hash=3286356672256490966, file_reference=b'\x01\x01E"ye^F\x1e\xc53\xf0\xb6\x0e\x0cA\xb9ynS\x16i\xff\xc7\x85')),
145 | (FileIDInfo(51, 2, 66), 'AgACAgQAAxkBASETeGVFYI4K72d4PwABuKUDTuvABxYAAYgAApbAMRu6-ihSkw6oo5RWf9UBAAMCAAN5AAMzBA', InputPhoto(id=5920257387405623446, access_hash=-3062634025187799405, file_reference=b'\x01\x01!\x13xeE`\x8e\n\xefgx?\x00\xb8\xa5\x03N\xeb\xc0\x07\x16\x00\x88')),
146 | (FileIDInfo(51, 3, 54), 'AwACAgQAAxkBATfuq2VTwpTyYuNFpXLoHasR_YLICgTbAAL4DwACNcGIU-ZBsyDxJHOfMwQ', InputDocument(id=6019273335358099448, access_hash=-6957176381302947354, file_reference=b'\x01\x017\xee\xabeS\xc2\x94\xf2b\xe3E\xa5r\xe8\x1d\xab\x11\xfd\x82\xc8\n\x04\xdb')),
147 | (FileIDInfo(51, 4, 54), 'BAACAgIAAxkBATAZAAFlTi7EAAFaVD3S6JlXQRRKpxNf-qoAAu0tAAIvIOlKjGBHa02TmCQzBA', InputDocument(id=5397881014615813613, access_hash=2637019542547030156, file_reference=b'\x01\x010\x19\x00eN.\xc4\x00ZT=\xd2\xe8\x99WA\x14J\xa7\x13_\xfa\xaa')),
148 | (FileIDInfo(51, 5, 54), 'BQACAgQAAxkBAU4QxWVkjWr6NyN4OVibVN-qL_rBFue0AALkEAACWTEgU09XYAJalulkMwQ', InputDocument(id=5989841762724614372, access_hash=7271508386697467727, file_reference=b'\x01\x01N\x10\xc5ed\x8dj\xfa7#x9X\x9bT\xdf\xaa/\xfa\xc1\x16\xe7\xb4')),
149 | (FileIDInfo(51, 8, 54), 'CAACAgQAAxkBAVeG4mVqSX0uJ8VhdZqrvQTI_4glj_3GAALxEAACXKFRU5fPsdGVDjMRMwQ', InputDocument(id=6003757194770649329, access_hash=1239350359088025495, file_reference=b"\x01\x01W\x86\xe2ejI}.'\xc5au\x9a\xab\xbd\x04\xc8\xff\x88%\x8f\xfd\xc6")),
150 | ]
151 |
152 |
153 | def idfn(info):
154 | if isinstance(info, (FileIDInfo,)):
155 | return f'ver={info.version} type={FileType(info.type).name} len={info.decoded_len}'
156 | return ''
157 |
158 |
159 | @pytest.mark.parametrize("info,file_id,ExpectedInputObj", test_file_ids, ids=idfn)
160 | def test_file_id_to_input_media(info, file_id, ExpectedInputObj):
161 | assert file_id_to_input_media(file_id) == ExpectedInputObj
--------------------------------------------------------------------------------
/shell.nix:
--------------------------------------------------------------------------------
1 | { pkgs ? import {} }:
2 | pkgs.mkShell {
3 | name = "spoilero-shell";
4 | buildInputs = [
5 | (pkgs.python311.withPackages (ps: with ps; [
6 | (callPackage ./telethon.nix {})
7 | asyncpg
8 | cryptography
9 | construct
10 | validators
11 | ]))
12 | ];
13 | shellHook = ''
14 | export token=${(builtins.readFile ./token.txt)}
15 | export admin_id=232787997
16 | export db_pwd=thegame
17 | export pepper=06d57f766ee56ddf
18 | '';
19 | }
--------------------------------------------------------------------------------
/spoilerobot.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import importlib
3 | import logging
4 | import os
5 |
6 | from telethon import TelegramClient, utils
7 |
8 | import database
9 | import proxy
10 | from config import ADMIN_ID, BOT_TOKEN
11 | from message_serializer import deserialize_to_params
12 | import file_id
13 |
14 |
15 | logging.basicConfig(level=logging.INFO)
16 | logger = logging.getLogger('main')
17 |
18 |
19 | def load_handler_module(name):
20 | proxy.logger = logging.getLogger(name)
21 | return importlib.import_module(name)
22 |
23 |
24 | async def main():
25 | await database.init()
26 |
27 | client = TelegramClient('spoilerobot', 6, 'eb06d4abfb49dc3eeb1aeb98ae0f581e')
28 | client.parse_mode = 'html'
29 |
30 | await client.start(bot_token=BOT_TOKEN)
31 |
32 | me = await client.get_me()
33 | logger.info(f'Running as @{me.username}')
34 |
35 | # Assign some vars to the proxy module so handlers have access to it
36 | proxy.client = client
37 | proxy.me = await client.get_me()
38 |
39 | load_handler_module('handlers_inline')
40 | load_handler_module('handlers_callback')
41 | load_handler_module('handlers_pm')
42 |
43 | await client.run_until_disconnected()
44 |
45 |
46 | asyncio.run(main())
--------------------------------------------------------------------------------
/structs.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import List, Optional
3 |
4 |
5 | @dataclass
6 | class Spoiler:
7 | type: str
8 | description: str
9 | # Text, media, or list of media
10 | content: str | dict | List[dict]
11 |
12 |
13 | @dataclass
14 | class ContentGeneric:
15 | text: str
16 | file_id: Optional[str]
--------------------------------------------------------------------------------
/telethon.nix:
--------------------------------------------------------------------------------
1 | { lib , buildPythonPackage , fetchFromGitHub , openssl , rsa , pyaes , pythonOlder , setuptools , pytest-asyncio , pytestCheckHook }:
2 |
3 | buildPythonPackage rec {
4 | pname = "telethon";
5 | version = "1.33.1";
6 | format = "pyproject";
7 | disabled = pythonOlder "3.5";
8 |
9 | src = fetchFromGitHub {
10 | owner = "LonamiWebs";
11 | repo = "Telethon";
12 | rev = "refs/tags/v${version}";
13 | hash = "sha256:1b125xhppwjz3rd5xarmwciwcxznjl08f93m226331kwa1ywca1z";
14 | };
15 |
16 | patchPhase = ''
17 | substituteInPlace telethon/crypto/libssl.py --replace \
18 | "ctypes.util.find_library('ssl')" "'${lib.getLib openssl}/lib/libssl.so'"
19 | '';
20 |
21 | nativeBuildInputs = [
22 | setuptools
23 | ];
24 |
25 | propagatedBuildInputs = [
26 | rsa
27 | pyaes
28 | ];
29 |
30 | # this is fine
31 | # nativeCheckInputs = [
32 | # pytest-asyncio
33 | # pytestCheckHook
34 | # ];
35 |
36 | # pytestFlagsArray = [
37 | # "tests/telethon"
38 | # ];
39 |
40 | meta = with lib; {
41 | homepage = "https://github.com/LonamiWebs/Telethon";
42 | description = "Full-featured Telegram client library for Python 3";
43 | license = licenses.mit;
44 | maintainers = with maintainers; [ nyanloutre ];
45 | };
46 | }
47 |
--------------------------------------------------------------------------------
/util.py:
--------------------------------------------------------------------------------
1 | import struct
2 | from functools import wraps
3 | import logging
4 | import traceback
5 |
6 | from telethon import types
7 | from telethon.types import struct
8 | from telethon.utils import _encode_telegram_base64, _rle_encode
9 |
10 |
11 | # Updated version from https://github.com/LonamiWebs/Telethon/pull/3255
12 | def pack_bot_file_id(file):
13 | """
14 | Inverse operation for `resolve_bot_file_id`.
15 |
16 | The only parameters this method will accept are :tl:`Document` and
17 | :tl:`Photo`, and it will return a variable-length ``file_id`` string.
18 |
19 | If an invalid parameter is given, it will ``return None``.
20 | """
21 | if isinstance(file, types.MessageMediaDocument):
22 | file = file.document
23 | elif isinstance(file, types.MessageMediaPhoto):
24 | file = file.photo
25 |
26 | if isinstance(file, types.Document):
27 | file_type = 5
28 | for attribute in file.attributes:
29 | if isinstance(attribute, types.DocumentAttributeAudio):
30 | file_type = 3 if attribute.voice else 9
31 | elif isinstance(attribute, types.DocumentAttributeVideo):
32 | file_type = 13 if attribute.round_message else 4
33 | elif isinstance(attribute, types.DocumentAttributeSticker):
34 | file_type = 8
35 | elif isinstance(attribute, types.DocumentAttributeAnimated):
36 | file_type = 10
37 | else:
38 | continue
39 | break
40 |
41 | return _encode_telegram_base64(_rle_encode(struct.pack(
42 | '