├── .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}: ' + f'{html.escape(spoiler.description)}' 56 | else: 57 | description_fmt = f'{spoiler.type}, {{}}' 58 | text_fmt = '<{tag}>{inner}{inner_suf}' 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 | '