├── .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 | 
4 | 
5 | 
6 |
7 | 
8 | 
9 | 
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 |
--------------------------------------------------------------------------------