├── .gitignore ├── LICENSE ├── README.md ├── base ├── __init__.py ├── base_ext.py ├── command_registry.py ├── db.py ├── db_migration.py ├── loader.py ├── mod_backup.py ├── mod_ext.py ├── mod_manager.py ├── module.py └── states.py ├── config.example.yaml ├── config.py ├── db.py ├── extensions └── PUT_EXTENSIONS_HERE ├── install.ps1 ├── install.sh ├── main.py ├── modules └── core │ ├── __init__.py │ ├── config.yaml │ ├── extensions │ ├── logs.py │ ├── mod_manage.py │ └── permissions.py │ ├── main.py │ └── strings │ ├── en.yaml │ ├── ru.yaml │ └── uk.yaml └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | venv/ 3 | .idea/ 4 | __pycache__/ 5 | config.yaml 6 | bot.log 7 | bot.session* 8 | bot_db.sqlite3 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 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 General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PBModular 2 | 3 | ![Python](https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54) 4 | ![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) 5 | ![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows11&logoColor=white) 6 | 7 | ![Python Version](https://img.shields.io/badge/python-%3E%203.11-blue) 8 | ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/PBModular/bot) 9 | ![GitHub](https://img.shields.io/github/license/PBModular/bot) 10 | 11 | PBModular is a lightweight and flexible bot framework designed for anything you code. Something between a userbot and a standard bot. 12 | 13 | ## Key Features 14 | 15 | * **Modular Design:** Easily extend and customize your bot features with a plugin-based [modules](https://github.com/PBModular/) 16 | * **Cross-Platform:** Supports Linux, Windows, and Android (Termux). 17 | * **Note for Windows and Android:** Remove the `uvloop` dependency from `requirements.txt` before installation. 18 | * **Open Source:** Contribute, modify, and adapt the bot to your specific needs. 19 | 20 | ## Getting Started 21 | 22 | ### Quick Installation (Linux/Windows) 23 | 24 | We provide convenient installation scripts for Linux and Windows: 25 | 26 | **Linux:** 27 | 28 | ```bash 29 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/pbmodular/bot/master/install.sh)" 30 | ``` 31 | 32 | **Windows (PowerShell):** 33 | 34 | ```powershell 35 | iex ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PBModular/bot/master/install.ps1')) 36 | ``` 37 | 38 | ### Manual Installation 39 | 40 | 1. **Clone the repository:** 41 | 42 | ```bash 43 | git clone https://github.com/PBModular/bot PBModular 44 | ``` 45 | 46 | 2. **Install dependencies (using a virtual environment is recommended):** 47 | 48 | ```bash 49 | python -m venv venv 50 | source venv/bin/activate 51 | pip install -r requirements.txt 52 | ``` 53 | 54 | 3. **Configure your bot:** 55 | 56 | ```bash 57 | cp config.example.yaml config.yaml 58 | nano config.yaml # Edit the configuration file 59 | ``` 60 | 61 | 4. **Run the bot:** 62 | 63 | ```bash 64 | python main.py 65 | ``` 66 | 67 | ## Running as a Systemd Service (Linux) 68 | 69 | Use this example systemd service file to run your bot automatically at system boot: 70 | 71 | ```systemd 72 | [Unit] 73 | Description=PBModular Bot 74 | After=network.target 75 | 76 | [Service] 77 | WorkingDirectory=/path/to/bot/sources 78 | Type=simple 79 | User=your_user 80 | # If you don't use venv, change python path to /usr/bin/python3 in a command below 81 | ExecStart=/path/to/bot/sources/venv/bin/python3 -u /path/to/bot/sources/main.py 82 | # Restart bot after fail 83 | Restart=always 84 | RestartSec=10 85 | 86 | [Install] 87 | WantedBy=multi-user.target 88 | ``` 89 | 90 | Remember to replace placeholders like `/path/to/bot/sources` and `your_user` with your actual paths and username. 91 | 92 | ## Documentation 93 | 94 | * **Russian:** [https://pbmodular.github.io/wiki/ru/](https://pbmodular.github.io/wiki/ru/) 95 | * **English:** [https://pbmodular.github.io/wiki/](https://pbmodular.github.io/wiki/) 96 | 97 | Want to contribute documentation in your language? Contact [@SanyaPilot](https://github.com/SanyaPilot) or [@Ultra119](https://github.com/Ultra119) 98 | 99 | ## Windows Support Notice 100 | 101 | While we strive for cross-platform compatibility, Windows support is not fully guaranteed. Minor issues might arise due to the primary development environment being *nix-based. 102 | 103 | ## Contributors 104 | 105 | * **[@SanyaPilot](https://github.com/SanyaPilot)** ([Telegram](https://t.me/sanyapilot)) - Bot core, wiki 106 | * **[@CakesTwix](https://github.com/CakesTwix)** ([Telegram](https://t.me/CakesTwix)) - Translations 107 | * **[@vilander1337](https://github.com/vilander1337)** - Gitbook documentation, scripts 108 | * **[@Ultra119](https://github.com/Ultra119)** ([Telegram](https://t.me/Ultra119)) - New features, wiki site 109 | 110 | ## Contributing 111 | 112 | We welcome contributions! Feel free to open issues, submit pull requests, or join the discussion in our [Telegram Chat](https://t.me/PBModular_chat) 113 | 114 | ## License 115 | 116 | [GNU GPLv3](https://github.com/SanyaPilot/PBModular/blob/master/LICENSE) 117 | -------------------------------------------------------------------------------- /base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PBModular/bot/fa268e4bb342d85bb4e5cdde5c194edb2c397c21/base/__init__.py -------------------------------------------------------------------------------- /base/base_ext.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from dataclasses import dataclass 3 | from typing import Optional 4 | 5 | 6 | @dataclass 7 | class ExtensionInfo: 8 | name: str 9 | author: str 10 | version: str 11 | src_url: Optional[str] = None 12 | 13 | 14 | class BaseExtension(ABC): 15 | """ 16 | Class to extend ModuleLoader functionality. 17 | Can modify module object before stage2 initialization 18 | Useful for implementing features for every module 19 | """ 20 | 21 | @property 22 | @abstractmethod 23 | def extension_info(self) -> ExtensionInfo: 24 | """ 25 | Extension info. Must be set 26 | 27 | :return: ExtensionInfo dataclass object 28 | """ 29 | 30 | @abstractmethod 31 | def on_module(self, obj): 32 | """ 33 | Main method where extension must edit module object 34 | 35 | :param obj: BaseModule object 36 | """ 37 | -------------------------------------------------------------------------------- /base/command_registry.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | commands: dict[str, list[str]] = {} 4 | 5 | 6 | def register_command(owner: str, command: str, override: bool = False): 7 | """Register a command for the given owner. 8 | 9 | :param owner (str): The owner of the command. 10 | :param command (str): The command to register. 11 | :param override (bool): If True, overrides the command if it’s already registered to another owner. Defaults to False. 12 | 13 | :return ValueError: If the command is already registered to another owner and override is False. 14 | """ 15 | current_owner = get_command_owner(command) 16 | if current_owner and current_owner != owner and not override: 17 | raise ValueError(f"Command '{command}' is already registered to '{current_owner}'.") 18 | 19 | if owner not in commands: 20 | commands[owner] = [] 21 | if command not in commands[owner]: 22 | commands[owner].append(command) 23 | 24 | 25 | def get_commands(owner: str) -> list[str]: 26 | """Get the list of commands registered by the given owner. 27 | 28 | :param owner (str): The owner of the commands. 29 | :return: The list of commands, or an empty list if the owner is not found. 30 | """ 31 | return commands.get(owner, []) 32 | 33 | 34 | def check_command(command: str) -> bool: 35 | """Check if the command is registered by any owner. 36 | 37 | :param command (str): The command to check. 38 | :return bool: True if the command is registered, False otherwise. 39 | """ 40 | for cmds in commands.values(): 41 | if command in cmds: 42 | return True 43 | return False 44 | 45 | 46 | def get_command_owner(command: str) -> Optional[str]: 47 | """Get the owner of the given command. 48 | 49 | :param command (str): The command to find the owner for. 50 | :return Optional[str]: The owner of the command, or None if not found. 51 | """ 52 | for owner, cmds in commands.items(): 53 | if command in cmds: 54 | return owner 55 | return None 56 | 57 | 58 | def remove_all(owner: str) -> bool: 59 | """Remove all commands registered by the given owner. 60 | 61 | :param owner (str): The owner whose commands to remove. 62 | :return bool: True if the owner's commands were removed, False if the owner was not found. 63 | """ 64 | if owner in commands: 65 | del commands[owner] 66 | return True 67 | return False 68 | -------------------------------------------------------------------------------- /base/db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker 2 | 3 | from config import config 4 | import logging 5 | import traceback 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class Database: 11 | def __init__(self, modname: str): 12 | try: 13 | self.engine = create_async_engine(self.decide_url(modname)) 14 | self.session_maker = async_sessionmaker(self.engine, expire_on_commit=False) 15 | except Exception as e: 16 | logger.error("Failed to initialize database! Disabling for runtime! Error: %s", e) 17 | traceback.print_exc() 18 | self.engine = None 19 | self.session_maker = None 20 | 21 | @staticmethod 22 | def decide_url(modname: str) -> str: 23 | if "sqlite" in config.db_url: 24 | return config.db_url + f"/modules/{modname}/{config.db_file_name}" 25 | return config.db_url + f"/{modname}" 26 | -------------------------------------------------------------------------------- /base/db_migration.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from sqlalchemy.orm import Session 3 | from sqlalchemy import Engine, MetaData 4 | 5 | 6 | class DBMigration(ABC): 7 | """Class for handling database migrations between module updates""" 8 | 9 | @abstractmethod 10 | def apply(self, session: Session, engine: Engine, metadata: MetaData): 11 | """Main method where migration happens""" 12 | -------------------------------------------------------------------------------- /base/loader.py: -------------------------------------------------------------------------------- 1 | from base.module import BaseModule, ModuleInfo, Permissions, HelpPage 2 | from base.base_ext import BaseExtension 3 | from base.db import Database 4 | from config import config 5 | from base.mod_manager import ModuleManager 6 | 7 | from pyrogram import Client 8 | import requirements 9 | from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncEngine 10 | 11 | import asyncio 12 | import importlib 13 | import inspect 14 | import logging 15 | import os 16 | import sys 17 | import yaml 18 | from typing import Optional, Union 19 | import gc 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class ModuleLoader: 25 | """ 26 | Main module dispatcher 27 | Modules must be placed into modules/ directory as directories with __init__.py 28 | """ 29 | 30 | def __init__( 31 | self, 32 | bot: Client, 33 | root_dir: str, 34 | bot_db_session: async_sessionmaker, 35 | bot_db_engine: AsyncEngine, 36 | ): 37 | self.__bot = bot 38 | self.__modules: dict[str, BaseModule] = {} 39 | self.__modules_info: dict[str, ModuleInfo] = {} 40 | self.__all_modules_info: dict[str, ModuleInfo] = {} 41 | self.__modules_deps: dict[str, list[str]] = {} 42 | self.__root_dir = root_dir 43 | self.bot_db_session = bot_db_session 44 | self.bot_db_engine = bot_db_engine 45 | 46 | # Initialize the module manager 47 | self.mod_manager = ModuleManager(root_dir) 48 | 49 | # Load extensions 50 | self.__extensions: dict[str, BaseExtension] = {} 51 | extensions_dir = os.path.join(self.__root_dir, "extensions") 52 | extensions = os.listdir(path=extensions_dir) 53 | for ext in extensions: 54 | ext_path = os.path.join(extensions_dir, ext) 55 | if not os.path.isdir(ext_path): 56 | continue 57 | 58 | try: 59 | imported = importlib.import_module("extensions." + ext) 60 | except ImportError as e: 61 | logger.error(f"ImportError has occurred while loading extension {ext}!") 62 | logger.exception(e) 63 | continue 64 | 65 | for obj_name, obj in inspect.getmembers(imported, inspect.isclass): 66 | if BaseExtension in inspect.getmro(obj): 67 | # Check dependencies using absolute path 68 | if config.update_deps_at_load and os.path.exists(os.path.join(ext_path, "requirements.txt")): 69 | self.mod_manager.install_deps(ext, "extensions") 70 | instance: BaseExtension = obj() 71 | name = instance.extension_info.name 72 | self.__extensions[name] = instance 73 | logger.info(f"Successfully loaded extension {name}!") 74 | 75 | def load_everything(self): 76 | """Load all modules with auto_load enabled and gather info for all modules""" 77 | modules = os.listdir(path="./modules/") 78 | if "core" in modules: 79 | modules.remove("core") 80 | modules.insert(0, "core") 81 | 82 | modules_to_load = [] 83 | all_modules = [] 84 | 85 | for module in modules: 86 | if not os.path.isdir(f"./modules/{module}"): 87 | continue 88 | 89 | all_modules.append(module) 90 | auto_load = True 91 | 92 | config_path = f"./modules/{module}/config.yaml" 93 | 94 | if os.path.exists(config_path): 95 | try: 96 | with open(config_path, "r") as f: 97 | config_data = yaml.safe_load(f) or {} 98 | auto_load = config_data.get("info", {}).get("auto_load", True) 99 | except Exception as e: 100 | logger.error(f"Error reading config.yaml for module {module}: {e}") 101 | 102 | if auto_load: 103 | modules_to_load.append(module) 104 | else: 105 | logger.info(f"Module {module} has auto_load set to False, skipping loading") 106 | 107 | for module in modules_to_load: 108 | self.load_module(module) 109 | 110 | # Populate info for all modules (loaded or not) 111 | for module in all_modules: 112 | if module not in self.__modules_info: 113 | # Create basic info for non-loaded modules 114 | try: 115 | config_path = f"./modules/{module}/config.yaml" 116 | if os.path.exists(config_path): 117 | with open(config_path, "r") as f: 118 | config_data = yaml.safe_load(f) or {} 119 | info_block = config_data.get("info", {}) 120 | mod_info = ModuleInfo( 121 | name=info_block.get("name", module), 122 | author=info_block.get("author", ""), 123 | version=info_block.get("version", ""), 124 | description=info_block.get("description", ""), 125 | src_url=info_block.get("src_url", ""), 126 | python=info_block.get("python", ""), 127 | auto_load=info_block.get("auto_load", True) 128 | ) 129 | else: 130 | mod_info = ModuleInfo( 131 | name=module, 132 | author="", 133 | version="0.0.0", 134 | description="config.yaml not found.", 135 | src_url="", 136 | python="", 137 | auto_load=True 138 | ) 139 | self.__all_modules_info[module] = mod_info 140 | except Exception as e: 141 | logger.error(f"Error creating info for non-loaded module {module}: {e}") 142 | 143 | def load_module(self, name: str, skip_deps: bool = False) -> Optional[str]: 144 | """ 145 | Main loading method 146 | 147 | :param name: Name of Python module inside modules dir 148 | """ 149 | module_path = os.path.abspath(os.path.join(self.__root_dir, "modules", name)) 150 | if not os.path.exists(module_path): 151 | logger.error(f"Module directory {module_path} does not exist") 152 | return None 153 | 154 | req_path = os.path.join(module_path, "requirements.txt") 155 | if os.path.exists(req_path): 156 | if config.update_deps_at_load and not skip_deps: 157 | self.mod_manager.install_deps(name, "modules") 158 | 159 | # Load dependencies into dict 160 | self.__modules_deps[name] = [] 161 | with open(req_path, encoding="utf-8") as f: 162 | for req in requirements.parse(f): 163 | self.__modules_deps[name].append(req.name.lower()) 164 | 165 | try: 166 | imported = importlib.import_module("modules." + name) 167 | except ImportError as e: 168 | logger.error(f"ImportError has occurred while loading module {name}!") 169 | logger.exception(e) 170 | return None 171 | 172 | for obj_name, obj in inspect.getmembers(imported, inspect.isclass): 173 | if BaseModule in inspect.getmro(obj): 174 | try: 175 | instance: BaseModule = obj( 176 | self.__bot, 177 | self.get_modules_info, 178 | self.bot_db_session, 179 | self.bot_db_engine, 180 | module_path, 181 | ) 182 | perms = instance.module_permissions 183 | info = instance.module_info 184 | 185 | # Version check 186 | if info.python: 187 | parts = tuple(map(int, info.python.split('.'))) 188 | current_version = '.'.join(map(str, sys.version_info[:3])) 189 | if sys.version_info[1] != parts[1]: 190 | logger.warning( 191 | f"Module {name} tested on Python {info.python}, " 192 | f"current version is {current_version}, proceed with caution!" 193 | ) 194 | 195 | # Don't allow modules with more than 1 word in name 196 | if len(info.name.split()) > 1: 197 | logger.warning(f"Module {name} has invalid name. Skipping!") 198 | del instance 199 | return None 200 | 201 | if Permissions.require_db in perms and not config.enable_db: 202 | logger.warning(f"Module {name} requires DB, but it's disabled. Skipping!") 203 | del instance 204 | return None 205 | 206 | if (Permissions.use_db in perms or Permissions.require_db in perms) and config.enable_db: 207 | asyncio.create_task(instance.set_db(Database(name))) 208 | 209 | if Permissions.use_loader in perms: 210 | instance.loader = self 211 | 212 | # Stage 1 init passed ok, applying extensions 213 | for ext_name, ext in self.__extensions.items(): 214 | try: 215 | ext.on_module(instance) 216 | except Exception as e: 217 | logger.error(f"Extension {ext_name} failed on module {info.name}!") 218 | logger.exception(e) 219 | 220 | # Stage 2 221 | # Register everything for pyrogram 222 | instance.stage2() 223 | self.__modules[name] = instance 224 | self.__modules_info[name] = info 225 | self.__all_modules_info[name] = info 226 | 227 | # Custom init execution 228 | instance.on_init() 229 | 230 | # Clear hash backup if present 231 | self.mod_manager.clear_hash_backup(name) 232 | logger.info(f"Successfully imported module {info.name}!") 233 | return info.name 234 | except Exception as e: 235 | logger.error(f"Error loading module {name}! Printing traceback") 236 | logger.exception(e) 237 | return None 238 | return None 239 | 240 | async def unload_module(self, name: str): 241 | """ 242 | Method for unloading modules. 243 | 244 | :param name: Name of Python module inside modules dir 245 | """ 246 | # Before unloading, store the module info 247 | if name in self.__modules_info: 248 | self.__all_modules_info[name] = self.__modules_info[name] 249 | 250 | if module := self.__modules.get(name): 251 | module._BaseModule__state_machines.clear() 252 | 253 | self.__modules[name].on_unload() 254 | await self.__modules[name].unregister_all() 255 | self.__modules.pop(name) 256 | self.__modules_info.pop(name) 257 | try: 258 | self.__modules_deps.pop(name) 259 | except KeyError: 260 | pass 261 | 262 | # Clear imports 263 | del_keys = [key for key in sys.modules.keys() if name in key] 264 | for key in del_keys: 265 | del sys.modules[key] 266 | 267 | gc.collect() 268 | logger.info(f"Successfully unloaded module {name}!") 269 | 270 | def get_module(self, name: str) -> Optional[BaseModule]: 271 | """ 272 | Get module instance object 273 | 274 | :param name: Name of Python module inside modules dir 275 | :return: Module object 276 | """ 277 | return self.__modules.get(name) 278 | 279 | def get_modules_info(self) -> dict[str, ModuleInfo]: 280 | """ 281 | Get info about all loaded modules 282 | 283 | :return: Dictionary with ModuleInfo objects 284 | """ 285 | return self.__modules_info 286 | 287 | def get_all_modules_info(self) -> dict[str, ModuleInfo]: 288 | """ 289 | Get info about all modules, including unloaded ones 290 | 291 | :return: Dictionary with ModuleInfo objects for all modules 292 | """ 293 | return self.__all_modules_info 294 | 295 | def get_module_info(self, name: str) -> Optional[ModuleInfo]: 296 | """ 297 | Get module info regardless of load status 298 | 299 | :param name: Name of Python module inside modules dir 300 | :return: Object with module info 301 | """ 302 | mod_info = self.__modules_info.get(name) 303 | if mod_info is None: 304 | return self.__all_modules_info.get(name) 305 | else: 306 | return mod_info 307 | 308 | def get_module_help(self, name: str) -> Optional[Union[HelpPage, str]]: 309 | """ 310 | Get module help page 311 | 312 | :param name: Name of Python module inside modules dir 313 | :return: Help page as string 314 | """ 315 | mod = self.__modules.get(name) 316 | if mod is None: 317 | return None 318 | else: 319 | return mod.help_page 320 | 321 | def get_module_perms(self, name: str) -> list[Permissions]: 322 | """ 323 | Get module permissions 324 | 325 | :param name: Name of Python module inside modules dir 326 | :return: Object with permissions 327 | """ 328 | mod = self.__modules.get(name) 329 | if mod is None: 330 | return [] 331 | else: 332 | return mod.module_permissions 333 | 334 | def get_modules_deps(self) -> dict[str, list[str]]: 335 | """ 336 | Get module deps 337 | 338 | :return: __modules_deps object 339 | """ 340 | return self.__modules_deps 341 | 342 | def get_int_name(self, name: str) -> Optional[str]: 343 | """ 344 | Get internal name (name of a directory) of a module from user-friendly name 345 | 346 | :param name: User-friendly name of a module 347 | :return: Internal name of a module 348 | """ 349 | for n, info in self.__all_modules_info.items(): 350 | if info.name.lower() == name.lower(): 351 | return n 352 | 353 | return None 354 | 355 | async def prepare_for_module_update(self, name: str) -> Optional[BaseModule]: 356 | """ 357 | Unload module if loaded to prepare for update 358 | 359 | :param name: Name of Python module inside modules dir 360 | :return: The module instance that was unloaded, or None if not loaded 361 | """ 362 | module = None 363 | if name in self.__modules: 364 | module = self.__modules[name] 365 | await self.unload_module(name) 366 | return module 367 | -------------------------------------------------------------------------------- /base/mod_backup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import subprocess 4 | import shutil 5 | import time 6 | import zipfile 7 | from typing import Optional, Tuple, List 8 | import json 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | class BackupManager: 14 | """ 15 | Manages module backups and restoration for safe module updates. 16 | Provides both git-based and file-based backup/restore capabilities. 17 | """ 18 | 19 | def __init__(self, root_dir: str): 20 | self.__root_dir = root_dir 21 | self.__backup_dir = os.path.join(root_dir, "backups") 22 | self.__ensure_backup_dir() 23 | 24 | def __ensure_backup_dir(self): 25 | """Ensure the backup directory exists""" 26 | if not os.path.exists(self.__backup_dir): 27 | try: 28 | os.makedirs(self.__backup_dir) 29 | logger.info(f"Created backup directory at {self.__backup_dir}") 30 | except Exception as e: 31 | logger.error(f"Failed to create backup directory: {e}") 32 | 33 | def safe_remove_tree(self, path: str) -> List[str]: 34 | skipped_files = [] 35 | for root, dirs, files in os.walk(path, topdown=False): 36 | for file in files: 37 | file_path = os.path.join(root, file) 38 | try: 39 | os.remove(file_path) 40 | except Exception as e: 41 | logger.warning(f"Failed to remove file {file_path}: {e}") 42 | skipped_files.append(file_path) 43 | for dir in dirs: 44 | dir_path = os.path.join(root, dir) 45 | try: 46 | os.rmdir(dir_path) 47 | except Exception as e: 48 | logger.warning(f"Failed to remove directory {dir_path}: {e}") 49 | skipped_files.append(dir_path) 50 | return skipped_files 51 | 52 | def safe_copy_tree(self, src: str, dst: str) -> list[str]: 53 | skipped_files = [] 54 | for root, dirs, files in os.walk(src): 55 | rel_path = os.path.relpath(root, src) 56 | dest_dir = os.path.join(dst, rel_path) 57 | os.makedirs(dest_dir, exist_ok=True) 58 | for file in files: 59 | src_file = os.path.join(root, file) 60 | dst_file = os.path.join(dest_dir, file) 61 | try: 62 | shutil.copy2(src_file, dst_file) 63 | except Exception as e: 64 | logger.warning(f"Failed to copy file {src_file} to {dst_file}: {e}") 65 | skipped_files.append(dst_file) 66 | return skipped_files 67 | 68 | def create_backup(self, name: str, directory: str) -> Tuple[bool, str]: 69 | """ 70 | Create a backup of a module, skipping the .git folder and storing git metadata if applicable. 71 | 72 | :param name: Name of the module to backup 73 | :param directory: Directory containing the module (e.g., 'modules') 74 | :return Tuple: (success, backup_path or error_message) 75 | """ 76 | try: 77 | timestamp = time.strftime("%Y%m%d-%H%M%S") 78 | backup_filename = f"{name}_{timestamp}.zip" 79 | backup_path = os.path.join(self.__backup_dir, backup_filename) 80 | source_dir = os.path.join(self.__root_dir, directory, name) 81 | 82 | if not os.path.exists(source_dir): 83 | return False, f"Module directory {source_dir} does not exist" 84 | 85 | # Prepare metadata 86 | metadata = {} 87 | if os.path.exists(os.path.join(source_dir, ".git")): 88 | # Get current commit hash 89 | hash_p = subprocess.run( 90 | ["git", "rev-parse", "HEAD"], 91 | cwd=source_dir, 92 | stdout=subprocess.PIPE, 93 | stderr=subprocess.STDOUT 94 | ) 95 | if hash_p.returncode == 0: 96 | commit_hash = hash_p.stdout.decode("utf-8").strip() 97 | # Get untracked files 98 | untracked_p = subprocess.run( 99 | ["git", "ls-files", "--others", "--exclude-standard"], 100 | cwd=source_dir, 101 | stdout=subprocess.PIPE, 102 | stderr=subprocess.STDOUT 103 | ) 104 | if untracked_p.returncode == 0: 105 | untracked_files = untracked_p.stdout.decode("utf-8").splitlines() 106 | metadata = { 107 | "is_git_repo": True, 108 | "commit_hash": commit_hash, 109 | "untracked_files": untracked_files 110 | } 111 | else: 112 | logger.warning(f"Failed to get untracked files for {name}") 113 | metadata = {"is_git_repo": False} 114 | else: 115 | logger.warning(f"Failed to get commit hash for {name}") 116 | metadata = {"is_git_repo": False} 117 | else: 118 | metadata = {"is_git_repo": False} 119 | 120 | # Create zip backup 121 | with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf: 122 | # Write metadata 123 | zipf.writestr("backup_meta.json", json.dumps(metadata)) 124 | # Add files, excluding .git 125 | for root, dirs, files in os.walk(source_dir): 126 | if '.git' in dirs: 127 | dirs.remove('.git') 128 | rel_dir = os.path.relpath(root, source_dir) 129 | for file in files: 130 | if file == "backup_meta.json": 131 | continue 132 | file_path = os.path.join(root, file) 133 | arcname = os.path.join(rel_dir, file) if rel_dir != '.' else file 134 | zipf.write(file_path, arcname) 135 | 136 | logger.info(f"Created backup of module {name} at {backup_path}") 137 | return True, backup_path 138 | except Exception as e: 139 | logger.error(f"Failed to create backup for module {name}: {e}") 140 | return False, str(e) 141 | 142 | def restore_from_backup(self, backup_path: str, name: str, directory: str) -> Tuple[bool, List[str]]: 143 | """ 144 | Restore a module from a backup, performing a git reset and restoring untracked files for git repos. 145 | 146 | :param backup_path: Path to the backup zip file 147 | :param name: Name of the module to restore 148 | :param directory: Directory containing the module (e.g., 'modules') 149 | :return Tuple: (success, list of skipped files) 150 | """ 151 | try: 152 | module_dir = os.path.join(self.__root_dir, directory, name) 153 | if not os.path.exists(backup_path): 154 | logger.error(f"Backup file {backup_path} not found") 155 | return False, [] 156 | 157 | with zipfile.ZipFile(backup_path, 'r') as zipf: 158 | # Read metadata 159 | import json 160 | try: 161 | with zipf.open("backup_meta.json") as meta_file: 162 | metadata = json.load(meta_file) 163 | except KeyError: 164 | # Backward compatibility: treat as non-git backup 165 | metadata = {"is_git_repo": False} 166 | 167 | if metadata.get("is_git_repo", False) and metadata.get("commit_hash"): 168 | # Handle git repository restoration 169 | commit_hash = metadata["commit_hash"] 170 | reset_p = subprocess.run( 171 | ["git", "reset", "--hard", commit_hash], 172 | cwd=module_dir, 173 | stdout=subprocess.PIPE, 174 | stderr=subprocess.STDOUT 175 | ) 176 | if reset_p.returncode != 0: 177 | logger.error(f"Failed to reset repository for {name}: {reset_p.stdout.decode('utf-8')}") 178 | return False, [] 179 | 180 | # Remove all files except .git 181 | skipped_files = [] 182 | for item in os.listdir(module_dir): 183 | item_path = os.path.join(module_dir, item) 184 | if item != ".git": 185 | try: 186 | if os.path.isfile(item_path): 187 | os.remove(item_path) 188 | elif os.path.isdir(item_path): 189 | shutil.rmtree(item_path) 190 | except Exception as e: 191 | logger.warning(f"Failed to remove {item_path}: {e}") 192 | skipped_files.append(item_path) 193 | 194 | # Extract all files from the zip 195 | try: 196 | zipf.extractall(module_dir) 197 | except Exception as e: 198 | logger.error(f"Failed to extract backup for {name}: {e}") 199 | skipped_files.append(module_dir) 200 | 201 | logger.info(f"Restored git module {name} from backup {backup_path}") 202 | return True, skipped_files 203 | else: 204 | # Handle non-git restoration 205 | if os.path.exists(module_dir): 206 | skipped_remove = self.safe_remove_tree(module_dir) 207 | else: 208 | skipped_remove = [] 209 | os.makedirs(module_dir, exist_ok=True) 210 | try: 211 | zipf.extractall(module_dir) 212 | skipped_copy = [] 213 | except Exception as e: 214 | logger.error(f"Failed to extract backup for {name}: {e}") 215 | skipped_copy = [module_dir] 216 | logger.info(f"Restored non-git module {name} from backup {backup_path}") 217 | return True, skipped_remove + skipped_copy 218 | except Exception as e: 219 | logger.error(f"Failed to restore module {name} from backup: {e}") 220 | return False, [] 221 | 222 | def list_backups(self, name: Optional[str] = None) -> list: 223 | """ 224 | List available backups, optionally filtered by module name 225 | 226 | :param name: Optional name of the module to filter backups 227 | :return list: backup files (full paths) 228 | """ 229 | try: 230 | all_backups = [] 231 | if os.path.exists(self.__backup_dir): 232 | for file in os.listdir(self.__backup_dir): 233 | if file.endswith('.zip'): 234 | # If a module name is specified, filter for that module 235 | if name is None or file.startswith(f"{name}_"): 236 | all_backups.append(os.path.join(self.__backup_dir, file)) 237 | 238 | # Sort by modification time (newest first) 239 | all_backups.sort(key=lambda x: os.path.getmtime(x), reverse=True) 240 | return all_backups 241 | 242 | except Exception as e: 243 | logger.error(f"Error listing backups: {e}") 244 | return [] 245 | 246 | def get_latest_backup(self, name: str) -> Optional[str]: 247 | """ 248 | Get the most recent backup for a specific module 249 | 250 | :param name: Name of the module 251 | :return: Path to the most recent backup or None if no backups exist 252 | """ 253 | backups = self.list_backups(name) 254 | return backups[0] if backups else None 255 | 256 | def delete_backup(self, backup_path: str) -> bool: 257 | """ 258 | Delete a backup file 259 | 260 | :param backup_path: Path to the backup file to delete 261 | :return: Success status 262 | """ 263 | try: 264 | if os.path.exists(backup_path): 265 | os.remove(backup_path) 266 | logger.info(f"Deleted backup {backup_path}") 267 | return True 268 | return False 269 | except Exception as e: 270 | logger.error(f"Failed to delete backup {backup_path}: {e}") 271 | return False 272 | 273 | def cleanup_old_backups(self, name: str, keep_count: int = 5) -> int: 274 | """ 275 | Remove old backups of a module, keeping only the most recent ones 276 | 277 | :param name: Name of the module 278 | :param keep_count: Number of recent backups to keep 279 | :return: Number of backups deleted 280 | """ 281 | try: 282 | backups = self.list_backups(name) 283 | if len(backups) <= keep_count: 284 | return 0 285 | 286 | # Delete older backups 287 | deleted_count = 0 288 | for backup in backups[keep_count:]: 289 | if self.delete_backup(backup): 290 | deleted_count += 1 291 | 292 | return deleted_count 293 | except Exception as e: 294 | logger.error(f"Failed to clean up old backups for {name}: {e}") 295 | return 0 296 | -------------------------------------------------------------------------------- /base/mod_ext.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import os 4 | from base.module import BaseModule, Handler 5 | from typing import Union, Tuple 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class ModuleExtension: 11 | """ 12 | Module extension for BaseModule. Allows to split code in several files 13 | """ 14 | 15 | def __init__(self, base_mod: BaseModule): 16 | # Inherit some attrs from BaseModule object 17 | self.bot = base_mod.bot 18 | self.S = base_mod.S 19 | self.rawS = base_mod.rawS 20 | self.cur_lang = base_mod.cur_lang 21 | self.loader = base_mod.loader 22 | self.logger = base_mod.logger 23 | self.state_machine = base_mod.state_machine 24 | self.get_sm = base_mod.get_sm 25 | self.module_path = base_mod.module_path 26 | 27 | # Save base ref 28 | self.__base_mod = base_mod 29 | 30 | # Set the extension's path to the directory of its code file 31 | self.extension_path = os.path.dirname(sys.modules[self.__class__.__module__].__file__) 32 | 33 | # Register methods 34 | base_mod.register_all(ext=self) 35 | 36 | # Execute custom init 37 | self.on_init() 38 | 39 | def on_init(self): 40 | """Custom init goes here""" 41 | pass 42 | 43 | @property 44 | def db(self): 45 | return self.__base_mod.db 46 | 47 | @property 48 | def custom_handlers(self) -> list[Union[Handler, Tuple[Handler, int]]]: 49 | """ 50 | Custom handlers for specialized use cases (e.g., raw updates, specific message types). 51 | Override if necessary. 52 | 53 | Each item in the list should be either: 54 | 1. A Pyrogram Handler instance (e.g., MessageHandler, CallbackQueryHandler, RawUpdateHandler). 55 | These handlers will be added to the default group (0). 56 | 2. A tuple containing (Handler, int), where the integer specifies the Pyrogram handler group. 57 | 58 | Handlers are processed by group number, lowest first. Within a group, order is determined by PyroTGFork. 59 | See: https://telegramplayground.github.io/pyrogram/topics/more-on-updates.html#handler-groups 60 | """ 61 | return [] 62 | -------------------------------------------------------------------------------- /base/mod_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import subprocess 4 | import sys 5 | from urllib.parse import urlparse 6 | from typing import Optional, Union, Tuple 7 | from packaging import version 8 | import importlib 9 | import inspect 10 | import yaml 11 | 12 | from base.db_migration import DBMigration 13 | from base.mod_backup import BackupManager 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class ModuleManager: 19 | """ 20 | Handles module installation, updates, dependency management, and configuration. 21 | Includes backup and restoration capabilities. 22 | """ 23 | 24 | def __init__(self, root_dir: str): 25 | self.__root_dir = root_dir 26 | self.__hash_backups: dict[str, str] = {} 27 | self.__backup_manager = BackupManager(root_dir) 28 | 29 | def install_from_git(self, url: str) -> Tuple[int, str]: 30 | """ 31 | Module installation method. Clones git repository from the given URL 32 | 33 | :param url: Git repository URL 34 | :return Tuple: with exit code and read STDOUT 35 | """ 36 | logger.info(f"Downloading module from git URL {url}!") 37 | name = urlparse(url).path.split("/")[-1].removesuffix(".git") 38 | modules_dir = os.path.join(self.__root_dir, "modules") 39 | p = subprocess.run( 40 | ["git", "clone", url, name], 41 | cwd=modules_dir, 42 | stdout=subprocess.PIPE, 43 | stderr=subprocess.STDOUT 44 | ) 45 | 46 | if p.returncode != 0: 47 | logger.error(f"Error while cloning module {name}!") 48 | logger.error(f"Printing STDOUT and STDERR:") 49 | logger.error(p.stdout.decode("utf-8")) 50 | subprocess.run(["rm", f"{self.__root_dir}/modules/{name}"]) 51 | 52 | return p.returncode, p.stdout.decode("utf-8") 53 | 54 | def check_for_updates(self, name: str, directory: str) -> Optional[bool]: 55 | """ 56 | Check if there are new commits available for the module or extension. 57 | 58 | :param name: Name of the module or extension 59 | :param directory: Directory of modules or extensions 60 | :return bool: True if there are new commits, False if up-to-date, or None on error 61 | """ 62 | try: 63 | repo_dir = os.path.join(self.__root_dir, directory, name) 64 | p = subprocess.run( 65 | ["git", "fetch"], cwd=repo_dir, 66 | stdout=subprocess.PIPE, 67 | stderr=subprocess.STDOUT 68 | ) 69 | 70 | if p.returncode != 0: 71 | logger.error(f"Error while fetching updates for {name}!") 72 | logger.error(p.stdout.decode("utf-8")) 73 | return None 74 | 75 | cmd_check = ( 76 | f"cd {self.__root_dir}/{directory}/{name} && git rev-list --count HEAD..origin" 77 | ) 78 | p_check = subprocess.run( 79 | cmd_check, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT 80 | ) 81 | 82 | if p_check.returncode != 0: 83 | logger.error(f"Error while checking for new commits for {name}! Return code: {p_check.returncode}") 84 | logger.error(f"Git output: {p_check.stdout.decode('utf-8')}") 85 | return None 86 | 87 | output = p_check.stdout.decode("utf-8").strip() 88 | 89 | # Handle empty or invalid output 90 | if not output.isdigit(): 91 | logger.error(f"Invalid commit count output for {name}: '{output}'") 92 | return None 93 | 94 | new_commits_count = int(output) 95 | return new_commits_count > 0 96 | 97 | except Exception as e: 98 | logger.error(f"Failed to check for new commits for {name}. Details: {e}") 99 | return None 100 | 101 | def update_from_git(self, name: str, directory: str, module=None) -> Tuple[int, str, Optional[str]]: 102 | """ 103 | Method to update git repository (module or extensions) 104 | Creates a backup, remembers commit hash for reverting, and executes git pull 105 | 106 | :param name: Name of module or extension 107 | :param directory: Directory of modules or extensions 108 | :param module: Module object if updating a loaded module (provides access to version, db, etc.) 109 | :return Tuple: With exit code, output of git pull, and backup path (or None if backup failed) 110 | """ 111 | # Store module data before unloading if provided 112 | prev_version = None 113 | prev_db = None 114 | prev_db_meta = None 115 | 116 | if module: 117 | prev_version = module.module_info.version 118 | prev_db = module.db 119 | prev_db_meta = module.db_meta 120 | 121 | logger.info(f"Updating {name}!") 122 | 123 | # Create a backup before updating 124 | backup_success, backup_result = self.__backup_manager.create_backup(name, directory) 125 | backup_path = backup_result if backup_success else None 126 | 127 | if not backup_success: 128 | logger.warning(f"Failed to create backup for {name}: {backup_result}") 129 | # Continue with update even if backup fails, but log the warning 130 | 131 | # Backup current hash 132 | repo_dir = os.path.join(self.__root_dir, directory, name) 133 | hash_p = subprocess.run( 134 | ["git", "rev-parse", "HEAD"], 135 | cwd=repo_dir, 136 | stdout=subprocess.PIPE, 137 | stderr=subprocess.STDOUT 138 | ) 139 | 140 | if hash_p.returncode != 0: 141 | logger.error(f"Failed to retrieve HEAD hash for {name}. STDOUT below") 142 | logger.error(hash_p.stdout.decode("utf-8")) 143 | return hash_p.returncode, hash_p.stdout.decode("utf-8"), backup_path 144 | 145 | self.__hash_backups[name] = hash_p.stdout.decode("utf-8").strip() 146 | 147 | # Pull updates 148 | p = subprocess.run( 149 | ["git", "pull"], 150 | cwd=repo_dir, 151 | stdout=subprocess.PIPE, 152 | stderr=subprocess.STDOUT 153 | ) 154 | 155 | if p.returncode != 0: 156 | logger.error(f"Error while updating module {name}!") 157 | logger.error(f"Printing STDOUT and STDERR:") 158 | logger.error(p.stdout.decode("utf-8")) 159 | return p.returncode, p.stdout.decode("utf-8"), backup_path 160 | 161 | # Start database migration if module provided and db_migrations directory exists 162 | if prev_db is not None and os.path.exists( 163 | f"{self.__root_dir}/{directory}/{name}/db_migrations" 164 | ): 165 | for file in os.listdir( 166 | f"{self.__root_dir}/{directory}/{name}/db_migrations" 167 | ): 168 | mig_ver = file.removesuffix(".py") 169 | if version.parse(prev_version) < version.parse(mig_ver): 170 | logger.info( 171 | f"Migrating database for module {name} to version {mig_ver}..." 172 | ) 173 | imported = importlib.import_module( 174 | f"modules.{name}.db_migrations.{mig_ver}" 175 | ) 176 | classes = inspect.getmembers(imported, inspect.isclass) 177 | if len(classes) == 0: 178 | logger.error("Invalid migration! No DBMigration classes found!") 179 | continue 180 | 181 | obj = classes[0][1] # Use first detected class 182 | instance: DBMigration = obj() 183 | instance.apply(prev_db.session, prev_db.engine, prev_db_meta) 184 | 185 | return p.returncode, p.stdout.decode("utf-8"), backup_path 186 | 187 | def revert_update(self, name: str, directory: str) -> bool: 188 | """ 189 | Reverts update caused by update_from_git(). Resets to previously stored hash. 190 | 191 | :param name: Name of module or extension 192 | :param directory: Directory of modules or extensions 193 | :return bool: success or not 194 | """ 195 | try: 196 | if name not in self.__hash_backups: 197 | logger.error(f"Tried to revert module {name} with no pending update!") 198 | return False 199 | 200 | repo_dir = os.path.join(self.__root_dir, directory, name) 201 | p = subprocess.run( 202 | ["git", "reset", "--hard", self.__hash_backups[name]], 203 | cwd=repo_dir, 204 | stdout=subprocess.PIPE, 205 | stderr=subprocess.STDOUT 206 | ) 207 | 208 | if p.returncode != 0: 209 | logger.error( 210 | f"Failed to revert update of module {name}! Printing STDOUT" 211 | ) 212 | logger.error(p.stdout.decode("utf-8")) 213 | return False 214 | 215 | logger.info(f"Update of module {name} reverted!") 216 | return True 217 | except Exception as e: 218 | logger.error(f"Error reverting update for {name}: {e}") 219 | return False 220 | 221 | def restore_from_backup(self, name: str, directory: str, backup_path: Optional[str] = None) -> bool: 222 | """ 223 | Restore module from a backup file 224 | 225 | :param name: Name of module or extension 226 | :param directory: Directory of modules or extensions 227 | :param Optional[str] backup_path: Optional path to specific backup file. If None, uses the latest backup. 228 | :return bool: success or not 229 | """ 230 | try: 231 | # If no specific backup path provided, get the latest backup 232 | if backup_path is None: 233 | backup_path = self.__backup_manager.get_latest_backup(name) 234 | 235 | if not backup_path: 236 | logger.error(f"No backup found for module {name}") 237 | return False 238 | 239 | # Use the backup manager to restore files 240 | return self.__backup_manager.restore_from_backup(backup_path, name, directory) 241 | except Exception as e: 242 | logger.error(f"Error restoring module {name} from backup: {e}") 243 | return False 244 | 245 | def list_backups(self, name: Optional[str] = None) -> list: 246 | """ 247 | List available backups for a module or all modules 248 | 249 | :param Optional[str] name: Optional module name to filter backups 250 | :return: List of backup files 251 | """ 252 | return self.__backup_manager.list_backups(name) 253 | 254 | def install_deps(self, name: str, directory: str) -> Tuple[int, Union[str, list[str]]]: 255 | """ 256 | Method to install Python dependencies from requirements.txt file 257 | 258 | :param name: Name of module or extension 259 | :param directory: Directory of modules or extensions 260 | :return Tuple: With exit code and read STDOUT or list of requirements 261 | """ 262 | logger.info(f"Upgrading dependencies for {name}!") 263 | r = subprocess.run( 264 | [ 265 | sys.executable, 266 | "-m", 267 | "pip", 268 | "install", 269 | "-U", 270 | "-r", 271 | f"{self.__root_dir}/{directory}/{name}/requirements.txt", 272 | ], 273 | stdout=subprocess.PIPE, 274 | stderr=subprocess.STDOUT, 275 | ) 276 | if r.returncode != 0: 277 | logger.error( 278 | f"Error at upgrading deps for {name}!\nPip output:\n" 279 | f"{r.stdout.decode('utf-8')}" 280 | ) 281 | return r.returncode, r.stdout.decode("utf-8") 282 | else: 283 | logger.info(f"Dependencies upgraded successfully!") 284 | with open(f"{self.__root_dir}/{directory}/{name}/requirements.txt") as f: 285 | reqs = [line.strip() for line in f if line.strip()] 286 | if not reqs: 287 | logger.warning(f"{name} requirements.txt is empty or contains only whitespace") 288 | return r.returncode, reqs 289 | 290 | def uninstall_mod_deps(self, name: str, modules_deps: dict[str, list[str]]): 291 | """ 292 | Method to uninstall module dependencies. Removes package only if it isn't required by other module 293 | 294 | :param name: Name of module 295 | :param dict modules_deps: Dictionary mapping module names to their dependencies 296 | """ 297 | if name not in modules_deps: 298 | logger.warning(f"No dependencies found for module {name}") 299 | return 300 | 301 | for mod_dep in modules_deps[name]: 302 | found = False 303 | for other_name, deps in modules_deps.items(): 304 | if other_name == name: 305 | continue 306 | if mod_dep in deps: 307 | found = True 308 | break 309 | if found: 310 | continue 311 | 312 | subprocess.run( 313 | [sys.executable, "-m", "pip", "uninstall", "-y", mod_dep], 314 | stdout=subprocess.PIPE, 315 | stderr=subprocess.STDOUT, 316 | ) 317 | 318 | def uninstall_packages(self, pkgs: list[str], modules_deps: dict[str, list[str]]): 319 | """ 320 | Uninstall specified packages if they are not required by any module 321 | 322 | :param list pkgs: List of package names to uninstall 323 | :param dict modules_deps: Dictionary mapping module names to their dependencies 324 | """ 325 | for dep in pkgs: 326 | found = False 327 | for other_name, deps in modules_deps.items(): 328 | if dep in deps: 329 | found = True 330 | break 331 | if found: 332 | continue 333 | 334 | subprocess.run( 335 | [sys.executable, "-m", "pip", "uninstall", "-y", dep], 336 | stdout=subprocess.PIPE, 337 | stderr=subprocess.STDOUT, 338 | ) 339 | 340 | def uninstall_module(self, name: str, modules_deps: dict[str, list[str]]) -> bool: 341 | """ 342 | Module uninstallation method. Removes module directory and its dependencies 343 | 344 | :param name: Name of Python module inside modules dir 345 | :param dict modules_deps: Dictionary mapping module names to their dependencies 346 | :return bool: success or not 347 | """ 348 | try: 349 | # Remove deps if they exist in the dependency dictionary 350 | if name in modules_deps: 351 | self.uninstall_mod_deps(name, modules_deps) 352 | modules_deps.pop(name) 353 | 354 | # Remove module directory 355 | module_path = os.path.join(self.__root_dir, "modules", name) 356 | if os.path.exists(module_path): 357 | logger.info(f"Attempting to remove module directory: {module_path}") 358 | skipped_files = self.__backup_manager.safe_remove_tree(module_path) 359 | 360 | if skipped_files: 361 | logger.warning(f"Could not remove some files/directories during uninstall of {name}:") 362 | for item in skipped_files: 363 | logger.warning(f" - {item}") 364 | logger.info(f"Partially removed module {name} (some items skipped).") 365 | try: 366 | if os.path.exists(module_path) and not os.listdir(module_path): 367 | os.rmdir(module_path) 368 | logger.info(f"Removed empty top-level directory: {module_path}") 369 | except Exception as e: 370 | logger.warning(f"Could not remove potentially empty top-level directory {module_path}: {e}") 371 | return True 372 | else: 373 | if not os.path.exists(module_path): 374 | logger.info(f"Successfully removed module {name}!") 375 | return True 376 | else: 377 | logger.error(f"safe_remove_tree completed for {name} but directory {module_path} still exists.") 378 | return False 379 | else: 380 | logger.warning(f"Module directory for {name} not found ({module_path}).") 381 | return True 382 | except Exception as e: 383 | logger.error(f"Error while removing module {name}! Printing traceback...") 384 | logger.exception(e) 385 | return False 386 | 387 | def set_module_auto_load(self, name: str, auto_load: bool) -> bool: 388 | """ 389 | Set auto_load preference for a module 390 | 391 | :param name: Name of Python module inside modules dir 392 | :param bool auto_load: Whether to auto-load the module on startup 393 | :return: Success status 394 | """ 395 | module_dir = os.path.join(self.__root_dir, "modules", name) 396 | config_path = os.path.join(module_dir, "config.yaml") 397 | 398 | if not os.path.isdir(module_dir): 399 | return False 400 | 401 | target_path = config_path 402 | data = None 403 | 404 | try: 405 | if os.path.exists(target_path): 406 | with open(target_path, "r", encoding="utf-8") as f: 407 | data = yaml.safe_load(f) or {} 408 | if 'info' not in data: 409 | data['info'] = {} 410 | data['info']['auto_load'] = auto_load 411 | else: 412 | logger.error(f"config.yaml not found for module {name} at {target_path}. Cannot set auto_load.") 413 | return False 414 | 415 | # Write the updated data back 416 | with open(target_path, "w", encoding="utf-8") as f: 417 | yaml.dump(data, f, default_flow_style=False, allow_unicode=True) 418 | 419 | logger.info(f"Set auto_load={auto_load} for module {name} in {os.path.basename(target_path)}") 420 | return True 421 | 422 | except yaml.YAMLError as e: 423 | logger.error(f"Error parsing YAML file {target_path} for module {name}: {e}") 424 | return False 425 | except IOError as e: 426 | logger.error(f"Error reading/writing file {target_path} for module {name}: {e}") 427 | return False 428 | except Exception as e: 429 | logger.error(f"Unexpected error updating auto_load for {name}: {e}", exc_info=True) 430 | return False 431 | 432 | def get_hash_backups(self) -> dict[str, str]: 433 | """ 434 | Get the current hash backups dictionary 435 | 436 | :return: Dictionary of module name to hash backup mappings 437 | """ 438 | return self.__hash_backups 439 | 440 | def clear_hash_backup(self, name: str) -> bool: 441 | """ 442 | Clear a specific hash backup 443 | 444 | :param name: Name of the module 445 | :return: Whether the hash was cleared 446 | """ 447 | if name in self.__hash_backups: 448 | self.__hash_backups.pop(name) 449 | return True 450 | return False 451 | 452 | def cleanup_old_backups(self, name: str, keep_count: int = 5) -> int: 453 | """ 454 | Clean up old backups for a module, keeping only the most recent ones 455 | 456 | :param name: Name of the module 457 | :param keep_count: Number of recent backups to keep 458 | :return: Number of backups deleted 459 | """ 460 | return self.__backup_manager.cleanup_old_backups(name, keep_count) -------------------------------------------------------------------------------- /base/module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC 3 | from dataclasses import dataclass, field 4 | from enum import Enum 5 | from typing import Optional, Union, Callable, Type, Tuple 6 | import inspect 7 | import os 8 | from functools import wraps 9 | import asyncio 10 | from copy import deepcopy 11 | 12 | from pyrogram import Client, filters 13 | from pyrogram.types import Message, InlineKeyboardButton 14 | from pyrogram.filters import Filter 15 | from pyrogram.handlers import MessageHandler, CallbackQueryHandler 16 | from pyrogram.handlers.handler import Handler 17 | from pyrogram.enums import ChatMemberStatus 18 | 19 | from base.db import Database 20 | from sqlalchemy import MetaData, Engine, select 21 | from sqlalchemy.orm import Session 22 | from db import CommandPermission, User 23 | 24 | import yaml 25 | from config import config 26 | from base import command_registry 27 | from dataclass_wizard import YAMLWizard 28 | from base.states import StateMachine, State 29 | 30 | 31 | @dataclass 32 | class ModuleInfo: 33 | name: str 34 | author: str 35 | version: str 36 | description: str 37 | src_url: Optional[str] = None 38 | python: Optional[str] = None 39 | auto_load: bool = True 40 | 41 | class Permissions(str, Enum): 42 | use_db = "use_db" 43 | require_db = "require_db" 44 | use_loader = "use_loader" 45 | 46 | 47 | @dataclass 48 | class ModuleConfig(YAMLWizard): 49 | info: ModuleInfo 50 | permissions: list[Permissions] = field(default_factory=list) 51 | config: dict = field(default_factory=dict) 52 | 53 | 54 | @dataclass 55 | class HelpPage: 56 | text: str 57 | buttons: Optional[list[list[InlineKeyboardButton]]] = None 58 | 59 | 60 | class SafeDict(dict): 61 | def __getitem__(self, key): 62 | try: 63 | value = super().__getitem__(key) 64 | except KeyError: 65 | return key # Return the key as the descriptor if missing 66 | # Ensure nested dicts are also SafeDict instances 67 | if isinstance(value, dict) and not isinstance(value, SafeDict): 68 | value = SafeDict(value) 69 | self[key] = value 70 | return value 71 | 72 | @classmethod 73 | def from_dict(cls, data): 74 | if isinstance(data, dict): 75 | safe_dict = cls() 76 | for k, v in data.items(): 77 | safe_dict[k] = cls.from_dict(v) 78 | return safe_dict 79 | else: 80 | return data 81 | 82 | 83 | def merge_dicts(dict_a: dict, dict_b: dict): 84 | for key in dict_b.keys(): 85 | if key in dict_a and isinstance(dict_a[key], dict) and isinstance(dict_b[key], dict): 86 | merge_dicts(dict_a[key], dict_b[key]) 87 | else: 88 | dict_a[key] = dict_b[key] 89 | 90 | 91 | class BaseModule(ABC): 92 | """ 93 | Bot module superclass 94 | """ 95 | 96 | def __init__( 97 | self, 98 | bot: Client, 99 | loaded_info_func: Callable, 100 | bot_db_session: Session, 101 | bot_db_engine: Engine, 102 | module_path: str, 103 | ): 104 | self.bot = bot 105 | self.__loaded_info = loaded_info_func 106 | self.module_path = module_path 107 | 108 | # Attempt to load config.yaml 109 | config_path = os.path.join(self.module_path, "config.yaml") 110 | if not os.path.exists(config_path): 111 | raise FileNotFoundError(f"config.yaml not found at {config_path}") 112 | try: config_file = ModuleConfig.from_yaml_file(config_path) 113 | except: raise ValueError(f"config.yaml is empty or invalid at {config_path}") 114 | 115 | self.module_info = config_file.info 116 | self.module_permissions = config_file.permissions 117 | self.module_config = config_file.config 118 | self.logger = logging.getLogger(self.module_info.name) 119 | 120 | # Load translations if available 121 | strings_dir = os.path.join(self.module_path, "strings") 122 | self.cur_lang = config.language 123 | if os.path.exists(strings_dir): 124 | files = os.listdir(strings_dir) 125 | self.rawS = {} 126 | for file in files: 127 | if file.endswith(".yaml"): 128 | lang = file.removesuffix(".yaml") 129 | with open(os.path.join(strings_dir, file), encoding="utf-8") as f: 130 | self.rawS[lang] = yaml.safe_load(f) 131 | self.logger.info(f"Available translations: {list(self.rawS.keys())}") 132 | if config.language in self.rawS.keys(): 133 | if config.fallback_language in self.rawS.keys(): 134 | # Create copies and merge 135 | fallback_dict = deepcopy(self.rawS[config.fallback_language]) 136 | main_dict = self.rawS[config.language] 137 | merge_dicts(fallback_dict, main_dict) 138 | self.S = SafeDict.from_dict(fallback_dict) 139 | else: 140 | self.logger.warning( 141 | f"Fallback language is not found, unable to merge translations!" 142 | ) 143 | self.S = SafeDict.from_dict(self.rawS[config.language]) 144 | elif config.fallback_language in self.rawS.keys(): 145 | self.logger.warning( 146 | f"Language {config.language} not found! Falling back to {config.fallback_language}" 147 | ) 148 | self.cur_lang = config.fallback_language 149 | self.S = SafeDict.from_dict(self.rawS[config.fallback_language]) 150 | else: 151 | self.logger.warning( 152 | f"Can't select language... Using first in list, you've been warned!" 153 | ) 154 | self.S = SafeDict.from_dict(list(self.rawS.values())[0]) 155 | 156 | # Global bot database 157 | self.__bot_db_session = bot_db_session 158 | self.__bot_db_engine = bot_db_engine 159 | 160 | # Place for database session. Will be set by loader if necessary 161 | self.__db: Optional[Database] = None 162 | 163 | # Place for loader 164 | self.loader = None 165 | 166 | # Place for message handlers and extensions 167 | self.__extensions = [] 168 | self.__handlers = [] 169 | 170 | # Auto-generated help 171 | self.__auto_help: Optional[HelpPage] = None 172 | 173 | # State machines for users 174 | self.__state_machines = {} 175 | 176 | def stage2(self): 177 | self.register_all() 178 | # Load extensions 179 | for ext in self.module_extensions: 180 | self.__extensions.append(ext(self)) 181 | 182 | async def unregister_all(self): 183 | """Unregister handlers""" 184 | del self.__extensions 185 | 186 | # Unregister handlers 187 | for handler, group in self.__handlers: 188 | self.bot.remove_handler(handler, group) 189 | 190 | self.__handlers.clear() 191 | 192 | command_registry.remove_all(self.module_info.name) 193 | 194 | # Close database synchronously within async context 195 | if self.__db: 196 | await self.__db.engine.dispose() 197 | self.__db = None 198 | 199 | def register_all(self, ext = None): 200 | """ 201 | Method that initiates method registering. Must be called only from loader or extension! 202 | """ 203 | methods = inspect.getmembers(ext if ext else self, inspect.ismethod) 204 | for name, func in methods: 205 | if hasattr(func, "bot_cmds"): 206 | # Func with @command decorator 207 | for cmd in func.bot_cmds: 208 | if command_registry.check_command(cmd): 209 | self.logger.warning( 210 | f"Command conflict! " 211 | f"Module {self.module_info.name} tried to register command {cmd}, which is already used! " 212 | f"Skipping this command" 213 | ) 214 | else: 215 | command_registry.register_command(self.module_info.name, cmd) 216 | final_filter = ( 217 | filters.command(cmd) & func.bot_msg_filter 218 | if func.bot_msg_filter 219 | else filters.command(cmd) 220 | ) & filters.create( 221 | self.__check_role, 222 | handler=func, 223 | session=self.__bot_db_session, 224 | ) 225 | final_filter = self.__add_fsm_filter(func, final_filter) 226 | 227 | handler = MessageHandler(func, final_filter) 228 | group = 0 229 | self.bot.add_handler(handler, group=group) 230 | self.__handlers.append((handler, group)) 231 | 232 | if self.__auto_help is None: 233 | self.__auto_help = HelpPage("") 234 | 235 | self.__auto_help.text += ( 236 | f"/{cmd}" 237 | + (f" - {func.__doc__}" if func.__doc__ else "") 238 | + "\n" 239 | ) 240 | 241 | elif hasattr(func, "bot_callback_filter"): 242 | # Func with @callback_query decorator 243 | final_filter = filters.create( 244 | self.__check_role, handler=func, session=self.__bot_db_session 245 | ) 246 | if func.bot_callback_filter is not None: 247 | final_filter = final_filter & func.bot_callback_filter 248 | 249 | final_filter = self.__add_fsm_filter(func, final_filter) 250 | 251 | handler = CallbackQueryHandler(func, final_filter) 252 | group = 0 253 | self.bot.add_handler(handler, group=group) 254 | self.__handlers.append((handler, group)) 255 | 256 | elif hasattr(func, "bot_msg_filter"): 257 | # Func with @message decorator 258 | final_filter = filters.create( 259 | self.__check_role, handler=func, session=self.__bot_db_session 260 | ) 261 | if func.bot_msg_filter is not None: 262 | final_filter = final_filter & func.bot_msg_filter 263 | 264 | final_filter = self.__add_fsm_filter(func, final_filter) 265 | 266 | handler = MessageHandler(func, final_filter) 267 | group = 0 268 | self.bot.add_handler(handler, group=group) 269 | self.__handlers.append((handler, group)) 270 | 271 | # Custom handlers registration 272 | custom_handlers_list = ext.custom_handlers if ext else self.custom_handlers 273 | for item in custom_handlers_list: 274 | handler_instance: Optional[Handler] = None 275 | group: int = 0 276 | 277 | if isinstance(item, Handler): 278 | # If it's just a Handler, use default group 0 279 | handler_instance = item 280 | elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Handler) and isinstance(item[1], int): 281 | # If it's a tuple (Handler, group_number) 282 | handler_instance = item[0] 283 | group = item[1] 284 | else: 285 | self.logger.warning( 286 | f"Invalid item type in custom_handlers list for module {self.module_info.name}. " 287 | f"Expected Handler or (Handler, int), got {type(item)}. Skipping." 288 | ) 289 | continue 290 | 291 | self.bot.add_handler(handler_instance, group=group) 292 | self.__handlers.append((handler_instance, group)) 293 | 294 | def __add_fsm_filter(self, func: Callable, final_filter: Filter) -> Filter: 295 | if hasattr(func, "bot_fsm_states"): 296 | if self.state_machine is None: 297 | self.logger.warning(f"Handler {func.__name__} tries to use FSM, but it wasn't defined!") 298 | return 299 | 300 | return final_filter & filters.create( 301 | self.__check_fsm_state, 302 | handler=func, 303 | state_machines=self.__state_machines, 304 | state_machine=self.state_machine 305 | ) 306 | else: 307 | return final_filter 308 | 309 | @staticmethod 310 | async def __check_role(flt: Filter, client: Client, update) -> bool: 311 | async with flt.session() as session: 312 | if hasattr(flt.handler, "bot_cmds"): 313 | db_command = await session.scalar( 314 | select(CommandPermission).where( 315 | CommandPermission.command == update.text.split()[0][1:] 316 | ) 317 | ) 318 | if db_command is None and not hasattr(flt.handler, "bot_allowed_for"): 319 | return True 320 | 321 | allowed_to = ( 322 | db_command.allowed_for.split(":") 323 | if db_command 324 | else flt.handler.bot_allowed_for 325 | ) 326 | else: 327 | if not hasattr(flt.handler, "bot_allowed_for"): 328 | return True 329 | 330 | allowed_to = flt.handler.bot_allowed_for 331 | 332 | db_user = await session.scalar( 333 | select(User).where(User.id == update.from_user.id) 334 | ) 335 | if ( 336 | "all" in allowed_to 337 | or f"@{update.from_user.username}" in allowed_to 338 | or (db_user is not None and db_user.role in allowed_to) 339 | or update.from_user.username == config.owner 340 | or update.from_user.id == config.owner 341 | ): 342 | return True 343 | if "owner" in allowed_to and ( 344 | update.from_user.id == config.owner 345 | or update.from_user.username == config.owner 346 | ): 347 | return True 348 | 349 | if "chat_owner" in allowed_to or "chat_admins" in allowed_to: 350 | member = await client.get_chat_member( 351 | chat_id=update.chat.id, user_id=update.from_user.id 352 | ) 353 | if ( 354 | "chat_owner" in allowed_to 355 | and member.status == ChatMemberStatus.OWNER 356 | ) or ( 357 | "chat_admins" in allowed_to 358 | and member.status == ChatMemberStatus.ADMINISTRATOR 359 | ): 360 | return True 361 | 362 | return False 363 | 364 | @staticmethod 365 | async def __check_fsm_state(flt: Filter, client: Client, update) -> bool: 366 | machine = flt.state_machines.get(update.from_user.id) 367 | if machine is None: 368 | machine = flt.state_machine() 369 | flt.state_machines[update.from_user.id] = machine 370 | 371 | for state in flt.handler.bot_fsm_states: 372 | if machine.cur_state == state: 373 | return True 374 | 375 | return False 376 | 377 | @property 378 | def module_extensions(self) -> list[Type]: 379 | """ 380 | List of module extension classes. Override if necessary. 381 | """ 382 | return [] 383 | 384 | @property 385 | def db(self): 386 | return self.__db 387 | 388 | async def set_db(self, value): 389 | """ 390 | Setter for DB object. Creates tables from db_meta if available 391 | """ 392 | self.__db = value 393 | if self.db_meta: 394 | async with self.__db.engine.begin() as conn: 395 | await conn.run_sync(self.db_meta.create_all) 396 | await self.on_db_ready() 397 | 398 | @property 399 | def db_meta(self): 400 | """ 401 | SQLAlchemy MetaData object. Must be set if using database 402 | 403 | :rtype: MetaData 404 | """ 405 | return None 406 | 407 | @property 408 | def state_machine(self): 409 | """ 410 | StateMachine class for usage in handlers. Override if necessary. 411 | 412 | :rtype: Type[StateMachine] 413 | """ 414 | return None 415 | 416 | async def start_cmd(self, bot: Client, message: Message): 417 | """ 418 | Start command handler, which will be called from main start dispatcher. 419 | For example: /start BestModule will execute this func in BestModule 420 | 421 | :return: 422 | """ 423 | 424 | @property 425 | def help_page(self) -> Optional[Union[HelpPage, str]]: 426 | """ 427 | Help page to be displayed in Core module help command. Highly recommended to set this! 428 | Defaults to auto-generated command listing, which uses callback func __doc__ for description 429 | Can be a string for backward compatibility 430 | """ 431 | return self.__auto_help 432 | 433 | @property 434 | def custom_handlers(self) -> list[Union[Handler, Tuple[Handler, int]]]: 435 | """ 436 | Custom handlers for specialized use cases (e.g., raw updates, specific message types). 437 | Override if necessary. 438 | 439 | Each item in the list should be either: 440 | 1. A Pyrogram Handler instance (e.g., MessageHandler, CallbackQueryHandler, RawUpdateHandler). 441 | These handlers will be added to the default group (0). 442 | 2. A tuple containing (Handler, int), where the integer specifies the Pyrogram handler group. 443 | 444 | Handlers are processed by group number, lowest first. Within a group, order is determined by PyroTGFork. 445 | See: https://telegramplayground.github.io/pyrogram/topics/more-on-updates.html#handler-groups 446 | """ 447 | return [] 448 | 449 | def on_init(self): 450 | """Called when module should initialize itself. Optional""" 451 | pass 452 | 453 | async def on_db_ready(self): 454 | """Called when module's database is fully initialized. Optional""" 455 | pass 456 | 457 | def on_unload(self): 458 | """Called on module unloading. Optional""" 459 | pass 460 | 461 | @property 462 | def loaded_modules(self) -> dict[str, ModuleInfo]: 463 | """ 464 | Method for querying loaded modules from child instance 465 | 466 | :return: List of loaded modules info 467 | """ 468 | return self.__loaded_info() 469 | 470 | def get_sm(self, update) -> Optional[StateMachine]: 471 | """ 472 | Get state machine for current user session 473 | 474 | :param update: Pyrogram update object (Message, CallbackQuery, etc.) 475 | """ 476 | machine = self.__state_machines.get(update.from_user.id) 477 | if machine is None: 478 | machine = self.state_machine() 479 | self.__state_machines[update.from_user.id] = machine 480 | 481 | return machine 482 | 483 | 484 | def command(cmds: Union[list[str], str], filters: Optional[Filter] = None, fsm_state: Optional[Union[State, list[State]]] = None): 485 | """ 486 | Decorator for registering module command. 487 | If FSM is present and the handler func has 4 args, then FSM for current user session is passed as a fourth parameter. 488 | 489 | :param cmds: List of commands w/o prefix. It may be a string if there's only one command 490 | :param filters: Final combined filter for validation. See https://docs.pyrogram.org/topics/use-filters 491 | :param fsm_state: FSM states at which this handler is allowed to run 492 | """ 493 | 494 | def _command(func: Callable): 495 | @wraps(func) 496 | async def inner(self: BaseModule, client, update): 497 | await _launch_handler(func, self, client, update) 498 | 499 | inner.bot_cmds = cmds if type(cmds) == list else [cmds] 500 | inner.bot_msg_filter = filters 501 | 502 | if fsm_state is not None: 503 | inner.bot_fsm_states = fsm_state if type(fsm_state) == list else [fsm_state] 504 | 505 | return inner 506 | 507 | return _command 508 | 509 | 510 | def callback_query(filters: Optional[Filter] = None, fsm_state: Optional[Union[State, list[State]]] = None): 511 | """ 512 | Decorator for registering callback query handlers 513 | If FSM is present and the handler func has 4 args, then FSM for current user session is passed as a fourth parameter. 514 | 515 | :param filters: Final combined filter for validation. See https://docs.pyrogram.org/topics/use-filters 516 | :param fsm_state: FSM states at which this handler is allowed to run 517 | """ 518 | 519 | def _callback_query(func: Callable): 520 | @wraps(func) 521 | async def inner(self: BaseModule, client, update): 522 | await _launch_handler(func, self, client, update) 523 | 524 | inner.bot_callback_filter = filters 525 | 526 | if fsm_state is not None: 527 | inner.bot_fsm_states = fsm_state if type(fsm_state) == list else [fsm_state] 528 | 529 | return inner 530 | 531 | return _callback_query 532 | 533 | 534 | def message(filters: Optional[Filter] = None, fsm_state: Optional[Union[State, list[State]]] = None): 535 | """ 536 | Decorator for registering all messages handler. 537 | If FSM is present and the handler func has 4 args, then FSM for current user session is passed as a fourth parameter. 538 | 539 | :param filters: Final combined filter for validation. See https://docs.pyrogram.org/topics/use-filters. Highly recommended to set this! 540 | :param fsm_state: FSM states at which this handler is allowed to run 541 | """ 542 | 543 | def _message(func: Callable): 544 | @wraps(func) 545 | async def inner(self: BaseModule, client, update): 546 | await _launch_handler(func, self, client, update) 547 | 548 | inner.bot_msg_filter = filters 549 | 550 | if fsm_state is not None: 551 | inner.bot_fsm_states = fsm_state if type(fsm_state) == list else [fsm_state] 552 | 553 | return inner 554 | 555 | return _message 556 | 557 | 558 | async def _launch_handler(func: Callable, self: BaseModule, client, update): 559 | params = inspect.signature(func).parameters 560 | if len(params) == 2: 561 | # FSM is not used, client obj is not used 562 | await func(self, update) 563 | elif self.state_machine is None and len(params) >= 3: 564 | # FSM is not used, client obj used 565 | await func(self, client, update) 566 | elif self.state_machine is not None and len(params) == 3: 567 | # FSM is used, client obj isn't 568 | await func(self, update, self.get_sm(update)) 569 | elif self.state_machine is not None and len(params) >= 4: 570 | await func(self, client, update, self.get_sm(update)) 571 | 572 | 573 | def allowed_for(roles: Union[list[str], str]): 574 | """ 575 | Decorator for built-in permission system. Allows certain roles or users to use this command. 576 | May be overwritten by user 577 | """ 578 | 579 | def wrapper(func: Callable): 580 | func.bot_allowed_for = roles if type(roles) == list else [roles] 581 | return func 582 | 583 | return wrapper 584 | -------------------------------------------------------------------------------- /base/states.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Optional 3 | from copy import copy 4 | 5 | 6 | class State: 7 | def __init__(self): 8 | self.__owner: Optional[StateMachine] = None 9 | self.__name: Optional[str] = None 10 | 11 | def __set_owner__(self, owner: "StateMachine", name: str): 12 | self.__owner = owner 13 | self.__name = name 14 | 15 | @property 16 | def name(self) -> Optional[str]: 17 | return f"{self.__owner.__class__.__name__}:{self.__name}" if self.__owner is not None else None 18 | 19 | def set(self): 20 | """ 21 | Set this state as active. Shortcut for StateMachine.cur_state setter 22 | """ 23 | self.__owner.cur_state = self 24 | 25 | def is_set(self) -> bool: 26 | """ 27 | Checks if this state is active now 28 | """ 29 | return (self.__owner.cur_state == self) if self.__owner is not None else False 30 | 31 | def __eq__(self, __o: object) -> bool: 32 | return isinstance(__o, State) and __o.name == self.name 33 | 34 | def __str__(self) -> str: 35 | return f"State(name={self.name}, is_set={self.is_set()})" 36 | 37 | __repr__ = __str__ 38 | 39 | 40 | class StateMachine: 41 | def __init__(self): 42 | self.__current_state: Optional[State] = None 43 | self.__state_data = {} 44 | 45 | # Init all declared states 46 | members = inspect.getmembers(self) 47 | for name, member in members: 48 | if isinstance(member, State): 49 | member.__set_owner__(self, name) 50 | setattr(self, name, copy(member)) 51 | 52 | @property 53 | def cur_state(self) -> Optional[State]: 54 | """ 55 | Get the current state 56 | """ 57 | return self.__current_state 58 | 59 | @cur_state.setter 60 | def cur_state(self, data): 61 | """ 62 | Set the current state 63 | """ 64 | if not isinstance(data, State): 65 | raise ValueError("Invalid state type!") 66 | 67 | self.__current_state = data 68 | 69 | def clear(self): 70 | """ 71 | Reset the machine to default state 72 | """ 73 | self.__current_state = None 74 | self.__state_data = {} 75 | 76 | def clear_data(self): 77 | """ 78 | Clear only data, preserve state 79 | """ 80 | self.__state_data = {} 81 | 82 | @property 83 | def data(self) -> dict: 84 | return self.__state_data 85 | 86 | @data.setter 87 | def data(self, data): 88 | if type(data) != dict: 89 | raise ValueError("FSM data must be a dict!") 90 | 91 | self.__state_data = data 92 | 93 | def update_data(self, **kwargs): 94 | r""" 95 | Update fields in the data dictionary. 96 | 97 | :param \**kwargs: Key-value pairs for the dictionary 98 | """ 99 | for key, value in kwargs.items(): 100 | self.__state_data[key] = value 101 | 102 | def get_data(self, key: str): 103 | """ 104 | Get a value from the data dictionary 105 | 106 | :param key: Key for the dictionary 107 | """ 108 | return self.__state_data.get(key) 109 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | token: null # Insert bot-API token here 2 | # MTProto API tokens. Get them from https://my.telegram.org/ 3 | api-id: null 4 | api-hash: null 5 | 6 | # Localisation settings 7 | language: ru 8 | fallback-language: en 9 | 10 | # Whether to try update module dependencies at every load 11 | update_deps_at_load: true 12 | 13 | ### Database section ### 14 | enable-db: true 15 | 16 | # Database backend selection. Fill only type and driver! [+]:// 17 | # Useful links for setting this properly: 18 | # https://docs.sqlalchemy.org/en/20/tutorial/engine.html#establishing-connectivity-the-engine 19 | # https://docs.sqlalchemy.org/en/20/dialects/ (Supported backends) 20 | # https://docs.sqlalchemy.org/en/20/core/engines.html#backend-specific-urls 21 | db-url: "sqlite+aiosqlite://" 22 | 23 | # SQLite only! Name for database file inside modules directory 24 | db-file-name: "db.sqlite3" 25 | 26 | # Bot owner Telegram ID or username 27 | owner: "sanyapilot" 28 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from dataclass_wizard import YAMLWizard 3 | import os 4 | import shutil 5 | from typing import Union 6 | 7 | CONF_FILE = "config.yaml" 8 | 9 | 10 | @dataclass 11 | class Config(YAMLWizard): 12 | token: str 13 | api_id: int 14 | api_hash: str 15 | language: str 16 | fallback_language: str 17 | update_deps_at_load: bool 18 | enable_db: bool 19 | db_url: str 20 | db_file_name: str 21 | owner: Union[int, str] 22 | 23 | 24 | # Load from YAML 25 | if CONF_FILE not in os.listdir("./"): 26 | shutil.copy("config.example.yaml", CONF_FILE) 27 | 28 | config = Config.from_yaml_file(CONF_FILE) 29 | -------------------------------------------------------------------------------- /db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase 2 | from typing import Optional 3 | 4 | 5 | class Base(DeclarativeBase): 6 | pass 7 | 8 | 9 | class CommandPermission(Base): 10 | __tablename__ = "cmd_permissions" 11 | 12 | id: Mapped[int] = mapped_column(primary_key=True) 13 | command: Mapped[str] = mapped_column(unique=True) 14 | module: Mapped[str] 15 | allowed_for: Mapped[str] 16 | 17 | 18 | class User(Base): 19 | __tablename__ = "users" 20 | 21 | id: Mapped[int] = mapped_column(primary_key=True) 22 | name: Mapped[str] 23 | role: Mapped[str] 24 | -------------------------------------------------------------------------------- /extensions/PUT_EXTENSIONS_HERE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PBModular/bot/fa268e4bb342d85bb4e5cdde5c194edb2c397c21/extensions/PUT_EXTENSIONS_HERE -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | Set-ExecutionPolicy Unrestricted -Scope Process 2 | 3 | Clear-Host 4 | 5 | Write-Output " 6 | ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗██╗ █████╗ ██████╗ 7 | ██╔══██╗██╔══██╗████╗ ████║██╔═══██╗██╔══██╗██║ ██║██║ ██╔══██╗██╔══██╗ 8 | ██████╔╝██████╔╝██╔████╔██║██║ ██║██║ ██║██║ ██║██║ ███████║██████╔╝ 9 | ██╔═══╝ ██╔══██╗██║╚██╔╝██║██║ ██║██║ ██║██║ ██║██║ ██╔══██║██╔══██╗ 10 | ██║ ██████╔╝██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████╗██║ ██║██║ ██║ 11 | ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ 12 | " 13 | 14 | git clone https://github.com/PBModular/bot PBModular 15 | if ($LASTEXITCODE -ne 0) { 16 | Write-Error "Git clone failed. Exiting." 17 | exit 1 18 | } 19 | 20 | cd PBModular 21 | 22 | if (-not (Test-Path "venv")) { 23 | python -m venv venv 24 | if ($LASTEXITCODE -ne 0) { 25 | Write-Error "Virtual environment creation failed. Exiting." 26 | exit 1 27 | } 28 | } 29 | 30 | .\venv\Scripts\Activate.ps1 31 | if ($LASTEXITCODE -ne 0) { 32 | Write-Error "Virtual environment activation failed. Exiting." 33 | exit 1 34 | } 35 | 36 | pip install --upgrade pip 37 | if ($LASTEXITCODE -ne 0) { 38 | Write-Error "Pip upgrade failed. Exiting." 39 | exit 1 40 | } 41 | 42 | (Get-Content -Path "requirements.txt") | Where-Object { $_ -notmatch 'uvloop' } | Set-Content -Path "requirements.txt" 43 | 44 | pip install -r requirements.txt 45 | if ($LASTEXITCODE -ne 0) { 46 | Write-Error "Dependency installation failed. Exiting." 47 | exit 1 48 | } 49 | 50 | if (-not (Test-Path "config.yaml")) { 51 | Copy-Item config.example.yaml config.yaml 52 | if ($LASTEXITCODE -ne 0) { 53 | Write-Error "Config file copy failed. Exiting." 54 | exit 1 55 | } 56 | } 57 | 58 | Clear-Host 59 | 60 | Write-Output " 61 | ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗██╗ █████╗ ██████╗ 62 | ██╔══██╗██╔══██╗████╗ ████║██╔═══██╗██╔══██╗██║ ██║██║ ██╔══██╗██╔══██╗ 63 | ██████╔╝██████╔╝██╔████╔██║██║ ██║██║ ██║██║ ██║██║ ███████║██████╔╝ 64 | ██╔═══╝ ██╔══██╗██║╚██╔╝██║██║ ██║██║ ██║██║ ██║██║ ██╔══██║██╔══██╗ 65 | ██║ ██████╔╝██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████╗██║ ██║██║ ██║ 66 | ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ 67 | " 68 | 69 | $bottoken = Read-Host "Enter bot token: " 70 | $api_id = Read-Host "Enter API ID: " 71 | $api_hash = Read-Host "Enter API Hash: " 72 | $username = Read-Host "Enter your Telegram username/ID: " 73 | $language = Read-Host "Choose your language (ru/en/ua): " 74 | 75 | $configContent = Get-Content -Path "config.yaml" 76 | $configContent -replace 'token: null', "token: $bottoken" | 77 | ForEach-Object { $_ -replace 'api-id: null', "api-id: $api_id" } | 78 | ForEach-Object { $_ -replace 'api-hash: null', "api-hash: $api_hash" } | 79 | ForEach-Object { $_ -replace 'owner: "sanyapilot"', "owner: `"$username`"" } | 80 | ForEach-Object { $_ -replace 'language: ru', "language: $language" } | 81 | Set-Content -Path "config.yaml" 82 | 83 | Clear-Host 84 | 85 | python .\main.py 86 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | check_status() { 4 | if [ $? -ne 0 ]; then 5 | echo "Error: $1 failed. Exiting." 6 | exit 1 7 | fi 8 | } 9 | 10 | clear 11 | echo " 12 | ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗██╗ █████╗ ██████╗ 13 | ██╔══██╗██╔══██╗████╗ ████║██╔═══██╗██╔══██╗██║ ██║██║ ██╔══██╗██╔══██╗ 14 | ██████╔╝██████╔╝██╔████╔██║██║ ██║██║ ██║██║ ██║██║ ███████║██████╔╝ 15 | ██╔═══╝ ██╔══██╗██║╚██╔╝██║██║ ██║██║ ██║██║ ██║██║ ██╔══██║██╔══██╗ 16 | ██║ ██████╔╝██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████╗██║ ██║██║ ██║ 17 | ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ 18 | " 19 | 20 | git clone https://github.com/PBModular/bot PBModular 21 | check_status "Git clone" 22 | 23 | cd PBModular 24 | 25 | if [ ! -d "venv" ]; then 26 | python3 -m venv venv 27 | check_status "Virtual environment creation" 28 | fi 29 | 30 | source venv/bin/activate 31 | check_status "Virtual environment activation" 32 | 33 | pip install --upgrade pip 34 | check_status "Pip upgrade" 35 | 36 | pip install -r requirements.txt 37 | check_status "Dependency installation" 38 | 39 | if [ ! -f "config.yaml" ]; then 40 | cp config.example.yaml config.yaml 41 | check_status "Config file copy" 42 | fi 43 | 44 | clear 45 | 46 | echo " 47 | ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗██╗ █████╗ ██████╗ 48 | ██╔══██╗██╔══██╗████╗ ████║██╔═══██╗██╔══██╗██║ ██║██║ ██╔══██╗██╔══██╗ 49 | ██████╔╝██████╔╝██╔████╔██║██║ ██║██║ ██║██║ ██║██║ ███████║██████╔╝ 50 | ██╔═══╝ ██╔══██╗██║╚██╔╝██║██║ ██║██║ ██║██║ ██║██║ ██╔══██║██╔══██╗ 51 | ██║ ██████╔╝██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████╗██║ ██║██║ ██║ 52 | ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ 53 | " 54 | 55 | read -p "Enter bot token: " bottoken 56 | read -p "Enter API ID: " api_id 57 | read -p "Enter API Hash: " api_hash 58 | read -p "Enter your Telegram username/ID: " username 59 | read -p "Choose your language (ru/en/ua): " language 60 | 61 | sed -i "s/token: null/token: $bottoken/" config.yaml 62 | sed -i "s/api-id: null/api-id: $api_id/" config.yaml 63 | sed -i "s/api-hash: null/api-hash: $api_hash/" config.yaml 64 | sed -i "s/owner: \"sanyapilot\"/owner: \"$username\"/" config.yaml 65 | sed -i "s/language: ru/language: $language/" config.yaml 66 | 67 | clear 68 | 69 | python3 main.py 70 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from pyrogram import Client, idle 2 | from pyrogram.enums import ParseMode 3 | from pyrogram.errors.exceptions.bad_request_400 import BadRequest 4 | from base.loader import ModuleLoader 5 | from config import config, CONF_FILE 6 | from logging.handlers import RotatingFileHandler 7 | from colorama import init, Fore, Style 8 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker 9 | from db import Base 10 | 11 | import os 12 | import logging 13 | import subprocess 14 | 15 | init(autoreset=True) 16 | 17 | DATE_FORMAT = "%Y-%m-%d %H:%M:%S" 18 | 19 | class ColorFormatter(logging.Formatter): 20 | status_colors = { 21 | logging.DEBUG: Fore.CYAN, 22 | logging.INFO: Fore.WHITE, 23 | logging.WARN: Fore.YELLOW, 24 | logging.ERROR: Fore.RED, 25 | logging.CRITICAL: Fore.RED + Style.DIM, 26 | } 27 | name_color = Fore.CYAN + Style.BRIGHT 28 | reset = Style.RESET_ALL 29 | time_color = Fore.LIGHTBLACK_EX 30 | separator_color = Fore.LIGHTBLACK_EX 31 | 32 | def format(self, record: logging.LogRecord) -> str: 33 | log_level = record.levelno 34 | level_color = self.status_colors.get(log_level, Fore.WHITE) 35 | 36 | f = ( 37 | f"{self.time_color}%(asctime)s{self.reset}" 38 | f"{self.separator_color} | {self.reset}" 39 | f"{level_color}%(levelname)-8s{self.reset}" 40 | f"{self.separator_color} | {self.reset}" 41 | f"{self.name_color}%(name)s{self.reset}" 42 | f"{self.separator_color} » {self.reset}" 43 | f"{level_color}%(message)s{self.reset}" 44 | ) 45 | 46 | formatter = logging.Formatter(f, datefmt=DATE_FORMAT) 47 | return formatter.format(record) 48 | 49 | # File/Console Logger 50 | file_formatter = logging.Formatter( 51 | "%(asctime)s | %(levelname)-8s | %(name)s:%(module)s:%(funcName)s:%(lineno)d | %(message)s", 52 | datefmt=DATE_FORMAT 53 | ) 54 | file_handler = RotatingFileHandler( 55 | filename="bot.log", maxBytes=128 * 1024, encoding='utf-8' 56 | ) 57 | file_handler.setFormatter(file_formatter) 58 | file_handler.setLevel(logging.INFO) # Change to DEBUG if you need 59 | 60 | stdout_handler = logging.StreamHandler() 61 | stdout_handler.setLevel(logging.INFO) 62 | stdout_handler.setFormatter(ColorFormatter()) 63 | 64 | logging.getLogger().setLevel(logging.DEBUG) 65 | logging.getLogger().addHandler(file_handler) 66 | logging.getLogger().addHandler(stdout_handler) 67 | 68 | logger = logging.getLogger(__name__) 69 | 70 | # Root path 71 | ROOT_DIR = os.getcwd() 72 | 73 | 74 | def get_last_commit_info(): 75 | try: 76 | sha = subprocess.check_output(["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL).strip().decode("utf-8") 77 | date = subprocess.check_output( 78 | ["git", "log", "-1", "--format=%cd", "--date=short"], stderr=subprocess.DEVNULL 79 | ).strip().decode("utf-8") 80 | return sha, date 81 | except subprocess.CalledProcessError: 82 | return "Unknown", "Unknown" 83 | 84 | 85 | def main(update_conf: bool = False): 86 | if config.token and config.api_id and config.api_hash: 87 | # Try to run bot 88 | try: 89 | bot = Client( 90 | name="bot", 91 | api_id=config.api_id, 92 | api_hash=config.api_hash, 93 | bot_token=config.token, 94 | parse_mode=ParseMode.HTML, 95 | ) 96 | 97 | # Reset token and again run main 98 | except BadRequest: 99 | config.token = None 100 | config.api_id = None 101 | config.api_hash = None 102 | main(update_conf=True) 103 | 104 | # All ok, write token to config 105 | if update_conf: 106 | config.to_yaml_file(CONF_FILE) 107 | 108 | logger.info("Bot starting...") 109 | 110 | async def start(): 111 | # Init database 112 | try: 113 | # Use the URL from config if DB is enabled 114 | db_uri = config.db_url if config.enable_db else f"sqlite+aiosqlite:///{config.db_file_name}" 115 | if config.enable_db: 116 | logger.info(f"Database enabled. Connecting to: {db_uri.split('@')[-1]}") 117 | else: 118 | logger.info(f"Database disabled. Using file: {config.db_file_name}") 119 | 120 | engine = create_async_engine(db_uri) 121 | session_maker = async_sessionmaker(engine, expire_on_commit=False) 122 | 123 | async with engine.begin() as conn: 124 | await conn.run_sync(Base.metadata.create_all) 125 | logger.info("Database initialized and tables created/checked.") 126 | except Exception as e: 127 | logger.critical(f"Database initialization failed: {e}") 128 | session_maker = None 129 | engine = None 130 | return 131 | 132 | # Load modules 133 | logger.info("Initializing Module Loader...") 134 | loader = ModuleLoader( 135 | bot, 136 | root_dir=ROOT_DIR, 137 | bot_db_session=session_maker, 138 | bot_db_engine=engine, 139 | ) 140 | loader.load_everything() 141 | logger.info("Module loading complete.") 142 | 143 | # Launch bot 144 | try: 145 | await bot.start() 146 | user = await bot.get_me() 147 | logger.info(f"Bot started as @{user.username} (ID: {user.id})") 148 | await idle() 149 | except Exception as e: 150 | logger.exception("An error occurred during bot runtime.") 151 | finally: 152 | logger.warning("Stopping bot...") 153 | await bot.stop() 154 | if engine: 155 | await engine.dispose() 156 | logger.info("Bot stopped.") 157 | 158 | 159 | # Run the async start function 160 | try: 161 | bot.run(start()) 162 | except KeyboardInterrupt: 163 | logger.info("Shutdown requested by user (KeyboardInterrupt).") 164 | except Exception as e: 165 | logger.exception("Critical error in main loop.") 166 | 167 | else: 168 | logger.warning("Credentials not found in config. Requesting input.") 169 | try: 170 | token_in = input(f"{Fore.YELLOW}Input Bot Token: {Style.RESET_ALL}") 171 | api_id_in = input(f"{Fore.YELLOW}Input API ID: {Style.RESET_ALL}") 172 | api_hash_in = input(f"{Fore.YELLOW}Input API Hash: {Style.RESET_ALL}") 173 | 174 | # Basic validation 175 | if not token_in or not api_id_in.isdigit() or not api_hash_in: 176 | logger.error("Invalid input. Token and API Hash cannot be empty, API ID must be a number.") 177 | return 178 | 179 | config.token = token_in 180 | config.api_id = int(api_id_in) 181 | config.api_hash = api_hash_in 182 | main(update_conf=True) 183 | except EOFError: 184 | logger.critical("Input stream closed unexpectedly. Exiting.") 185 | except ValueError: 186 | logger.error("Invalid API ID provided. It must be an integer.") 187 | except Exception as e: 188 | logger.exception(f"An error occurred during credential input: {e}") 189 | 190 | 191 | if __name__ == "__main__": 192 | sha, date = get_last_commit_info() 193 | print( 194 | f""" 195 | {Fore.CYAN} 196 | ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗██╗ █████╗ ██████╗ 197 | ██╔══██╗██╔══██╗████╗ ████║██╔═══██╗██╔══██╗██║ ██║██║ ██╔══██╗██╔══██╗ 198 | ██████╔╝██████╔╝██╔████╔██║██║ ██║██║ ██║██║ ██║██║ ███████║██████╔╝ 199 | ██╔═══╝ ██╔══██╗██║╚██╔╝██║██║ ██║██║ ██║██║ ██║██║ ██╔══██║██╔══██╗ 200 | ██║ ██████╔╝██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████╗██║ ██║██║ ██║ 201 | ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ 202 | {Style.RESET_ALL} 203 | {Fore.LIGHTBLACK_EX}------------------------------------------------------------{Style.RESET_ALL} 204 | {Fore.CYAN}Commit:{Style.RESET_ALL} {Fore.YELLOW}{sha[:7]}{Style.RESET_ALL} 205 | {Fore.CYAN}Date:{Style.RESET_ALL} {date} 206 | {Fore.LIGHTBLACK_EX}------------------------------------------------------------{Style.RESET_ALL} 207 | """ 208 | ) 209 | try: 210 | main(update_conf=False) 211 | except Exception as e: 212 | logging.getLogger(__name__).exception("An uncaught exception occurred at the top level.") 213 | finally: 214 | print(f"{Fore.LIGHTBLACK_EX}Execution finished.{Style.RESET_ALL}") 215 | -------------------------------------------------------------------------------- /modules/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import CoreModule 2 | -------------------------------------------------------------------------------- /modules/core/config.yaml: -------------------------------------------------------------------------------- 1 | info: 2 | name: Core 3 | author: Developers 4 | version: 1.0.0 5 | description: Module with core functionality like managing other modules, reading logs 6 | 7 | # Use raw loader object. Very dangerous permission! 8 | permissions: 9 | - use_loader 10 | -------------------------------------------------------------------------------- /modules/core/extensions/logs.py: -------------------------------------------------------------------------------- 1 | from base.mod_ext import ModuleExtension 2 | from base.module import command, allowed_for 3 | 4 | from pyrogram.types import Message 5 | 6 | 7 | class LogsExtension(ModuleExtension): 8 | @allowed_for("owner") 9 | @command("logs") 10 | async def logs_cmd(self, _, message: Message): 11 | """Get logs in a message""" 12 | logs = "" 13 | with open("bot.log") as file: 14 | for line in file.readlines()[-10:]: 15 | logs += line 16 | await message.reply(f"{logs}") 17 | 18 | @allowed_for("owner") 19 | @command("log_file") 20 | async def log_file_cmd(self, _, message: Message): 21 | """Get logs as a file""" 22 | await message.reply_document( 23 | "bot.log", caption=self.S["log_file"]["answer_caption_file"] 24 | ) 25 | 26 | @allowed_for("owner") 27 | @command("clear_log") 28 | async def clear_log_cmd(self, _, message: Message): 29 | """Clear logfile""" 30 | with open("bot.log", "w"): 31 | pass 32 | 33 | await message.reply(f"{self.S['log_file']['answer_log_cleared']}") 34 | self.logger.info("Log file cleared") 35 | -------------------------------------------------------------------------------- /modules/core/extensions/permissions.py: -------------------------------------------------------------------------------- 1 | from base.mod_ext import ModuleExtension 2 | from base.module import command, allowed_for 3 | from base import command_registry 4 | from base.loader import ModuleLoader 5 | 6 | from pyrogram import Client 7 | from pyrogram.types import Message 8 | from pyrogram.errors.exceptions.bad_request_400 import BadRequest 9 | 10 | from sqlalchemy import select 11 | from db import CommandPermission, User 12 | 13 | 14 | class PermissionsExtension(ModuleExtension): 15 | @allowed_for("owner") 16 | @command("allow_cmd") 17 | async def allow_cmd(self, _, message: Message): 18 | self.loader: ModuleLoader 19 | args = message.text.split() 20 | if len(args) < 3: 21 | await message.reply(self.S["allow_cmd"]["args_err"]) 22 | return 23 | 24 | cmd = args[1] 25 | roles = args[2:] 26 | if not command_registry.check_command(cmd): 27 | await message.reply(self.S["allow_cmd"]["command_not_found"]) 28 | return 29 | 30 | async with self.loader.bot_db_session() as session: 31 | db_cmd = await session.scalar( 32 | select(CommandPermission).where(CommandPermission.command == cmd) 33 | ) 34 | if db_cmd is None: 35 | db_cmd = CommandPermission( 36 | command=cmd, module=command_registry.get_command_owner(cmd) 37 | ) 38 | session.add(db_cmd) 39 | 40 | db_cmd.allowed_for = ":".join(roles) 41 | await session.commit() 42 | 43 | await message.reply( 44 | self.S["allow_cmd"]["ok"].format(command=cmd, roles=" ".join(roles)) 45 | ) 46 | 47 | @allowed_for("owner") 48 | @command("reset_perms") 49 | async def reset_perms(self, _, message: Message): 50 | self.loader: ModuleLoader 51 | args = message.text.split() 52 | if len(args) != 2: 53 | await message.reply(self.S["reset_perms"]["args_err"]) 54 | return 55 | 56 | cmd = args[1] 57 | if not command_registry.check_command(cmd): 58 | await message.reply(self.S["reset_perms"]["command_not_found"]) 59 | return 60 | 61 | async with self.loader.bot_db_session() as session: 62 | db_cmd = await session.scalar( 63 | select(CommandPermission).where(CommandPermission.command == cmd) 64 | ) 65 | if db_cmd is None: 66 | await message.reply(self.S["reset_perms"]["settings_not_found"]) 67 | return 68 | 69 | await session.delete(db_cmd) 70 | await session.commit() 71 | 72 | await message.reply(self.S["reset_perms"]["ok"].format(command=cmd)) 73 | 74 | @allowed_for("owner") 75 | @command("set_role") 76 | async def set_role_cmd(self, bot: Client, message: Message): 77 | self.loader: ModuleLoader 78 | args = message.text.split() 79 | if len(args) < 3: 80 | await message.reply(self.S["set_role"]["args_err"]) 81 | return 82 | 83 | username, role = args[1:] 84 | try: 85 | user = await bot.get_users(username) 86 | except BadRequest: 87 | await message.reply(self.S["set_role"]["user_not_found"]) 88 | return 89 | 90 | if role in ("chat_owner", "chat_admins", "owner", "all"): 91 | await message.reply(self.S["set_role"]["reserved_role"]) 92 | return 93 | 94 | async with self.loader.bot_db_session() as session: 95 | db_user = await session.scalar(select(User).where(User.id == user.id)) 96 | if db_user is None: 97 | db_user = User(id=user.id, name=user.username) 98 | session.add(db_user) 99 | 100 | db_user.role = role 101 | await session.commit() 102 | 103 | await message.reply( 104 | self.S["set_role"]["ok"].format(user=user.username, role=role) 105 | ) 106 | 107 | @allowed_for("owner") 108 | @command("reset_role") 109 | async def reset_role(self, bot: Client, message: Message): 110 | self.loader: ModuleLoader 111 | args = message.text.split() 112 | if len(args) != 2: 113 | await message.reply(self.S["reset_role"]["args_err"]) 114 | return 115 | 116 | username = args[1] 117 | try: 118 | user = await bot.get_users(username) 119 | except BadRequest: 120 | await message.reply(self.S["reset_role"]["user_not_found"]) 121 | return 122 | 123 | async with self.loader.bot_db_session() as session: 124 | db_user = await session.scalar(select(User).where(User.id == user.id)) 125 | if db_user is None: 126 | await message.reply(self.S["reset_role"]["settings_not_found"]) 127 | return 128 | 129 | await session.delete(db_user) 130 | await session.commit() 131 | 132 | await message.reply(self.S["reset_role"]["ok"].format(user=user.username)) 133 | 134 | @allowed_for("owner") 135 | @command("perms") 136 | async def perm_settings_cmd(self, _, message: Message): 137 | self.loader: ModuleLoader 138 | args = message.text.split() 139 | if len(args) != 2 or args[1] not in ("roles", "commands"): 140 | await message.reply(self.S["perm_settings"]["args_err"]) 141 | return 142 | 143 | async with self.loader.bot_db_session() as session: 144 | if args[1] == "commands": 145 | permissions = (await session.scalars(select(CommandPermission))).all() 146 | if len(permissions) == 0: 147 | text = self.S["perm_settings"]["no_perms"] 148 | else: 149 | text = self.S["perm_settings"]["perms_header"] + "\n" 150 | for perm in permissions: 151 | text += f"/{perm.command}: {perm.allowed_for.replace(':', ' ')}\n" 152 | else: 153 | users = (await session.scalars(select(User))).all() 154 | if len(users) == 0: 155 | text = self.S["perm_settings"]["no_roles"] 156 | else: 157 | text = self.S["perm_settings"]["roles_header"] + "\n" 158 | for user in users: 159 | text += f"@{user.name}: {user.role}\n" 160 | 161 | await message.reply(text) 162 | -------------------------------------------------------------------------------- /modules/core/main.py: -------------------------------------------------------------------------------- 1 | from base.module import BaseModule, HelpPage 2 | from base.module import command 3 | from base.loader import ModuleLoader 4 | from base.mod_ext import ModuleExtension 5 | 6 | from pyrogram.types import Message, InlineKeyboardMarkup 7 | from pyrogram import Client, filters 8 | from typing import Type 9 | import time 10 | 11 | # Extensions 12 | from .extensions.mod_manage import ModManageExtension 13 | from .extensions.logs import LogsExtension 14 | from .extensions.permissions import PermissionsExtension 15 | 16 | 17 | class CoreModule(BaseModule): 18 | @property 19 | def module_extensions(self) -> list[Type[ModuleExtension]]: 20 | return [ModManageExtension, LogsExtension, PermissionsExtension] 21 | 22 | @command("help") 23 | async def help_cmd(self, _, message: Message): 24 | """Displays help page""" 25 | text = self.S["help"]["header"] 26 | for module in self.loaded_modules.values(): 27 | text += f"{module.name} [{module.version}] - {module.author} \n" 28 | 29 | text += "\n" 30 | text += self.S["help"]["footer"] 31 | await message.reply(text) 32 | 33 | @command(["mhelp", "mod_help"]) 34 | async def mod_help_cmd(self, message: Message): 35 | if len(message.text.split()) != 2: 36 | await message.reply(self.S["mod_help"]["args_err"]) 37 | return 38 | 39 | self.loader: ModuleLoader 40 | name = " ".join(message.text.split()[1:]) 41 | help_page = self.loader.get_module_help(self.loader.get_int_name(name)) 42 | if help_page is None: 43 | await message.reply(self.S["mod_help"]["module_not_found"].format(name)) 44 | return 45 | 46 | if isinstance(help_page, HelpPage): 47 | await message.reply( 48 | f"{self.S['mod_help']['module_found'].format(name)}\n\n{help_page.text}", 49 | reply_markup=InlineKeyboardMarkup(help_page.buttons) if help_page.buttons else None 50 | ) 51 | elif type(help_page) == str: 52 | # Backward compatibility with str-only help pages 53 | await message.reply( 54 | f"{self.S['mod_help']['module_found'].format(name)}\n\n{help_page}" 55 | ) 56 | else: 57 | self.logger.error(f"Module {name} has invalid help page! Contact developer") 58 | 59 | @command("ping") 60 | async def ping_cmd(self, _: Client, message: Message): 61 | """Execute a ping to get the processing time""" 62 | start_time = time.perf_counter() 63 | response_message = await message.reply("pong!") 64 | end_time = time.perf_counter() 65 | elapsed_time = end_time - start_time 66 | 67 | formatted_time = f"{elapsed_time * 1000:.2f} ms" 68 | response_text = self.S["ping"]["response"].format(time=formatted_time, locale=self.cur_lang) 69 | await response_message.edit(response_text) 70 | 71 | @command("start", filters.regex(r"/start \w+$")) 72 | async def start_cmd(self, bot: Client, message: Message): 73 | """Execute start for specific module""" 74 | self.loader: ModuleLoader 75 | modname = message.text.split()[1] 76 | 77 | if modname.lower() == "core": 78 | return 79 | 80 | int_name = self.loader.get_int_name(modname) 81 | if int_name is None: 82 | return 83 | 84 | module = self.loader.get_module(int_name) 85 | await module.start_cmd(bot, message) 86 | -------------------------------------------------------------------------------- /modules/core/strings/en.yaml: -------------------------------------------------------------------------------- 1 | yes_btn: Yes ✅ 2 | no_btn: No ❌ 3 | try_again_btn: Try Again 🔄 4 | abort_btn: Abort ❌ 5 | ok_btn: OK 👍 6 | 7 | help: 8 | header: | 9 | Welcome to PBModular! Thank you for using it. 10 | Get help for commands within specific modules using: 11 | /mod_help or 12 | /mhelp 13 | 14 | 📥 Loaded modules: 15 | footer: | 16 | Found it useful? Consider starring us on GitHub 😊 17 | https://github.com/PBModular/bot 18 | 19 | mod_help: 20 | args_err: | 21 | Usage Example: 22 | /mod_help 23 | /mhelp 24 | module_found: "\U0001F4C4 Help for module {module_name}:" 25 | module_not_found: "❌ Help information for the {module_name} module is not available." 26 | 27 | info: 28 | args_err: | 29 | Error: Module name not specified! 30 | 31 | Usage Example: 32 | /mod_info BestModule 33 | not_found: "❌ Error: The requested module could not be found." 34 | header: | 35 | 📄 Module Information: {name} 36 | Author: {author} 37 | Version: {version} 38 | src_url: | 39 | Source code link: 40 | {url} 41 | description: | 42 | Description: 43 | {description} 44 | 45 | install: 46 | perms: 47 | use_loader: Access to module loader ⚠️ 48 | use_db: Database usage 49 | require_db: Requires database access 50 | args_err: | 51 | Error: Module URL not provided! 52 | 53 | Usage Example: 54 | /mod_install https://github.com/SanyaPilot/best_module 55 | start: | 56 | 🛠 Installing module {name}... 57 | 58 | Please wait... 59 | cleanup_err: "❌ Failed to clean up existing module directory for {name} during pre-installation. Error: {error}" 60 | no_config_yaml_err: "❌ Module {name} downloaded, but config.yaml is missing. Installation aborted." 61 | config_parse_err: | 62 | ❌ Failed to parse config.yaml for module {name}. 63 | Installation aborted. Error: {error} 64 | confirm: | 65 | Are you sure you want to install the module {name}? 66 | Author: {author} 67 | Version: {version} 68 | confirm_perms: | 69 | Requested permissions: 70 | {perms} 71 | confirm_warn_perms: "Warning! This module requests potentially dangerous permissions (marked with ⚠)." 72 | confirmation_expired: "⚠️ Installation confirmation expired or was already handled." 73 | processing: "⚙️ Finalizing installation for {name}..." 74 | down_reqs_next: | 75 | 🛠 Module {name} downloaded! 76 | Installing dependencies... 77 | down_end_next: | 78 | 🛠 Module {name} downloaded! 79 | Loading module... 80 | reqs_ok: | 81 | 🛠 Dependencies for module {name} installed! 82 | Loading module... 83 | loading: "⏳ Loading module {name}..." 84 | end_reqs: | 85 | ✅ Module {name} installed successfully! 86 | 87 | Required Python dependencies were installed: 88 | {reqs} 89 | 90 | See usage with: 91 | /help {name} 92 | end: | 93 | ✅ Module {name} installed successfully! 94 | 95 | See usage with: 96 | /help {name} 97 | aborted: "❌ Installation aborted." 98 | down_err: | 99 | ❌ Failed to download module {name}! 100 | 101 | Output from `git clone`: 102 | {out} 103 | reqs_err: | 104 | ❌ Failed to install dependencies for module {name}! 105 | 106 | Output from `pip install`: 107 | {out} 108 | 109 | If this error seems unrelated to your setup, please contact the developer with: 110 | - Your Python version 111 | - Bot version 112 | - The output above 113 | load_err: | 114 | ❌ Failed to load module {name}! 115 | 116 | Check the bot logs for detailed error information. If needed, contact the developer with: 117 | - Your Python version 118 | - Bot version 119 | - The relevant error log 120 | unexpected_err: "❌ An unexpected error occurred while installing module {name}: {error}" 121 | 122 | ping: 123 | response: "Pong! Request processed in {time}." 124 | 125 | uninstall: 126 | args_err: | 127 | Error: Module name not specified! 128 | 129 | Usage Example: 130 | /mod_uninstall BestModule 131 | not_found: "❌ Module {name} not found!" 132 | ok: "✅ Module {name} uninstalled successfully!" 133 | err: "❌ Error uninstalling module {name}!" 134 | unload_err_before_delete: "❌ Failed to unload module {name} before deletion." 135 | uninstall_core: "❌ Error: The Core module cannot be uninstalled!" 136 | 137 | update: 138 | args_err: | 139 | Error: Module name not specified! 140 | 141 | Usage Example: 142 | /mod_update BestModule 143 | checking: "🔎 Checking for updates for {name}..." 144 | check_err: "❌ Failed to check for updates for {name}." 145 | prepare_err: "❌ Error preparing module {name} for update: {error}. Unloading failed." 146 | pulling: "🔄 Pulling updates for {name}..." 147 | checking_info: "📄 Verifying updated module information for {name}..." 148 | config_yaml_missing_after_update: "❌ config.yaml is missing after updating module {name}. Update cannot proceed." 149 | config_parse_err_after_update: "❌ Failed to parse config.yaml after updating module {name}. Error: {error}" 150 | confirm: | 151 | Are you sure you want to update the module {name}? 152 | Author: {author} 153 | Current Version: {old_ver} 154 | New Version: {new_ver} 155 | confirmation_expired: "⚠️ Update confirmation expired or was already handled." 156 | processing: "⚙️ Finalizing update for {name}..." 157 | loading: "⏳ Loading updated module {name}..." 158 | err: | 159 | ❌ Failed to update module {name}! 160 | 161 | Output from git pull: 162 | {out} 163 | err_no_backup: "❌ Update for {name} failed, and no backup was created to revert to." 164 | ok: | 165 | ✅ Module {name} updated successfully! 166 | 167 | Version: {old_ver} → {new_ver} 168 | Repository URL: {url} 169 | no_updates_found: "✅ Module {name} is already up-to-date." 170 | reqs: "Required Python dependencies for the updated module:" 171 | abort: "❌ Update aborted." 172 | abort_no_backup: "❌ Update for {name} aborted. No automatic backup was made during this attempt." 173 | info_file_missing: "❌ Error: Missing info.yaml file. Aborting update..." 174 | unexpected_err: "❌ An unexpected error occurred while updating module {name}: {error}" 175 | 176 | log_file: 177 | answer_caption_file: 📁 Here is the current log file 178 | answer_log_cleared: ♻️ Log file cleared successfully. 179 | 180 | allow_cmd: 181 | args_err: | 182 | Error: Command or role not specified! 183 | 184 | Usage Example: 185 | /allow_cmd best_command 186 | 187 | Where is one of: 188 | chat_owner - Chat owner 189 | chat_admins - Chat administrators 190 | owner - Bot owner 191 | all - All users 192 | Or another custom role name / @username. 193 | command_not_found: "❌ Error: The specified command was not found." 194 | ok: | 195 | ✅ Permissions updated for command {command}. 196 | Allowed roles: {roles} 197 | 198 | reset_perms: 199 | args_err: | 200 | Error: Command not specified! 201 | 202 | Usage Example: 203 | /reset_perms best_command 204 | command_not_found: "❌ Error: The specified command was not found." 205 | settings_not_found: "ℹ️ No custom permission settings found for this command." 206 | ok: | 207 | ✅ Permissions reset successfully for command {command}! 208 | 209 | set_role: 210 | args_err: | 211 | Error: User or role not specified! 212 | 213 | Usage Example: 214 | /set_role @username 215 | 216 | Where is the desired role. 217 | reserved_role: "❌ Error: This role name is reserved and cannot be assigned." 218 | user_not_found: "❌ Error: The specified user was not found." 219 | ok: | 220 | ✅ Role updated for user {user}. 221 | New role: {role} 222 | 223 | reset_role: 224 | args_err: | 225 | Error: User not specified! 226 | 227 | Usage Example: 228 | /reset_role @username 229 | user_not_found: "❌ Error: The specified user was not found." 230 | settings_not_found: "ℹ️ No custom role found for this user." 231 | ok: | 232 | ✅ Role reset successfully for user {user}! 233 | 234 | perm_settings: 235 | args_err: | 236 | Usage: 237 | /perms roles (Show user roles) 238 | /perms commands (Show command permissions) 239 | perms_header: "\U0001F4C4 Custom Command Permissions:" 240 | roles_header: "\U0001F4C4 Custom User Roles:" 241 | no_perms: "ℹ️ No custom command permissions have been set." 242 | no_roles: "ℹ️ No custom user roles have been assigned." 243 | 244 | load: 245 | args_err: | 246 | Usage Example: 247 | /mod_load 248 | load_err: | 249 | ❌ Error loading module {name}! 250 | Please check the logs for details. 251 | not_found: "❌ Module {name} not found!" 252 | already_loaded_err: "⚠️ Module {name} is already loaded." 253 | ok: "✅ Module {name} loaded successfully!" 254 | 255 | unload: 256 | args_err: | 257 | Usage Example: 258 | /mod_unload 259 | not_loaded_err: "⚠️ Module {name} is not currently loaded." 260 | unload_core: "❌ Error: The Core module cannot be unloaded!" 261 | ok: "✅ Module {name} unloaded successfully!" 262 | 263 | reload: 264 | args_err: | 265 | Usage Example: 266 | /mod_reload 267 | loading: | 268 | ⚙️ Reloading module {module_name}... 269 | Please wait. 270 | ok: "✅ Module {module_name} reloaded successfully!" 271 | 272 | modules: 273 | list: "Installed modules:" 274 | next_btn: Next → 275 | prev_btn: ← Previous 276 | 277 | module_page: 278 | invalid_module: ⚠️ Module is invalid. Please check its local files! 279 | name: "Name: {name}" 280 | author: "Author: {author}" 281 | version: "Version: {version}" 282 | src_url: "Repository: {url}" 283 | description: | 284 | Description: 285 | {description} 286 | updates_found: "🚀 Update available!" 287 | no_updates_found: "✅ Module is up-to-date." 288 | update_btn: Update 🚀 289 | delete_btn: Uninstall 🗑️ 290 | back_btn: ← Back 291 | refresh_page_btn: Refresh 🔄 292 | refresh_page_err: | 293 | ❌ Failed to refresh page for {module_name}! 294 | The module might have been unloaded, or an unexpected error occurred. 295 | no_changes: No changes detected. 296 | unload_btn: Unload 🔌 297 | load_btn: Load 🔌 298 | reload_btn: Reload 🔄 299 | reload_ok: ✅ Module {module_name} reloaded successfully! 300 | auto_load: "Auto-load: {status}" 301 | enabled: Enabled 302 | disabled: Disabled 303 | enable_auto_load_btn: Enable Auto-load 304 | disable_auto_load_btn: Disable Auto-load 305 | auto_load_toggled: ✅ Auto-load {status}. 306 | auto_load_toggle_error: ❌ Failed to update auto-load setting. 307 | 308 | backup: 309 | no_backups: "ℹ️ No backups found." 310 | no_backups_module: "ℹ️ No backups found for module {name}." 311 | list_module: "Available backups for module {name}:" 312 | view_backups_btn: View Backups 📂 313 | restore_btn: Restore 💾 314 | cleanup_btn: Cleanup Backups 🧹 315 | restore_latest_btn: Restore Latest 💾 316 | creating_backup: "⚙️ Creating backup for module {name}..." 317 | backup_failed: "❌ Backup failed for module {name}." 318 | backup_created: | 319 | ✅ Backup created successfully. 320 | Path: {path} 321 | backup_failed_during_update: "⚠️ Warning: A backup could not be created before attempting the update." 322 | offer_restore: "An error occurred. Would you like to restore from the backup {backup} created for {name} before the operation?" 323 | restoring: "⚙️ Restoring module {name} from selected backup..." 324 | restore_success: "✅ Module {name} successfully restored from backup {backup}." 325 | restore_failed: "❌ Failed to restore module {name} from backup." 326 | restore_canceled: | 327 | ⚠️ Backup restoration canceled for {name}. 328 | The module may be in an inconsistent state. 329 | restore_load_err: | 330 | ⚠️ Module {name} restored from backup {backup}, but failed to load afterwards. 331 | Check bot logs for details. 332 | restore_skipped_files: "⚠️ Some files ({count}) were skipped during restoration. Check logs for details." 333 | cleanup_select_count: "Choose how many recent backups to keep for {name}:" 334 | back_btn: ← Back 335 | confirm_restore: "Are you sure you want to restore module {name} from backup {backup}?" 336 | all_except_latest: Keep only the latest 337 | cleanup_complete: "✅ Cleaned up {count} old backups for module {name}. Kept the {keep} most recent." 338 | invalid_backup: "❌ Error: Invalid backup selected." 339 | invalid_backup_path: "❌ The provided backup path is invalid or the file does not exist." 340 | invalid_backup_path_edit: "❌ Invalid backup path. Please try again or go back." -------------------------------------------------------------------------------- /modules/core/strings/ru.yaml: -------------------------------------------------------------------------------- 1 | yes_btn: Да ✅ 2 | no_btn: Нет ❌ 3 | try_again_btn: Попробовать снова 🔄 4 | abort_btn: Отмена ❌ 5 | ok_btn: OK 👍 6 | 7 | help: 8 | header: | 9 | Добро пожаловать в PBModular! Спасибо за использование. 10 | Получить справку по командам конкретных модулей можно с помощью: 11 | /mod_help <ИмяМодуля> или 12 | /mhelp <ИмяМодуля> 13 | 14 | 📥 Загруженные модули: 15 | footer: | 16 | Понравилось? Поставьте нам звезду на GitHub 😊 17 | https://github.com/PBModular/bot 18 | 19 | mod_help: 20 | args_err: | 21 | Пример использования: 22 | /mod_help <ИмяМодуля> 23 | /mhelp <ИмяМодуля> 24 | module_found: "\U0001F4C4 Справка для модуля {module_name}:" 25 | module_not_found: "❌ Справочная информация для модуля {module_name} недоступна." 26 | 27 | info: 28 | args_err: | 29 | Ошибка: Имя модуля не указано! 30 | 31 | Пример использования: 32 | /mod_info ЛучшийМодуль 33 | not_found: "❌ Ошибка: Запрошенный модуль не найден." 34 | header: | 35 | 📄 Информация о модуле: {name} 36 | Автор: {author} 37 | Версия: {version} 38 | src_url: | 39 | Ссылка на исходный код: 40 | {url} 41 | description: | 42 | Описание: 43 | {description} 44 | 45 | install: 46 | perms: 47 | use_loader: Доступ к загрузчику модулей ⚠️ 48 | use_db: Использование базы данных 49 | require_db: Требуется доступ к базе данных 50 | args_err: | 51 | Ошибка: URL модуля не указан! 52 | 53 | Пример использования: 54 | /mod_install https://github.com/SanyaPilot/best_module 55 | start: | 56 | 🛠 Установка модуля {name}... 57 | 58 | Пожалуйста, подождите... 59 | cleanup_err: "❌ Не удалось очистить существующую директорию модуля {name} перед установкой. Ошибка: {error}" 60 | no_config_yaml_err: "❌ Модуль {name} загружен, но файл config.yaml отсутствует. Установка прервана." 61 | config_parse_err: | 62 | ❌ Не удалось обработать config.yaml для модуля {name}. 63 | Установка прервана. Ошибка: {error} 64 | confirm: | 65 | Вы уверены, что хотите установить модуль {name}? 66 | Автор: {author} 67 | Версия: {version} 68 | confirm_perms: | 69 | Запрошенные разрешения: 70 | {perms} 71 | confirm_warn_perms: "Внимание! Этот модуль запрашивает потенциально опасные разрешения (отмечены ⚠)." 72 | confirmation_expired: "⚠️ Подтверждение установки истекло или уже было обработано." 73 | processing: "⚙️ Завершение установки для {name}..." 74 | down_reqs_next: | 75 | 🛠 Модуль {name} загружен! 76 | Установка зависимостей... 77 | down_end_next: | 78 | 🛠 Модуль {name} загружен! 79 | Загрузка модуля... 80 | reqs_ok: | 81 | 🛠 Зависимости для модуля {name} установлены! 82 | Загрузка модуля... 83 | loading: "⏳ Загрузка модуля {name}..." 84 | end_reqs: | 85 | ✅ Модуль {name} успешно установлен! 86 | 87 | Установлены необходимые Python зависимости: 88 | {reqs} 89 | 90 | Посмотрите использование с помощью: 91 | /help {name} 92 | end: | 93 | ✅ Модуль {name} успешно установлен! 94 | 95 | Посмотрите использование с помощью: 96 | /help {name} 97 | aborted: "❌ Установка прервана." 98 | down_err: | 99 | ❌ Не удалось загрузить модуль {name}! 100 | 101 | Вывод `git clone`: 102 | {out} 103 | reqs_err: | 104 | ❌ Не удалось установить зависимости для модуля {name}! 105 | 106 | Вывод `pip install`: 107 | {out} 108 | 109 | Если эта ошибка кажется не связанной с вашей настройкой, пожалуйста, свяжитесь с разработчиком, предоставив: 110 | - Вашу версию Python 111 | - Версию бота 112 | - Вывод выше 113 | load_err: | 114 | ❌ Не удалось загрузить модуль {name}! 115 | 116 | Проверьте логи бота для детальной информации об ошибке. При необходимости свяжитесь с разработчиком, предоставив: 117 | - Вашу версию Python 118 | - Версию бота 119 | - Соответствующий лог ошибки 120 | unexpected_err: "❌ Произошла непредвиденная ошибка при установке модуля {name}: {error}" 121 | 122 | ping: 123 | response: "Понг! Запрос обработан за {time}." 124 | 125 | uninstall: 126 | args_err: | 127 | Ошибка: Имя модуля не указано! 128 | 129 | Пример использования: 130 | /mod_uninstall ЛучшийМодуль 131 | not_found: "❌ Модуль {name} не найден!" 132 | ok: "✅ Модуль {name} успешно удален!" 133 | err: "❌ Ошибка при удалении модуля {name}!" 134 | unload_err_before_delete: "❌ Не удалось выгрузить модуль {name} перед удалением." 135 | uninstall_core: "❌ Ошибка: Модуль Core не может быть удален!" 136 | 137 | update: 138 | args_err: | 139 | Ошибка: Имя модуля не указано! 140 | 141 | Пример использования: 142 | /mod_update ЛучшийМодуль 143 | checking: "🔎 Проверка обновлений для {name}..." 144 | check_err: "❌ Не удалось проверить обновления для {name}." 145 | prepare_err: "❌ Ошибка подготовки модуля {name} к обновлению: {error}. Выгрузка не удалась." 146 | pulling: "🔄 Загрузка обновлений для {name}..." 147 | checking_info: "📄 Проверка обновленной информации модуля {name}..." 148 | config_yaml_missing_after_update: "❌ Файл config.yaml отсутствует после обновления модуля {name}. Обновление не может быть продолжено." 149 | config_parse_err_after_update: "❌ Не удалось обработать config.yaml после обновления модуля {name}. Ошибка: {error}" 150 | confirm: | 151 | Вы уверены, что хотите обновить модуль {name}? 152 | Автор: {author} 153 | Текущая версия: {old_ver} 154 | Новая версия: {new_ver} 155 | confirmation_expired: "⚠️ Подтверждение обновления истекло или уже было обработано." 156 | processing: "⚙️ Завершение обновления для {name}..." 157 | loading: "⏳ Загрузка обновленного модуля {name}..." 158 | err: | 159 | ❌ Не удалось обновить модуль {name}! 160 | 161 | Вывод git pull: 162 | {out} 163 | err_no_backup: "❌ Обновление модуля {name} не удалось, и резервная копия для отката не была создана." 164 | ok: | 165 | ✅ Модуль {name} успешно обновлен! 166 | 167 | Версия: {old_ver} → {new_ver} 168 | URL репозитория: {url} 169 | no_updates_found: "✅ Модуль {name} уже последней версии." 170 | reqs: "Необходимые Python зависимости для обновленного модуля:" 171 | abort: "❌ Обновление прервано." 172 | abort_no_backup: "❌ Обновление модуля {name} прервано. Автоматическая резервная копия не была создана во время этой попытки." 173 | info_file_missing: "❌ Ошибка: Отсутствует файл info.yaml. Прерывание обновления..." 174 | unexpected_err: "❌ Произошла непредвиденная ошибка при обновлении модуля {name}: {error}" 175 | 176 | log_file: 177 | answer_caption_file: 📁 Вот текущий файл логов 178 | answer_log_cleared: ♻️ Файл логов успешно очищен. 179 | 180 | allow_cmd: 181 | args_err: | 182 | Ошибка: Команда или роль не указаны! 183 | 184 | Пример использования: 185 | /allow_cmd лучшая_команда <роль> 186 | 187 | Где <роль> одна из: 188 | chat_owner - Владелец чата 189 | chat_admins - Администраторы чата 190 | owner - Владелец бота 191 | all - Все пользователи 192 | Или другое пользовательское имя роли / @username. 193 | command_not_found: "❌ Ошибка: Указанная команда не найдена." 194 | ok: | 195 | ✅ Разрешения обновлены для команды {command}. 196 | Разрешенные роли: {roles} 197 | 198 | reset_perms: 199 | args_err: | 200 | Ошибка: Команда не указана! 201 | 202 | Пример использования: 203 | /reset_perms лучшая_команда 204 | command_not_found: "❌ Ошибка: Указанная команда не найдена." 205 | settings_not_found: "ℹ️ Не найдено пользовательских настроек разрешений для этой команды." 206 | ok: | 207 | ✅ Разрешения успешно сброшены для команды {command}! 208 | 209 | set_role: 210 | args_err: | 211 | Ошибка: Пользователь или роль не указаны! 212 | 213 | Пример использования: 214 | /set_role @username <имя_роли> 215 | 216 | Где <имя_роли> - желаемая роль. 217 | reserved_role: "❌ Ошибка: Это имя роли зарезервировано и не может быть назначено." 218 | user_not_found: "❌ Ошибка: Указанный пользователь не найден." 219 | ok: | 220 | ✅ Роль обновлена для пользователя {user}. 221 | Новая роль: {role} 222 | 223 | reset_role: 224 | args_err: | 225 | Ошибка: Пользователь не указан! 226 | 227 | Пример использования: 228 | /reset_role @username 229 | user_not_found: "❌ Ошибка: Указанный пользователь не найден." 230 | settings_not_found: "ℹ️ Не найдено пользовательской роли для этого пользователя." 231 | ok: | 232 | ✅ Роль успешно сброшена для пользователя {user}! 233 | 234 | perm_settings: 235 | args_err: | 236 | Использование: 237 | /perms roles (Показать роли пользователей) 238 | /perms commands (Показать разрешения команд) 239 | perms_header: "\U0001F4C4 Пользовательские разрешения команд:" 240 | roles_header: "\U0001F4C4 Пользовательские роли пользователей:" 241 | no_perms: "ℹ️ Пользовательские разрешения для команд не установлены." 242 | no_roles: "ℹ️ Пользовательские роли не назначены." 243 | 244 | load: 245 | args_err: | 246 | Пример использования: 247 | /mod_load <ИмяМодуля> 248 | load_err: | 249 | ❌ Ошибка загрузки модуля {name}! 250 | Пожалуйста, проверьте логи для деталей. 251 | not_found: "❌ Модуль {name} не найден!" 252 | already_loaded_err: "⚠️ Модуль {name} уже загружен." 253 | ok: "✅ Модуль {name} успешно загружен!" 254 | 255 | unload: 256 | args_err: | 257 | Пример использования: 258 | /mod_unload <ИмяМодуля> 259 | not_loaded_err: "⚠️ Модуль {name} в данный момент не загружен." 260 | unload_core: "❌ Ошибка: Модуль Core не может быть выгружен!" 261 | ok: "✅ Модуль {name} успешно выгружен!" 262 | 263 | reload: 264 | args_err: | 265 | Пример использования: 266 | /mod_reload <ИмяМодуля> 267 | loading: | 268 | ⚙️ Перезагрузка модуля {module_name}... 269 | Пожалуйста, подождите. 270 | ok: "✅ Модуль {module_name} успешно перезагружен!" 271 | 272 | modules: 273 | list: "Установленные модули:" 274 | next_btn: Далее → 275 | prev_btn: ← Назад 276 | 277 | module_page: 278 | invalid_module: ⚠️ Модуль недействителен. Пожалуйста, проверьте его локальные файлы! 279 | name: "Название: {name}" 280 | author: "Автор: {author}" 281 | version: "Версия: {version}" 282 | src_url: "Репозиторий: {url}" 283 | description: | 284 | Описание: 285 | {description} 286 | updates_found: "🚀 Доступно обновление!" 287 | no_updates_found: "✅ Модуль последней версии." 288 | update_btn: Обновить 🚀 289 | delete_btn: Удалить 🗑️ 290 | back_btn: ← Назад 291 | refresh_page_btn: Обновить 🔄 292 | refresh_page_err: | 293 | ❌ Не удалось обновить страницу для {module_name}! 294 | Модуль мог быть выгружен, или произошла непредвиденная ошибка. 295 | no_changes: Изменений не обнаружено. 296 | unload_btn: Выгрузить 🔌 297 | load_btn: Загрузить 🔌 298 | reload_btn: Перезагрузить 🔄 299 | reload_ok: ✅ Модуль {module_name} успешно перезагружен! 300 | auto_load: "Автозагрузка: {status}" 301 | enabled: Включена 302 | disabled: Выключена 303 | enable_auto_load_btn: Включить автозагрузку 304 | disable_auto_load_btn: Выключить автозагрузку 305 | auto_load_toggled: ✅ Автозагрузка {status}. 306 | auto_load_toggle_error: ❌ Не удалось обновить настройку автозагрузки. 307 | 308 | backup: 309 | no_backups: "ℹ️ Резервные копии не найдены." 310 | no_backups_module: "ℹ️ Резервные копии для модуля {name} не найдены." 311 | list_module: "Доступные резервные копии для модуля {name}:" 312 | view_backups_btn: Просмотреть копии 📂 313 | restore_btn: Восстановить 💾 314 | cleanup_btn: Очистить копии 🧹 315 | restore_latest_btn: Восстановить последнюю 💾 316 | creating_backup: "⚙️ Создание резервной копии для модуля {name}..." 317 | backup_failed: "❌ Не удалось создать резервную копию для модуля {name}." 318 | backup_created: | 319 | ✅ Резервная копия успешно создана. 320 | Путь: {path} 321 | backup_failed_during_update: "⚠️ Внимание: Не удалось создать резервную копию перед попыткой обновления." 322 | offer_restore: "Произошла ошибка. Хотите восстановить из резервной копии {backup}, созданной для {name} перед операцией?" 323 | restoring: "⚙️ Восстановление модуля {name} из выбранной резервной копии..." 324 | restore_success: "✅ Модуль {name} успешно восстановлен из резервной копии {backup}." 325 | restore_failed: "❌ Не удалось восстановить модуль {name} из резервной копии." 326 | restore_canceled: | 327 | ⚠️ Восстановление из резервной копии отменено для {name}. 328 | Модуль может находиться в неконсистентном состоянии. 329 | restore_load_err: | 330 | ⚠️ Модуль {name} восстановлен из резервной копии {backup}, но не удалось загрузить его после этого. 331 | Проверьте логи бота для деталей. 332 | restore_skipped_files: "⚠️ Некоторые файлы ({count}) были пропущены во время восстановления. Проверьте логи для деталей." 333 | cleanup_select_count: "Выберите, сколько последних резервных копий сохранить для {name}:" 334 | back_btn: ← Назад 335 | confirm_restore: "Вы уверены, что хотите восстановить модуль {name} из резервной копии {backup}?" 336 | all_except_latest: Оставить только последнюю 337 | cleanup_complete: "✅ Очищено {count} старых резервных копий для модуля {name}. Сохранено {keep} последних." 338 | invalid_backup: "❌ Ошибка: Выбрана недействительная резервная копия." 339 | invalid_backup_path: "❌ Указанный путь резервной копии недействителен или файл не существует." 340 | invalid_backup_path_edit: "❌ Неверный путь резервной копии. Попробуйте еще раз или вернитесь назад." -------------------------------------------------------------------------------- /modules/core/strings/uk.yaml: -------------------------------------------------------------------------------- 1 | yes_btn: Так ✅ 2 | no_btn: Ні ❌ 3 | try_again_btn: Спробувати ще 🔄 4 | abort_btn: Скасувати ❌ 5 | ok_btn: OK 👍 6 | 7 | help: 8 | header: | 9 | Ласкаво просимо до PBModular! Дякуємо за використання. 10 | Отримати довідку щодо команд конкретних модулів можна за допомогою: 11 | /mod_help <НазваМодуля> або 12 | /mhelp <НазваМодуля> 13 | 14 | 📥 Завантажені модулі: 15 | footer: | 16 | Сподобалося? Поставте нам зірку на GitHub 😊 17 | https://github.com/PBModular/bot 18 | 19 | mod_help: 20 | args_err: | 21 | Приклад використання: 22 | /mod_help <НазваМодуля> 23 | /mhelp <НазваМодуля> 24 | module_found: "\U0001F4C4 Довідка для модуля {module_name}:" 25 | module_not_found: "❌ Довідкова інформація для модуля {module_name} недоступна." 26 | 27 | info: 28 | args_err: | 29 | Помилка: Назву модуля не вказано! 30 | 31 | Приклад використання: 32 | /mod_info НайкращийМодуль 33 | not_found: "❌ Помилка: Запитаний модуль не знайдено." 34 | header: | 35 | 📄 Інформація про модуль: {name} 36 | Автор: {author} 37 | Версія: {version} 38 | src_url: | 39 | Посилання на вихідний код: 40 | {url} 41 | description: | 42 | Опис: 43 | {description} 44 | 45 | install: 46 | perms: 47 | use_loader: Доступ до завантажувача модулів ⚠️ 48 | use_db: Використання бази даних 49 | require_db: Потрібен доступ до бази даних 50 | args_err: | 51 | Помилка: URL модуля не надано! 52 | 53 | Приклад використання: 54 | /mod_install https://github.com/SanyaPilot/best_module 55 | start: | 56 | 🛠 Встановлення модуля {name}... 57 | 58 | Будь ласка, зачекайте... 59 | cleanup_err: "❌ Не вдалося очистити існуючу директорію модуля {name} перед встановленням. Помилка: {error}" 60 | no_config_yaml_err: "❌ Модуль {name} завантажено, але файл config.yaml відсутній. Встановлення скасовано." 61 | config_parse_err: | 62 | ❌ Не вдалося обробити config.yaml для модуля {name}. 63 | Встановлення скасовано. Помилка: {error} 64 | confirm: | 65 | Ви впевнені, що хочете встановити модуль {name}? 66 | Автор: {author} 67 | Версія: {version} 68 | confirm_perms: | 69 | Запитані дозволи: 70 | {perms} 71 | confirm_warn_perms: "Увага! Цей модуль запитує потенційно небезпечні дозволи (позначені ⚠)." 72 | confirmation_expired: "⚠️ Підтвердження встановлення закінчилося або вже було оброблено." 73 | processing: "⚙️ Завершення встановлення для {name}..." 74 | down_reqs_next: | 75 | 🛠 Модуль {name} завантажено! 76 | Встановлення залежностей... 77 | down_end_next: | 78 | 🛠 Модуль {name} завантажено! 79 | Завантаження модуля... 80 | reqs_ok: | 81 | 🛠 Залежності для модуля {name} встановлено! 82 | Завантаження модуля... 83 | loading: "⏳ Завантаження модуля {name}..." 84 | end_reqs: | 85 | ✅ Модуль {name} успішно встановлено! 86 | 87 | Встановлено необхідні Python залежності: 88 | {reqs} 89 | 90 | Перегляньте використання за допомогою: 91 | /help {name} 92 | end: | 93 | ✅ Модуль {name} успішно встановлено! 94 | 95 | Перегляньте використання за допомогою: 96 | /help {name} 97 | aborted: "❌ Встановлення скасовано." 98 | down_err: | 99 | ❌ Не вдалося завантажити модуль {name}! 100 | 101 | Вивід `git clone`: 102 | {out} 103 | reqs_err: | 104 | ❌ Не вдалося встановити залежності для модуля {name}! 105 | 106 | Вивід `pip install`: 107 | {out} 108 | 109 | Якщо ця помилка здається не пов'язаною з вашим налаштуванням, будь ласка, зв'яжіться з розробником, надавши: 110 | - Вашу версію Python 111 | - Версію бота 112 | - Вивід вище 113 | load_err: | 114 | ❌ Не вдалося завантажити модуль {name}! 115 | 116 | Перевірте логи бота для детальної інформації про помилку. За потреби зв'яжіться з розробником, надавши: 117 | - Вашу версію Python 118 | - Версію бота 119 | - Відповідний лог помилки 120 | unexpected_err: "❌ Сталася неочікувана помилка під час встановлення модуля {name}: {error}" 121 | 122 | ping: 123 | response: "Понг! Запит оброблено за {time}." 124 | 125 | uninstall: 126 | args_err: | 127 | Помилка: Назву модуля не вказано! 128 | 129 | Приклад використання: 130 | /mod_uninstall НайкращийМодуль 131 | not_found: "❌ Модуль {name} не знайдено!" 132 | ok: "✅ Модуль {name} успішно видалено!" 133 | err: "❌ Помилка під час видалення модуля {name}!" 134 | unload_err_before_delete: "❌ Не вдалося вивантажити модуль {name} перед видаленням." 135 | uninstall_core: "❌ Помилка: Модуль Core не може бути видалений!" 136 | 137 | update: 138 | args_err: | 139 | Помилка: Назву модуля не вказано! 140 | 141 | Приклад використання: 142 | /mod_update НайкращийМодуль 143 | checking: "🔎 Перевірка оновлень для {name}..." 144 | check_err: "❌ Не вдалося перевірити оновлення для {name}." 145 | prepare_err: "❌ Помилка підготовки модуля {name} до оновлення: {error}. Вивантаження не вдалося." 146 | pulling: "🔄 Завантаження оновлень для {name}..." 147 | checking_info: "📄 Перевірка оновленої інформації модуля {name}..." 148 | config_yaml_missing_after_update: "❌ Файл config.yaml відсутній після оновлення модуля {name}. Оновлення не може бути продовжено." 149 | config_parse_err_after_update: "❌ Не вдалося обробити config.yaml після оновлення модуля {name}. Помилка: {error}" 150 | confirm: | 151 | Ви впевнені, що хочете оновити модуль {name}? 152 | Автор: {author} 153 | Поточна версія: {old_ver} 154 | Нова версія: {new_ver} 155 | confirmation_expired: "⚠️ Підтвердження оновлення закінчилося або вже було оброблено." 156 | processing: "⚙️ Завершення оновлення для {name}..." 157 | loading: "⏳ Завантаження оновленого модуля {name}..." 158 | err: | 159 | ❌ Не вдалося оновити модуль {name}! 160 | 161 | Вивід git pull: 162 | {out} 163 | err_no_backup: "❌ Оновлення модуля {name} не вдалося, і резервну копію для відкату не було створено." 164 | ok: | 165 | ✅ Модуль {name} успішно оновлено! 166 | 167 | Версія: {old_ver} → {new_ver} 168 | URL репозиторію: {url} 169 | no_updates_found: "✅ Модуль {name} вже останньої версії." 170 | reqs: "Необхідні Python залежності для оновленого модуля:" 171 | abort: "❌ Оновлення скасовано." 172 | abort_no_backup: "❌ Оновлення модуля {name} скасовано. Автоматична резервна копія не була створена під час цієї спроби." 173 | info_file_missing: "❌ Помилка: Відсутній файл info.yaml. Скасування оновлення..." 174 | unexpected_err: "❌ Сталася неочікувана помилка під час оновлення модуля {name}: {error}" 175 | 176 | log_file: 177 | answer_caption_file: 📁 Ось поточний файл логів 178 | answer_log_cleared: ♻️ Файл логів успішно очищено. 179 | 180 | allow_cmd: 181 | args_err: | 182 | Помилка: Команду або роль не вказано! 183 | 184 | Приклад використання: 185 | /allow_cmd найкраща_команда <роль> 186 | 187 | Де <роль> одна з: 188 | chat_owner - Власник чату 189 | chat_admins - Адміністратори чату 190 | owner - Власник бота 191 | all - Всі користувачі 192 | Або інша користувацька назва ролі / @username. 193 | command_not_found: "❌ Помилка: Вказану команду не знайдено." 194 | ok: | 195 | ✅ Дозволи оновлено для команди {command}. 196 | Дозволені ролі: {roles} 197 | 198 | reset_perms: 199 | args_err: | 200 | Помилка: Команду не вказано! 201 | 202 | Приклад використання: 203 | /reset_perms найкраща_команда 204 | command_not_found: "❌ Помилка: Вказану команду не знайдено." 205 | settings_not_found: "ℹ️ Не знайдено користувацьких налаштувань дозволів для цієї команди." 206 | ok: | 207 | ✅ Дозволи успішно скинуто для команди {command}! 208 | 209 | set_role: 210 | args_err: | 211 | Помилка: Користувача або роль не вказано! 212 | 213 | Приклад використання: 214 | /set_role @username <назва_ролі> 215 | 216 | Де <назва_ролі> - бажана роль. 217 | reserved_role: "❌ Помилка: Ця назва ролі зарезервована і не може бути призначена." 218 | user_not_found: "❌ Помилка: Вказаного користувача не знайдено." 219 | ok: | 220 | ✅ Роль оновлено для користувача {user}. 221 | Нова роль: {role} 222 | 223 | reset_role: 224 | args_err: | 225 | Помилка: Користувача не вказано! 226 | 227 | Приклад використання: 228 | /reset_role @username 229 | user_not_found: "❌ Помилка: Вказаного користувача не знайдено." 230 | settings_not_found: "ℹ️ Не знайдено користувацької ролі для цього користувача." 231 | ok: | 232 | ✅ Роль успішно скинуто для користувача {user}! 233 | 234 | perm_settings: 235 | args_err: | 236 | Використання: 237 | /perms roles (Показати ролі користувачів) 238 | /perms commands (Показати дозволи команд) 239 | perms_header: "\U0001F4C4 Користувацькі дозволи команд:" 240 | roles_header: "\U0001F4C4 Користувацькі ролі користувачів:" 241 | no_perms: "ℹ️ Користувацькі дозволи для команд не встановлено." 242 | no_roles: "ℹ️ Користувацькі ролі не призначено." 243 | 244 | load: 245 | args_err: | 246 | Приклад використання: 247 | /mod_load <НазваМодуля> 248 | load_err: | 249 | ❌ Помилка завантаження модуля {name}! 250 | Будь ласка, перевірте логи для деталей. 251 | not_found: "❌ Модуль {name} не знайдено!" 252 | already_loaded_err: "⚠️ Модуль {name} вже завантажено." 253 | ok: "✅ Модуль {name} успішно завантажено!" 254 | 255 | unload: 256 | args_err: | 257 | Приклад використання: 258 | /mod_unload <НазваМодуля> 259 | not_loaded_err: "⚠️ Модуль {name} на даний момент не завантажений." 260 | unload_core: "❌ Помилка: Модуль Core не може бути вивантажений!" 261 | ok: "✅ Модуль {name} успішно вивантажено!" 262 | 263 | reload: 264 | args_err: | 265 | Приклад використання: 266 | /mod_reload <НазваМодуля> 267 | loading: | 268 | ⚙️ Перезавантаження модуля {module_name}... 269 | Будь ласка, зачекайте. 270 | ok: "✅ Модуль {module_name} успішно перезавантажено!" 271 | 272 | modules: 273 | list: "Встановлені модулі:" 274 | next_btn: Далі → 275 | prev_btn: ← Назад 276 | 277 | module_page: 278 | invalid_module: ⚠️ Модуль недійсний. Будь ласка, перевірте його локальні файли! 279 | name: "Назва: {name}" 280 | author: "Автор: {author}" 281 | version: "Версія: {version}" 282 | src_url: "Репозиторій: {url}" 283 | description: | 284 | Опис: 285 | {description} 286 | updates_found: "🚀 Доступне оновлення!" 287 | no_updates_found: "✅ Модуль останньої версії." 288 | update_btn: Оновити 🚀 289 | delete_btn: Видалити 🗑️ 290 | back_btn: ← Назад 291 | refresh_page_btn: Оновити 🔄 292 | refresh_page_err: | 293 | ❌ Не вдалося оновити сторінку для {module_name}! 294 | Модуль міг бути вивантажений, або сталася неочікувана помилка. 295 | no_changes: Змін не виявлено. 296 | unload_btn: Вивантажити 🔌 297 | load_btn: Завантажити 🔌 298 | reload_btn: Перезавантажити 🔄 299 | reload_ok: ✅ Модуль {module_name} успішно перезавантажено! 300 | auto_load: "Автозавантаження: {status}" 301 | enabled: Увімкнено 302 | disabled: Вимкнено 303 | enable_auto_load_btn: Увімкнути автозавантаження 304 | disable_auto_load_btn: Вимкнути автозавантаження 305 | auto_load_toggled: ✅ Автозавантаження {status}. 306 | auto_load_toggle_error: ❌ Не вдалося оновити налаштування автозавантаження. 307 | 308 | backup: 309 | no_backups: "ℹ️ Резервні копії не знайдено." 310 | no_backups_module: "ℹ️ Резервні копії для модуля {name} не знайдено." 311 | list_module: "Доступні резервні копії для модуля {name}:" 312 | view_backups_btn: Переглянути копії 📂 313 | restore_btn: Відновити 💾 314 | cleanup_btn: Очистити копії 🧹 315 | restore_latest_btn: Відновити останню 💾 316 | creating_backup: "⚙️ Створення резервної копії для модуля {name}..." 317 | backup_failed: "❌ Не вдалося створити резервну копію для модуля {name}." 318 | backup_created: | 319 | ✅ Резервну копію успішно створено. 320 | Шлях: {path} 321 | backup_failed_during_update: "⚠️ Увага: Не вдалося створити резервну копію перед спробою оновлення." 322 | offer_restore: "Сталася помилка. Бажаєте відновити з резервної копії {backup}, створеної для {name} перед операцією?" 323 | restoring: "⚙️ Відновлення модуля {name} з обраної резервної копії..." 324 | restore_success: "✅ Модуль {name} успішно відновлено з резервної копії {backup}." 325 | restore_failed: "❌ Не вдалося відновити модуль {name} з резервної копії." 326 | restore_canceled: | 327 | ⚠️ Відновлення з резервної копії скасовано для {name}. 328 | Модуль може перебувати в неконсистентному стані. 329 | restore_load_err: | 330 | ⚠️ Модуль {name} відновлено з резервної копії {backup}, але не вдалося завантажити його після цього. 331 | Перевірте логи бота для деталей. 332 | restore_skipped_files: "⚠️ Деякі файли ({count}) були пропущені під час відновлення. Перевірте логи для деталей." 333 | cleanup_select_count: "Виберіть, скільки останніх резервних копій зберегти для {name}:" 334 | back_btn: ← Назад 335 | confirm_restore: "Ви впевнені, що хочете відновити модуль {name} з резервної копії {backup}?" 336 | all_except_latest: Залишити тільки останню 337 | cleanup_complete: "✅ Очищено {count} старих резервних копій для модуля {name}. Збережено {keep} останніх." 338 | invalid_backup: "❌ Помилка: Вибрано недійсну резервну копію." 339 | invalid_backup_path: "❌ Наданий шлях резервної копії недійсний або файл не існує." 340 | invalid_backup_path_edit: "❌ Недійсний шлях резервної копії. Спробуйте ще раз або поверніться назад." -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyrotgfork==2.2.12 2 | tgcrypto2==1.3.2 3 | PyYAML==6.0.2 4 | dataclass-wizard==0.35.0 5 | SQLAlchemy==2.0.40 6 | requirements-parser==0.11.0 7 | packaging 8 | aiosqlite==0.21.0 9 | colorama 10 | tzdata 11 | uvloop 12 | --------------------------------------------------------------------------------