├── .dockerignore ├── .gitignore ├── .python-version ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── banner.png ├── dc_banner.png └── square.png ├── cogs ├── automod.py ├── devs.py ├── error_handler.py ├── help.py ├── info.py ├── mass_moderation.py ├── mod_logs.py ├── moderation.py ├── music.py ├── settings.py └── tickets.py ├── db ├── __init__.py ├── funcs │ ├── dev.py │ └── guild.py └── schema.py ├── docker-compose.yml ├── example.config.toml ├── main.py ├── music ├── client.py ├── equalizer_presets.py ├── sources │ └── spotify.py └── store.py ├── pyproject.toml ├── utils ├── check.py ├── config.py ├── emoji.py └── helpers.py └── uv.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Environments 2 | .env 3 | .venv 4 | env/ 5 | venv/ 6 | ENV/ 7 | env.bak/ 8 | venv.bak/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Config file 2 | config.toml 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Database 132 | migrations/ 133 | 134 | # Ruff 135 | .ruff_cache/ 136 | 137 | # Test script 138 | tst.* -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.13-bookworm 2 | 3 | WORKDIR /square 4 | 5 | COPY . . 6 | 7 | RUN uv sync --locked --no-dev 8 | 9 | CMD ["uv", "run", "main.py"] -------------------------------------------------------------------------------- /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 |
2 | 3 | ![Square Bot](./assets/banner.png) 4 | 5 | Advanced multipurpose discord bot for all your needs. 6 | 7 |
8 | 9 | ## 🎯 Features 10 | 11 | - Advanced moderation system. 12 | - Lots of utility & fun commands. 13 | - Advanced music system with support for YouTube, Spotify and SoundCloud. 14 | - Clean & informative help menu. 15 | 16 | ## 🚩 Installation 17 | 18 | 1. Clone this repository 19 | ```sh 20 | git clone https://github.com/swayam25/Square-Bot square 21 | cd square 22 | ``` 23 | 24 | 2. Create `config.toml` from `example.config.toml` and fill in the required values. 25 |
26 | 27 | Configuration 28 | 29 | - `owner-id` (`int`) 30 | - Owner's discord id. 31 | - Gives access to all commands. 32 | 33 | - `owner-guild-ids` (`list[int]`) 34 | - List of guild ids. 35 | - Developer commands will only work in these guilds. 36 | 37 | - `system-channel-id` (`int`) 38 | - System channel id. 39 | - Bot will send logs in this channel. 40 | 41 | - `support-server-url` (`str`) 42 | - Support server url. 43 | - Bot will use this url for support server. 44 | 45 | - `emoji` (`Literal["default", "custom"]`) 46 | - Emoji type. 47 | - `default` will use default emojis. 48 | - `custom` will use custom emojis defined in `.cache/emoji.json` (*requires setting up custom emojis*). 49 | - If you choose `custom`, make sure to define the emojis in the `.cache/emoji.json` file. 50 | - To create custom emojis, upload a `.zip` file containing the emojis (*`.png` format*) using `/emoji upload` command. 51 | - Emoji file names must match the attributes of `Emoji` class in [`emoji.py`](./utils/emoji.py). 52 | - Then run `/emoji sync` command to sync the emojis. (*This creates `.cache/emoji.json` file from bot's emojis*). 53 | - You can also manually create the `.cache/emoji.json` file with the same structure as `Emoji` class in [`emoji.py`](./utils/emoji.py). 54 | - Then set the `emoji` field to `custom`. 55 | 56 | - `bot-token` (`str`) 57 | - Discord api token. 58 | - Bot will use this token to connect to discord. 59 | 60 | - `database-url` (`str`) 61 | - Database url. 62 | - Bot will use this url to connect to the database. 63 | - Postgres database is supported. 64 | - Example: `asyncpg://user:password@db.host:5432/square`. 65 | - If your connection string starts with `postgresql://`, replace it with `asyncpg://`. 66 | - Services like Supabase provide a `postgresql://` connection string, remember to change it to `asyncpg://`. 67 | 68 | - `[colors]` 69 | - `theme` (`str`) 70 | - Theme color. 71 | - `error` (`str`) 72 | - Error color. 73 | 74 | - `[lavalink]` 75 | - `host` (`str`) 76 | - Lavalink host. 77 | - `port` (`int`) 78 | - Lavalink port. 79 | - `password` (`str`) 80 | - Lavalink password. 81 | - `secure` (`bool`) 82 | - Lavalink secure status 83 | 84 | - `[spotify]` 85 | - `client_id` (`str`) 86 | - Spotify client id 87 | - `client_secret` (`str`) 88 | - Spotify client secret 89 | 90 |
91 | 92 | 3. Set spotify credentials in `config.toml` file. 93 | - Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard). 94 | - Create a new application (*visit [Spotify Developer Docs](https://developer.spotify.com/documentation/web-api/tutorials/getting-started) for more details*). 95 | - Get the `client_id` and `client_secret` from the application settings. 96 | - Set the `client_id` and `client_secret` in the `config.toml` file. 97 | 98 | 4. Start the bot. 99 | ```sh 100 | uv run main.py 101 | ``` 102 | 103 | > [!IMPORTANT] 104 | > Make sure to have [uv](https://docs.astral.sh/uv) installed on your system to run the bot. 105 | > Know more about installing uv [here](https://docs.astral.sh/uv/getting-started/installation/). 106 | 107 | ## 🚀 Production 108 | 109 | 1. Follow steps 1-3 from the [installation guide](#-installation). *Ignore if already done.* 110 | 111 | 2. Run docker container (*via `docker compose`*) 112 | ```sh 113 | docker compose up -d 114 | ``` 115 | 116 | ## ❤️ Contributing 117 | 118 | - Things to keep in mind 119 | - Follow our commit message convention. 120 | - Write meaningful commit messages. 121 | - Keep the code clean and readable. 122 | - Make sure the bot is working as expected. 123 | 124 | - Code Formatting 125 | - Run `ruff format` before committing your changes or use [`Ruff`](https://docs.astral.sh/ruff/editors) extension in your code editor. 126 | - Make sure to commit error free code. Run `ruff check` to check for any errors. 127 | -------------------------------------------------------------------------------- /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swayam25/Square-Bot/a0da721bf769bf2b7fe4c6a8a81ade1bd6ef6695/assets/banner.png -------------------------------------------------------------------------------- /assets/dc_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swayam25/Square-Bot/a0da721bf769bf2b7fe4c6a8a81ade1bd6ef6695/assets/dc_banner.png -------------------------------------------------------------------------------- /assets/square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swayam25/Square-Bot/a0da721bf769bf2b7fe4c6a8a81ade1bd6ef6695/assets/square.png -------------------------------------------------------------------------------- /cogs/automod.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from db.funcs.guild import fetch_guild_settings, set_autorole 3 | from discord.ext import commands 4 | 5 | 6 | class AutoMod(commands.Cog): 7 | def __init__(self, client): 8 | self.client = client 9 | 10 | # Autorole 11 | @commands.Cog.listener() 12 | async def on_member_join(self, user: discord.Member): 13 | autorole = (await fetch_guild_settings(user.guild.id)).autorole 14 | if not user.bot: 15 | role = user.guild.get_role(autorole) 16 | if role: 17 | await user.add_roles(role) 18 | else: 19 | await set_autorole(user.guild.id, None) 20 | 21 | 22 | def setup(client: discord.Bot): 23 | client.add_cog(AutoMod(client)) 24 | -------------------------------------------------------------------------------- /cogs/devs.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import discord.ui 3 | import math 4 | import os 5 | import sys 6 | import zipfile 7 | from db.funcs.dev import add_dev, fetch_dev_ids, remove_dev 8 | from db.funcs.guild import add_guild, remove_guild 9 | from discord.commands import SlashCommandGroup, option, slash_command 10 | from discord.ext import commands 11 | from io import BytesIO 12 | from utils import check, config 13 | from utils.emoji import Emoji, emoji 14 | 15 | 16 | class GuildListView(discord.ui.View): 17 | def __init__(self, client: discord.Bot, ctx: discord.ApplicationContext, page: int, timeout: int): 18 | super().__init__(timeout=timeout, disable_on_timeout=True) 19 | self.client = client 20 | self.ctx = ctx 21 | self.page = page 22 | self.items_per_page = 10 23 | 24 | async def interaction_check(self, interaction: discord.Interaction): 25 | if interaction.user != self.ctx.author: 26 | help_check_em = discord.Embed( 27 | description=f"{emoji.error} You are not the author of this message", color=config.color.error 28 | ) 29 | await interaction.response.send_message(embed=help_check_em, ephemeral=True) 30 | return False 31 | else: 32 | return True 33 | 34 | # Start 35 | @discord.ui.button(emoji=f"{emoji.start}", custom_id="start", style=discord.ButtonStyle.grey) 36 | async def start_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 37 | self.page = 1 38 | em = GuildListEmbed(self.client, self.page).get_embed() 39 | await interaction.response.edit_message(embed=em, view=self) 40 | 41 | # Previous 42 | @discord.ui.button(emoji=f"{emoji.previous}", custom_id="previous", style=discord.ButtonStyle.grey) 43 | async def previous_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 44 | pages = math.ceil(len(self.client.guilds) / self.items_per_page) 45 | if self.page <= 1: 46 | self.page = pages 47 | else: 48 | self.page -= 1 49 | em = GuildListEmbed(self.client, self.page).get_embed() 50 | await interaction.response.edit_message(embed=em, view=self) 51 | 52 | # Next 53 | @discord.ui.button(emoji=f"{emoji.next}", custom_id="next", style=discord.ButtonStyle.grey) 54 | async def next_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 55 | pages = math.ceil(len(self.client.guilds) / self.items_per_page) 56 | if self.page >= pages: 57 | self.page = 1 58 | else: 59 | self.page += 1 60 | em = GuildListEmbed(self.client, self.page).get_embed() 61 | await interaction.response.edit_message(embed=em, view=self) 62 | 63 | # End 64 | @discord.ui.button(emoji=f"{emoji.end}", custom_id="end", style=discord.ButtonStyle.grey) 65 | async def end_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 66 | self.page = math.ceil(len(self.client.guilds) / self.items_per_page) 67 | em = GuildListEmbed(self.client, self.page).get_embed() 68 | await interaction.response.edit_message(embed=em, view=self) 69 | 70 | 71 | class GuildListEmbed(discord.Embed): 72 | def __init__(self, client: discord.Bot, page: int): 73 | super().__init__(title=f"{emoji.embed} Guilds List", color=config.color.theme) 74 | self.client = client 75 | self.page = page 76 | self.items_per_page = 10 77 | 78 | def get_guilds(self): 79 | guilds = self.client.guilds 80 | start = (self.page - 1) * self.items_per_page 81 | end = start + self.items_per_page 82 | return guilds[start:end] 83 | 84 | def get_guilds_list(self): 85 | guilds_list = "" 86 | for num, guild in enumerate(self.get_guilds(), start=self.items_per_page * (self.page - 1) + 1): 87 | guilds_list += f"`{num}.` **{guild.name}** - `{guild.id}`\n" 88 | return guilds_list 89 | 90 | def get_footer(self): 91 | total_pages = math.ceil(len(self.client.guilds) / self.items_per_page) 92 | return f"Viewing Page {self.page}/{total_pages}" 93 | 94 | def get_embed(self): 95 | self.description = self.get_guilds_list() 96 | self.set_footer(text=self.get_footer()) 97 | return self 98 | 99 | 100 | class Devs(commands.Cog): 101 | def __init__(self, client: discord.Bot): 102 | self.client = client 103 | 104 | # On start 105 | @commands.Cog.listener("on_ready") 106 | async def when_bot_gets_ready(self): 107 | start_log_ch = await self.client.fetch_channel(config.system_channel_id) 108 | start_log_em = discord.Embed( 109 | title=f"{emoji.restart} Restarted", 110 | description=f"Logged in as **{self.client.user}** with ID `{self.client.user.id}`", 111 | color=config.color.theme, 112 | ) 113 | await start_log_ch.send(embed=start_log_em) 114 | 115 | # On guild joined 116 | @commands.Cog.listener("on_guild_join") 117 | async def when_guild_joined(self, guild: discord.Guild): 118 | await add_guild(guild.id) 119 | join_log_ch = await self.client.fetch_channel(config.system_channel_id) 120 | join_log_em = discord.Embed( 121 | title=f"{emoji.plus} Someone Added Me!", 122 | description=f"{emoji.bullet} **Name**: {guild.name}\n" 123 | f"{emoji.bullet} **ID**: `{guild.id}`\n" 124 | f"{emoji.bullet} **Total Members**: `{guild.member_count}`\n" 125 | f"{emoji.bullet} **Total Humans**: `{len([m for m in guild.members if not m.bot])}`\n" 126 | f"{emoji.bullet} **Total Bots**: `{len([m for m in guild.members if m.bot])}`", 127 | color=config.color.theme, 128 | ) 129 | await join_log_ch.send(embed=join_log_em) 130 | 131 | # On guild leave 132 | @commands.Cog.listener("on_guild_remove") 133 | async def when_removed_from_guild(self, guild: discord.Guild): 134 | await remove_guild(guild.id) 135 | leave_log_ch = await self.client.fetch_channel(config.system_channel_id) 136 | leave_log_em = discord.Embed( 137 | title=f"{emoji.minus} Someone Removed Me!", 138 | description=f"{emoji.bullet2} **Name**: {guild.name}\n" 139 | f"{emoji.bullet2} **ID**: `{guild.id}`\n" 140 | f"{emoji.bullet2} **Total Members**: `{guild.member_count}`\n" 141 | f"{emoji.bullet2} **Total Humans**: `{len([m for m in guild.members if not m.bot])}`\n" 142 | f"{emoji.bullet2} **Total Bots**: `{len([m for m in guild.members if m.bot])}`", 143 | color=config.color.error, 144 | ) 145 | await leave_log_ch.send(embed=leave_log_em) 146 | 147 | # Dev slash cmd group 148 | dev = SlashCommandGroup(guild_ids=config.owner_guild_ids, name="dev", description="Developer related commands.") 149 | 150 | # Add dev 151 | @dev.command(name="add") 152 | @option("user", description="Mention the user whom you want to add to dev") 153 | @check.is_owner() 154 | async def add_dev(self, ctx: discord.ApplicationContext, user: discord.Member): 155 | """Adds a bot dev.""" 156 | await add_dev(user.id) 157 | done_em = discord.Embed( 158 | title=f"{emoji.plus} Added", description=f"Added {user.mention} to dev", color=config.color.theme 159 | ) 160 | await ctx.respond(embed=done_em) 161 | 162 | # Remove dev 163 | @dev.command(name="remove") 164 | @option("user", description="Mention the user whom you want to remove from dev") 165 | @check.is_owner() 166 | async def remove_dev(self, ctx: discord.ApplicationContext, user: discord.Member): 167 | """Removes a bot dev.""" 168 | await remove_dev(user.id) 169 | done_em = discord.Embed( 170 | title=f"{emoji.bin} Removed", 171 | description=f"Removed {user.mention} from dev", 172 | color=config.color.error, 173 | ) 174 | await ctx.respond(embed=done_em) 175 | 176 | # List devs 177 | @dev.command(name="list") 178 | @check.is_owner() 179 | async def list_devs(self, ctx: discord.ApplicationContext): 180 | """Shows bot devs.""" 181 | num = 0 182 | devs_list = "" 183 | dev_ids = await fetch_dev_ids() 184 | for ids in dev_ids: 185 | num += 1 186 | dev_mention = f"<@{ids}>" 187 | devs_list += f"`{num}.` {dev_mention}\n" 188 | dev_em = discord.Embed(title=f"{emoji.embed} Devs List", description=devs_list, color=config.color.theme) 189 | await ctx.respond(embed=dev_em) 190 | 191 | # Restart 192 | @slash_command(guild_ids=config.owner_guild_ids, name="restart") 193 | @check.is_dev() 194 | async def restart(self, ctx: discord.ApplicationContext): 195 | """Restarts the bot.""" 196 | restart_em = discord.Embed(title=f"{emoji.restart} Restarting", color=config.color.theme) 197 | await ctx.respond(embed=restart_em) 198 | await self.client.wait_until_ready() 199 | await self.client.close() 200 | os.system("clear") 201 | os.execv(sys.executable, [sys.executable] + sys.argv) 202 | 203 | # Reload cogs 204 | @slash_command(guild_ids=config.owner_guild_ids, name="reload-cogs") 205 | @check.is_dev() 206 | async def reload_cogs(self, ctx: discord.ApplicationContext): 207 | """Reloads bot's all files.""" 208 | reload_em = discord.Embed(title=f"{emoji.restart} Reloaded Cogs", color=config.color.theme) 209 | await ctx.respond(embed=reload_em, ephemeral=True, delete_after=2) 210 | for filename in os.listdir("./cogs"): 211 | if filename.endswith(".py"): 212 | self.client.reload_extension(f"cogs.{filename[:-3]}") 213 | 214 | # Shutdown 215 | @slash_command(guild_ids=config.owner_guild_ids, name="shutdown") 216 | @check.is_owner() 217 | async def shutdown(self, ctx: discord.ApplicationContext): 218 | """Shutdowns the bot.""" 219 | shutdown_em = discord.Embed(title=f"{emoji.shutdown} Shutdown", color=config.color.error) 220 | await ctx.respond(embed=shutdown_em) 221 | await self.client.wait_until_ready() 222 | await self.client.close() 223 | 224 | # Set status 225 | @slash_command(guild_ids=config.owner_guild_ids, name="status") 226 | @option("type", description="Choose bot status type", choices=["Game", "Streaming", "Listening", "Watching"]) 227 | @option("status", description="Enter new status of bot") 228 | @check.is_dev() 229 | async def set_status(self, ctx: discord.ApplicationContext, type: str, status: str): 230 | """Sets custom bot status.""" 231 | if type == "Game": 232 | await self.client.change_presence(activity=discord.Game(name=status)) 233 | elif type == "Streaming": 234 | await self.client.change_presence(activity=discord.Streaming(name=status, url=config.support_server_url)) 235 | elif type == "Listening": 236 | await self.client.change_presence( 237 | activity=discord.Activity(type=discord.ActivityType.listening, name=status) 238 | ) 239 | elif type == "Watching": 240 | await self.client.change_presence( 241 | activity=discord.Activity(type=discord.ActivityType.watching, name=status) 242 | ) 243 | status_em = discord.Embed( 244 | title=f"{emoji.console} Status Changed", 245 | description=f"Status changed to **{type}** as `{status}`", 246 | color=config.color.theme, 247 | ) 248 | await ctx.respond(embed=status_em) 249 | 250 | # Guild slash cmd group 251 | guild = SlashCommandGroup(guild_ids=config.owner_guild_ids, name="guild", description="Guild related commands.") 252 | 253 | # List guild 254 | @guild.command(name="list") 255 | @check.is_owner() 256 | async def list_guilds(self, ctx: discord.ApplicationContext): 257 | """Shows all guilds.""" 258 | guild_list_em = GuildListEmbed(self.client, 1).get_embed() 259 | guild_list_view = None 260 | if len(self.client.guilds) > 10: 261 | guild_list_view = GuildListView(self.client, ctx, 1, timeout=60) 262 | await ctx.respond(embed=guild_list_em, view=guild_list_view) 263 | 264 | # Leave guild 265 | @guild.command(name="leave") 266 | @option( 267 | "guild", 268 | description="Enter the guild name", 269 | autocomplete=lambda self, ctx: [ 270 | guild.name for guild in self.client.guilds if not any(guild.id == g for g in config.owner_guild_ids) 271 | ], 272 | ) 273 | @check.is_owner() 274 | async def leave_guild(self, ctx: discord.ApplicationContext, guild: discord.Guild): 275 | """Leaves a guild.""" 276 | if any(guild.id == g for g in config.owner_guild_ids): 277 | error_em = discord.Embed( 278 | description=f"{emoji.error} I can't leave the owner guild", color=config.color.error 279 | ) 280 | await ctx.respond(embed=error_em, ephemeral=True) 281 | else: 282 | await guild.leave() 283 | leave_em = discord.Embed( 284 | title=f"{emoji.minus} Left Guild", 285 | description=f"Left **{guild.name}**", 286 | color=config.color.error, 287 | ) 288 | await ctx.respond(embed=leave_em) 289 | 290 | # Guild invite 291 | @guild.command(name="invite") 292 | @option( 293 | "guild", 294 | description="Enter the guild name", 295 | autocomplete=lambda self, ctx: [ 296 | guild.name for guild in self.client.guilds if not any(guild.id == g for g in config.owner_guild_ids) 297 | ], 298 | ) 299 | @check.is_owner() 300 | async def guild_inv(self, ctx: discord.ApplicationContext, guild: discord.Guild): 301 | """Creates an invite link for the guild.""" 302 | if any(guild.id == g for g in config.owner_guild_ids): 303 | error_em = discord.Embed( 304 | description=f"{emoji.error} I can't create an invite link for the owner guild", 305 | color=config.color.error, 306 | ) 307 | await ctx.respond(embed=error_em, ephemeral=True) 308 | else: 309 | invite = await guild.text_channels[0].create_invite(max_age=0, max_uses=0) 310 | invite_em = discord.Embed( 311 | title=f"{emoji.plus} Guild Invite Link", 312 | description=f"Invite link for **{guild.name}**: {invite}", 313 | color=config.color.theme, 314 | ) 315 | await ctx.respond(embed=invite_em) 316 | 317 | # Emoji slash cmd group 318 | emoji = SlashCommandGroup(guild_ids=config.owner_guild_ids, name="emoji", description="Emoji related commands.") 319 | 320 | # Download app emojis 321 | @emoji.command(name="download") 322 | @check.is_dev() 323 | async def download_app_emojis(self, ctx: discord.ApplicationContext): 324 | """Downloads all emojis from the app.""" 325 | await ctx.defer() 326 | emojis: list[discord.AppEmoji] = await self.client.fetch_emojis() 327 | if not emojis: 328 | no_emojis_em = discord.Embed( 329 | description=f"{emoji.error} No emojis found in the app.", color=config.color.error 330 | ) 331 | await ctx.respond(embed=no_emojis_em, ephemeral=True) 332 | return 333 | 334 | zip_buffer = BytesIO() 335 | with zipfile.ZipFile(zip_buffer, "w") as zip_file: 336 | for app_emoji in emojis: 337 | async with self.client.http._HTTPClient__session.get(app_emoji.url) as response: 338 | if response.status == 200: 339 | zip_file.writestr(f"{app_emoji.name}.png", await response.read()) 340 | 341 | zip_buffer.seek(0) 342 | await ctx.respond(file=discord.File(fp=zip_buffer, filename="emojis.zip")) 343 | 344 | # Upload app emojis 345 | @emoji.command(name="upload") 346 | @option("file", description="Upload emojis zip file", type=discord.Attachment) 347 | @check.is_dev() 348 | async def upload_app_emojis(self, ctx: discord.ApplicationContext, file: discord.Attachment): 349 | """Uploads all emojis to the app. (Only supports .zip files with .png emojis)""" 350 | await ctx.defer() 351 | if not file.filename.endswith(".zip"): 352 | error_em = discord.Embed( 353 | description=f"{emoji.error} Please upload a valid zip file.", color=config.color.error 354 | ) 355 | await ctx.respond(embed=error_em, ephemeral=True) 356 | return 357 | zip_buffer = BytesIO() 358 | await file.save(zip_buffer) 359 | zip_buffer.seek(0) 360 | with zipfile.ZipFile(zip_buffer, "r") as zip_file: 361 | emojis = [discord.PartialEmoji(name=name, id=None) for name in zip_file.namelist()] 362 | for _emoji in emojis: 363 | if _emoji.name.endswith(".png"): 364 | _emoji.name = _emoji.name[:-4] 365 | if len(_emoji.name) > 32: 366 | error_em = discord.Embed( 367 | description=f"{_emoji.error} Emoji name `{_emoji.name}` is too long (max 32 characters).", 368 | color=config.color.error, 369 | ) 370 | await ctx.respond(embed=error_em, ephemeral=True) 371 | return 372 | try: 373 | await self.client.create_emoji(name=_emoji.name, image=zip_file.read(f"{_emoji.name}.png")) 374 | except Exception: 375 | await self.client.delete_emoji( 376 | [emoji for emoji in await self.client.fetch_emojis() if emoji.name == _emoji.name][0] 377 | ) 378 | finally: 379 | await self.client.create_emoji(name=_emoji.name, image=zip_file.read(f"{_emoji.name}.png")) 380 | zip_buffer.close() 381 | upload_em = discord.Embed( 382 | title=f"{emoji.upload} Uploaded Emoji(s)", 383 | description=f"Uploaded {len(emojis)} emojis.", 384 | color=config.color.theme, 385 | ) 386 | await ctx.respond(embed=upload_em) 387 | 388 | # Sync app emojis 389 | @emoji.command(name="sync") 390 | @check.is_dev() 391 | async def sync_app_emojis(self, ctx: discord.ApplicationContext): 392 | """Syncs all emojis from the app.""" 393 | await ctx.defer() 394 | emojis: list[discord.AppEmoji] = await self.client.fetch_emojis() 395 | emoji_dict: dict = {} 396 | if not emojis: 397 | no_emojis_em = discord.Embed( 398 | description=f"{emoji.error} No emojis found in the app.", color=config.color.error 399 | ) 400 | await ctx.respond(embed=no_emojis_em, ephemeral=True) 401 | return 402 | 403 | for app_emoji in emojis: 404 | if app_emoji.animated: 405 | emoji_dict[app_emoji.name] = f"" 406 | else: 407 | emoji_dict[app_emoji.name] = f"<:{app_emoji.name}:{app_emoji.id}>" 408 | 409 | resp: dict = Emoji.create_custom_emoji_config(emoji_dict) 410 | if resp["status"] == "error": 411 | error_em = discord.Embed( 412 | description=f"{emoji.error} Missing emojis:\n{'\n'.join([f'{emoji.bullet} `{i}`' for i in resp['missing_keys']])}", 413 | color=config.color.error, 414 | ) 415 | await ctx.respond(embed=error_em, ephemeral=True) 416 | else: 417 | sync_em = discord.Embed( 418 | title=f"{emoji.restart} Synced Emoji(s)", 419 | description=f"Synced {len(emojis)} emoji(s).", 420 | color=config.color.theme, 421 | ) 422 | if resp.get("extra_keys"): 423 | sync_em.add_field( 424 | name=f"{emoji.error} Extra emoji(s)", 425 | value="\n".join([f"{emoji.bullet} `{i}`: {i}" for i in resp["extra_keys"]]), 426 | ) 427 | await ctx.respond(embed=sync_em) 428 | 429 | 430 | def setup(client: discord.Bot): 431 | client.add_cog(Devs(client)) 432 | -------------------------------------------------------------------------------- /cogs/error_handler.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | from utils import check, config 4 | from utils.emoji import emoji 5 | from utils.helpers import fmt_perms 6 | 7 | 8 | class ErrorHandler(commands.Cog): 9 | def __init__(self, client): 10 | self.client = client 11 | 12 | # Slash cmd Error Handler 13 | @commands.Cog.listener() 14 | async def on_application_command_error(self, ctx: discord.ApplicationContext, error: discord.DiscordException): 15 | error_em = discord.Embed(color=config.color.error) 16 | 17 | if isinstance(error, commands.CommandNotFound): 18 | pass 19 | 20 | elif isinstance(error, commands.CommandOnCooldown): 21 | error_em.description = f"{emoji.error} You're on cooldown. Try again in {error.retry_after:.0f} seconds." 22 | 23 | elif isinstance(error, commands.BotMissingPermissions): 24 | error_em.description = (f"{emoji.error} I don't have {fmt_perms(error.missing_permissions)} permission(s)",) 25 | 26 | elif isinstance(error, commands.MissingPermissions): 27 | error_em.description = ( 28 | f"{emoji.error} You need {fmt_perms(error.missing_permissions)} permission(s) to use this command." 29 | ) 30 | 31 | elif isinstance(error, discord.errors.Forbidden): 32 | error_em.description = f"{emoji.error} I don't have permission to do that." 33 | 34 | elif await check.is_dev().predicate(ctx): 35 | error_em.description = ( 36 | f"{emoji.error} An unexpected error occurred: **`{error.__class__.__name__}`**\n```\n{error}```" 37 | ) 38 | await ctx.respond(embed=error_em, ephemeral=True) 39 | 40 | 41 | def setup(client: discord.Bot): 42 | client.add_cog(ErrorHandler(client)) 43 | -------------------------------------------------------------------------------- /cogs/help.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import discord.ui 3 | from discord.commands import SlashCommandGroup, slash_command 4 | from discord.ext import commands 5 | from utils import config 6 | from utils.emoji import emoji 7 | 8 | 9 | # Help embed 10 | def help_home_em(self, ctx: discord.ApplicationContext): 11 | help_em = discord.Embed( 12 | title=f"{self.client.user.name} Help Desk", 13 | description=f"Hello {ctx.author.mention}! I'm {self.client.user.name}, use the dropdown menu below to see the commands of each category. If you need help, feel free to ask in the [support server]({config.support_server_url}).", 14 | color=config.color.theme, 15 | ) 16 | help_em.add_field( 17 | name="Categories", 18 | value=f"{emoji.mod} `:` **Moderation**\n" 19 | + f"{emoji.mass_mod} `:` **Mass Moderation**\n" 20 | + f"{emoji.info} `:` **Info**\n" 21 | + f"{emoji.settings} `:` **Settings**\n" 22 | + f"{emoji.music} `:` **Music**\n" 23 | + f"{emoji.ticket} `:` **Tickets**", 24 | ) 25 | return help_em 26 | 27 | 28 | class HelpView(discord.ui.View): 29 | def __init__(self, client: discord.Bot, ctx: discord.ApplicationContext, timeout: int): 30 | super().__init__(timeout=timeout, disable_on_timeout=True) 31 | self.client = client 32 | self.ctx = ctx 33 | 34 | # Interaction check 35 | async def interaction_check(self, interaction: discord.Interaction): 36 | if interaction.user != self.ctx.author: 37 | help_check_em = discord.Embed( 38 | description=f"{emoji.error} You are not the author of this message", color=config.color.error 39 | ) 40 | await interaction.response.send_message(embed=help_check_em, ephemeral=True) 41 | return False 42 | else: 43 | return True 44 | 45 | # Help select menu 46 | @discord.ui.select( 47 | placeholder="Choose A Category", 48 | min_values=1, 49 | max_values=1, 50 | custom_id="help", 51 | options=[ 52 | discord.SelectOption( 53 | label="Moderation", 54 | description="Moderate your server & keep it managed by using commands.", 55 | emoji=f"{emoji.mod}", 56 | ), 57 | discord.SelectOption( 58 | label="Mass Moderation", description="Moderate your server in bulk.", emoji=f"{emoji.mass_mod}" 59 | ), 60 | discord.SelectOption( 61 | label="Info", description="See some info about bot and others.", emoji=f"{emoji.info}" 62 | ), 63 | discord.SelectOption( 64 | label="Settings", description="Highly customisable server settings.", emoji=f"{emoji.settings}" 65 | ), 66 | discord.SelectOption( 67 | label="Music", description="Wanna chill? Just play music & enjoy.", emoji=f"{emoji.music}" 68 | ), 69 | discord.SelectOption( 70 | label="Tickets", description="Need help? Create a ticket and ask.", emoji=f"{emoji.ticket}" 71 | ), 72 | discord.SelectOption(label="Home", description="Go back to home.", emoji=f"{emoji.previous}"), 73 | ], 74 | ) 75 | async def help_callback(self, select: discord.ui.Select, interaction: discord.Interaction): 76 | cmds = "" 77 | if select.values[0] == "Home": 78 | await interaction.response.edit_message(embed=help_home_em(self, self.ctx)) 79 | else: 80 | cog = self.client.get_cog(select.values[0].replace(" ", "")) 81 | for command in cog.get_commands(): 82 | if isinstance(command, SlashCommandGroup): 83 | for subcommand in command.walk_commands(): 84 | cmds += f"\n{emoji.bullet} {subcommand.description}\n\n" 85 | else: 86 | cmds += f"\n{emoji.bullet} {command.description}\n\n" 87 | help_em = discord.Embed( 88 | title=f"{select.values[0]} Commands", description=f"{cmds}", color=config.color.theme 89 | ) 90 | await interaction.response.edit_message(embed=help_em) 91 | 92 | 93 | class Help(commands.Cog): 94 | def __init__(self, client): 95 | self.client = client 96 | 97 | # Help 98 | @slash_command(name="help") 99 | async def help(self, ctx: discord.ApplicationContext): 100 | """Need bot's help? Use this!""" 101 | helpView = HelpView(self.client, ctx, timeout=60) 102 | helpView.msg = await ctx.respond(embed=help_home_em(self, ctx), view=helpView) 103 | 104 | 105 | def setup(client: discord.Bot): 106 | client.add_cog(Help(client)) 107 | -------------------------------------------------------------------------------- /cogs/info.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import discord 3 | import platform 4 | import time 5 | from discord.commands import SlashCommandGroup, option, slash_command 6 | from discord.ext import commands 7 | from utils import config 8 | from utils.emoji import emoji 9 | 10 | # Starting time of bot 11 | start_time = time.time() 12 | 13 | 14 | class Info(commands.Cog): 15 | def __init__(self, client): 16 | self.client = client 17 | 18 | # Ping 19 | @slash_command(name="ping") 20 | async def ping(self, ctx: discord.ApplicationContext): 21 | """Shows heartbeats of the bot.""" 22 | ping_em = discord.Embed( 23 | description=f"{emoji.bullet} **Ping**: `{round(self.client.latency * 1000)} ms`", 24 | color=config.color.theme, 25 | ) 26 | await ctx.respond(embed=ping_em) 27 | 28 | # Uptime 29 | @slash_command(name="uptime") 30 | async def uptime(self, ctx: discord.ApplicationContext): 31 | """Shows bot's uptime.""" 32 | uptime_em = discord.Embed( 33 | description=f"{emoji.bullet} **Bot's Uptime**: `{str(datetime.timedelta(seconds=int(round(time.time() - start_time))))}`", 34 | color=config.color.theme, 35 | ) 36 | await ctx.respond(embed=uptime_em) 37 | 38 | # Stats 39 | @slash_command(name="stats") 40 | async def stats(self, ctx: discord.ApplicationContext): 41 | """Shows bot stats.""" 42 | owner = await self.client.fetch_user(config.owner_id) 43 | stats_em = discord.Embed( 44 | title=f"{self.client.user.name} Stats", 45 | description=f"{emoji.bullet} **Bot's Latency**: `{round(self.client.latency * 1000)} ms`\n" 46 | + f"{emoji.bullet} **Bot's Uptime**: `{str(datetime.timedelta(seconds=int(round(time.time() - start_time))))}`\n" 47 | + f"{emoji.bullet} **Total Servers**: `{str(len(self.client.guilds))}`\n" 48 | + f"{emoji.bullet} **Total Members**: `{len(set(self.client.get_all_members()))}`\n" 49 | + f"{emoji.bullet} **Total Channels**: `{len(set(self.client.get_all_channels()))}`\n" 50 | + f"{emoji.bullet} **Python Version**: `v{platform.python_version()}`\n" 51 | + f"{emoji.bullet} **Pycord Version**: `v{discord.__version__}`", 52 | color=config.color.theme, 53 | ) 54 | stats_em.set_footer(text=f"Designed & Built by {owner}", icon_url=f"{owner.avatar.url}") 55 | await ctx.respond(embed=stats_em) 56 | 57 | # Avatar 58 | @slash_command(name="avatar") 59 | @option("user", description="Mention the user whom you will see avatar") 60 | async def avatar(self, ctx: discord.ApplicationContext, user: discord.Member): 61 | """Shows the avatar of the mentioned user.""" 62 | avatar_em = discord.Embed( 63 | title=f"{user.name}'s Avatar", description=f"[Avatar URL]({user.avatar.url})", color=config.color.theme 64 | ) 65 | avatar_em.set_image(url=f"{user.avatar.url}") 66 | await ctx.respond(embed=avatar_em) 67 | 68 | # Info slash cmd group 69 | info = SlashCommandGroup(name="info", description="Info related commands.") 70 | 71 | # User info 72 | @info.command(name="user") 73 | @option("user", description="Mention the member whom you will see info") 74 | async def user_info(self, ctx: discord.ApplicationContext, user: discord.Member): 75 | """Shows info of the mentioned user.""" 76 | user_info_em = discord.Embed( 77 | title=f"{user.name}'s Info", 78 | description=f"{emoji.bullet} **Name**: `{user}`\n" 79 | + f"{emoji.bullet} **ID**: `{user.id}`\n" 80 | + f"{emoji.bullet} **Bot?**: {user.bot}\n" 81 | + f"{emoji.bullet} **Avatar URL**: [Click Here]({user.avatar.url})\n" 82 | + f"{emoji.bullet} **Status**: {user.status}\n" 83 | + f"{emoji.bullet} **Nickname**: {user.nick}\n" 84 | + f"{emoji.bullet} **Highest Role**: {user.top_role.mention}\n" 85 | + f"{emoji.bullet} **Account Created**: {discord.utils.format_dt(user.created_at, 'R')}\n" 86 | + f"{emoji.bullet} **Server Joined**: {discord.utils.format_dt(user.joined_at, 'R')}", 87 | color=config.color.theme, 88 | ) 89 | user_info_em.set_thumbnail(url=f"{user.avatar.url}") 90 | await ctx.respond(embed=user_info_em) 91 | 92 | # Server info 93 | @info.command(name="server") 94 | async def server_info(self, ctx: discord.ApplicationContext): 95 | """Shows info of the current server.""" 96 | server_info_em = discord.Embed( 97 | title=f"{ctx.guild.name}'s Info", 98 | description=f"{emoji.bullet} **Name**: {ctx.guild.name}\n" 99 | + f"{emoji.bullet} **ID**: `{ctx.guild.id}`\n" 100 | + f"{emoji.bullet} **Icon URL**: {f'[Click Here]({ctx.guild.icon})' if ctx.guild.icon else 'None'}\n" 101 | + f"{emoji.bullet} **Owner**: {ctx.guild.owner.mention}\n" 102 | + f"{emoji.bullet} **Verification Level**: `{ctx.guild.verification_level}`\n" 103 | + f"{emoji.bullet} **Total Categorie(s)**: `{len(ctx.guild.categories)}`\n" 104 | + f"{emoji.bullet} **Total Channel(s)**: `{len(ctx.guild.text_channels) + len(ctx.guild.voice_channels)}`\n" 105 | + f"{emoji.bullet} **Text Channel(s)**: `{len(ctx.guild.text_channels)}`\n" 106 | + f"{emoji.bullet} **Voice Channel(s)**: `{len(ctx.guild.voice_channels)}`\n" 107 | + f"{emoji.bullet} **Stage Channel(s)**: `{len(ctx.guild.stage_channels)}`\n" 108 | + f"{emoji.bullet} **Total Member(s)**: `{len(list(ctx.guild.members))}`\n" 109 | + f"{emoji.bullet} **Human(s)**: `{len([m for m in ctx.guild.members if not m.bot])}`\n" 110 | + f"{emoji.bullet} **Bot(s)**: `{len([m for m in ctx.guild.members if m.bot])}`\n" 111 | + f"{emoji.bullet} **Role(s)**: `{len(ctx.guild.roles)}`\n" 112 | + f"{emoji.bullet} **Server Created**: {discord.utils.format_dt(ctx.guild.created_at, 'R')}", 113 | color=config.color.theme, 114 | ) 115 | server_info_em.set_thumbnail(url=ctx.guild.icon if ctx.guild.icon else "") 116 | await ctx.respond(embed=server_info_em) 117 | 118 | # Emoji info 119 | @info.command(name="emoji") 120 | @option("icon", description="Enter the emoji") 121 | async def emoji_info(self, ctx: discord.ApplicationContext, icon: discord.Emoji): 122 | """Shows info of the given emoji.""" 123 | emoji_info_em = discord.Embed( 124 | description=f"{emoji.bullet} **Name**: {icon.name}\n" 125 | + f"{emoji.bullet} **ID**: `{icon.id}`\n" 126 | + f"{emoji.bullet} **Emoji URL**: [Click Here]({icon.url})\n" 127 | + f"{emoji.bullet} **Is Animated?**: {icon.animated}\n" 128 | + f"{emoji.bullet} **Usage**: `{icon}`\n" 129 | + f"{emoji.bullet} **Emoji Created**: {discord.utils.format_dt(icon.created_at, 'R')}", 130 | color=config.color.theme, 131 | ) 132 | emoji_info_em.set_thumbnail(url=f"{icon.url}") 133 | await ctx.respond(embed=emoji_info_em) 134 | 135 | 136 | def setup(client: discord.Bot): 137 | client.add_cog(Info(client)) 138 | -------------------------------------------------------------------------------- /cogs/mass_moderation.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import discord 3 | from babel.dates import format_timedelta 4 | from db.funcs.guild import fetch_guild_settings 5 | from discord.commands import SlashCommandGroup, option 6 | from discord.ext import commands 7 | from utils import config 8 | from utils.emoji import emoji 9 | from utils.helpers import parse_duration 10 | 11 | 12 | class MassModeration(commands.Cog): 13 | def __init__(self, client): 14 | self.client = client 15 | 16 | # Mass slash cmd group 17 | mass = SlashCommandGroup( 18 | name="mass", 19 | description="Mass moderation commands.", 20 | default_member_permissions=discord.Permissions(manage_guild=True), 21 | ) 22 | 23 | # Mass kick 24 | @mass.command(name="kick") 25 | @option("users", description='Mention the users whom you want to kick. Use "," to separate users.', required=True) 26 | @option("reason", description="Enter the reason for kicking the user", required=False) 27 | async def mass_kick_users(self, ctx: discord.ApplicationContext, users: str, reason: str = None): 28 | """Kicks mentioned users.""" 29 | await ctx.defer() 30 | users: list = users.split(",") 31 | _users: list = [] 32 | errors: list[tuple] = [] 33 | if len(users) > 10: 34 | error_em = discord.Embed( 35 | description=f"{emoji.error} You can only mass kick upto 10 users.", color=config.color.error 36 | ) 37 | await ctx.respond(embed=error_em, ephemeral=True) 38 | else: 39 | for user in users: 40 | try: 41 | _user = await commands.MemberConverter().convert(ctx, user.strip()) 42 | except Exception: 43 | errors.append((user.strip(), "User not found.")) 44 | continue 45 | if _user == ctx.author: 46 | errors.append((ctx.author.mention, "You cannot use it on yourself.")) 47 | continue 48 | elif _user.top_role.position >= ctx.author.top_role.position: 49 | errors.append((_user.mention, "User has same role or higher role than you.")) 50 | continue 51 | else: 52 | _users.append(_user.mention) 53 | await _user.kick(reason=reason) 54 | if len(_users) > 0: 55 | mass_kick_em = discord.Embed( 56 | title=f"{emoji.kick} Mass Kicked Users", 57 | description=f"Successfully kicked {len(_users)} users.\n" 58 | + f"{emoji.bullet2} **Reason**: {reason}\n" 59 | + f"{emoji.bullet2} **Users**: {', '.join(_users)}", 60 | color=config.color.error, 61 | ) 62 | await ctx.respond(embed=mass_kick_em) 63 | channel_id = (await fetch_guild_settings(ctx.guild.id)).mod_cmd_log_channel_id 64 | if channel_id: 65 | log_ch = await self.client.fetch_channel(channel_id) 66 | mass_kick_em.description += f"\n{emoji.bullet2} **Moderator**: {ctx.author.mention}" 67 | await log_ch.send(embed=mass_kick_em) 68 | if len(errors) > 0: 69 | error_em = discord.Embed( 70 | title=f"{emoji.error} Can't kick users", 71 | description="\n".join([f"{emoji.bullet2} **{user}**: {reason}" for user, reason in errors]), 72 | color=config.color.error, 73 | ) 74 | await ctx.respond(embed=error_em, ephemeral=True) 75 | 76 | # Mass ban 77 | @mass.command(name="ban") 78 | @option("users", description='Mention the users whom you want to ban. Use "," to separate users.', required=True) 79 | @option("reason", description="Enter the reason for banning the user", required=False) 80 | async def mass_ban_users(self, ctx: discord.ApplicationContext, users: str, reason: str = None): 81 | """Bans mentioned users.""" 82 | await ctx.defer() 83 | users: list = users.split(",") 84 | _users: list = [] 85 | errors: list[tuple] = [] 86 | if len(users) > 10: 87 | error_em = discord.Embed( 88 | description=f"{emoji.error} You can only mass ban upto 10 users.", color=config.color.error 89 | ) 90 | await ctx.respond(embed=error_em, ephemeral=True) 91 | else: 92 | for user in users: 93 | try: 94 | _user = await commands.MemberConverter().convert(ctx, user.strip()) 95 | except Exception: 96 | errors.append((user.strip(), "User not found.")) 97 | continue 98 | if _user == ctx.author: 99 | errors.append((ctx.author.mention, "You cannot use it on yourself.")) 100 | continue 101 | elif _user.top_role.position >= ctx.author.top_role.position: 102 | errors.append((_user.mention, "User has same role or higher role than you.")) 103 | continue 104 | _users.append(_user.mention) 105 | await _user.ban(reason=reason) 106 | if len(_users) > 0: 107 | mass_ban_em = discord.Embed( 108 | title=f"{emoji.mod2} Mass Banned Users", 109 | description=f"Successfully banned {len(_users)} users.\n" 110 | + f"{emoji.bullet2} **Reason**: {reason}\n" 111 | + f"{emoji.bullet2} **Users**: {', '.join(_users)}", 112 | color=config.color.error, 113 | ) 114 | await ctx.respond(embed=mass_ban_em) 115 | channel_id = (await fetch_guild_settings(ctx.guild.id)).mod_cmd_log_channel_id 116 | if channel_id: 117 | log_ch = await self.client.fetch_channel(channel_id) 118 | mass_ban_em.description += f"\n{emoji.bullet2} **Moderator**: {ctx.author.mention}" 119 | await log_ch.send(embed=mass_ban_em) 120 | if len(errors) > 0: 121 | error_em = discord.Embed( 122 | title=f"{emoji.error} Can't ban users", 123 | description="\n".join([f"{emoji.bullet2} **{user}**: {reason}" for user, reason in errors]), 124 | color=config.color.error, 125 | ) 126 | await ctx.respond(embed=error_em, ephemeral=True) 127 | 128 | # Mass timeout users 129 | @mass.command(name="timeout") 130 | @option( 131 | "users", description='Mention the users whom you want to timeout. Use "," to separate users.', required=True 132 | ) 133 | @option("duration", description="Enter the duration of timeout. Ex: 1d, 2w etc...") 134 | @option("reason", description="Enter the reason for user timeout", required=False) 135 | async def mass_timeout_users(self, ctx: discord.ApplicationContext, users: str, duration: str, reason: str = None): 136 | """Timeouts mentioned users.""" 137 | await ctx.defer() 138 | users: list = users.split(",") 139 | _users: list = [] 140 | try: 141 | dur: datetime.timedelta = parse_duration(duration) 142 | except ValueError as e: 143 | error_em = discord.Embed(description=f"{emoji.error} {e}", color=config.color.error) 144 | await ctx.respond(embed=error_em, ephemeral=True) 145 | return 146 | errors: list[tuple] = [] 147 | if len(users) > 10: 148 | error_em = discord.Embed( 149 | description=f"{emoji.error} You can only mass timeout upto 10 users.", color=config.color.error 150 | ) 151 | await ctx.respond(embed=error_em, ephemeral=True) 152 | else: 153 | for user in users: 154 | try: 155 | _user = await commands.MemberConverter().convert(ctx, user.strip()) 156 | except Exception: 157 | errors.append((user.strip(), "User not found.")) 158 | continue 159 | if _user == ctx.guild.owner: 160 | errors.append((_user.mention, "You cannot use it on server owner.")) 161 | continue 162 | elif _user == ctx.author: 163 | errors.append((ctx.author.mention, "You cannot use it on yourself.")) 164 | continue 165 | elif _user.top_role.position >= ctx.author.top_role.position: 166 | errors.append((_user.mention, "User has same role or higher role than you.")) 167 | continue 168 | else: 169 | _users.append(_user.mention) 170 | await _user.timeout_for(dur, reason=reason) 171 | if len(_users) > 0: 172 | mass_timeout_em = discord.Embed( 173 | title=f"{emoji.timer2} Mass Timed out Users", 174 | description=f"Successfully timed out {len(_users)} users.\n" 175 | + f"{emoji.bullet2} **Duration**: `{format_timedelta(dur, locale='en_IN')}`\n" 176 | + f"{emoji.bullet2} **Reason**: {reason}\n" 177 | + f"{emoji.bullet2} **Users**: {', '.join(_users)}", 178 | color=config.color.error, 179 | ) 180 | await ctx.respond(embed=mass_timeout_em) 181 | channel_id = (await fetch_guild_settings(ctx.guild.id)).mod_cmd_log_channel_id 182 | if channel_id: 183 | log_ch = await self.client.fetch_channel(channel_id) 184 | mass_timeout_em.description += f"\n{emoji.bullet} **Moderator**: {ctx.author.mention}" 185 | await log_ch.send(embed=mass_timeout_em) 186 | if len(errors) > 0: 187 | error_em = discord.Embed( 188 | title=f"{emoji.error} Can't timeout users", 189 | description="\n".join([f"{emoji.bullet2} **{user}**: {reason}" for user, reason in errors]), 190 | color=config.color.error, 191 | ) 192 | await ctx.respond(embed=error_em, ephemeral=True) 193 | 194 | # Mass untimeout users 195 | @mass.command(name="untimeout") 196 | @option( 197 | "users", description='Mention the users whom you want to untimeout. Use "," to separate users.', required=True 198 | ) 199 | @option("reason", description="Enter the reason for user timeout", required=False) 200 | async def mass_untimeout_users(self, ctx: discord.ApplicationContext, users: str, reason: str = None): 201 | """Untimeouts mentioned users.""" 202 | await ctx.defer() 203 | users: list = users.split(",") 204 | _users: list = [] 205 | errors: list[tuple] = [] 206 | if len(users) > 10: 207 | error_em = discord.Embed( 208 | description=f"{emoji.error} You can only mass untimeout upto 10 users.", color=config.color.error 209 | ) 210 | await ctx.respond(embed=error_em, ephemeral=True) 211 | else: 212 | for user in users: 213 | try: 214 | _user = await commands.MemberConverter().convert(ctx, user.strip()) 215 | except Exception: 216 | errors.append((user.strip(), "User not found.")) 217 | continue 218 | if _user == ctx.author: 219 | errors.append((ctx.author.mention, "You cannot use it on yourself.")) 220 | continue 221 | elif _user.top_role.position >= ctx.author.top_role.position: 222 | errors.append((_user.mention, "User has same role or higher role than you.")) 223 | continue 224 | else: 225 | _users.append(_user.mention) 226 | await _user.timeout(None, reason=reason) 227 | if len(_users) > 0: 228 | mass_untimeout_em = discord.Embed( 229 | title=f"{emoji.timer} Mass Untimed out Users", 230 | description=f"Successfully untimed out {len(_users)} users.\n" 231 | + f"{emoji.bullet} **Reason**: {reason}\n" 232 | + f"{emoji.bullet} **Users**: {', '.join(_users)}", 233 | color=config.color.theme, 234 | ) 235 | await ctx.respond(embed=mass_untimeout_em) 236 | channel_id = (await fetch_guild_settings(ctx.guild.id)).mod_cmd_log_channel_id 237 | if channel_id: 238 | log_ch = await self.client.fetch_channel(channel_id) 239 | mass_untimeout_em.description += f"\n{emoji.bullet} **Moderator**: {ctx.author.mention}" 240 | await log_ch.send(embed=mass_untimeout_em) 241 | if len(errors) > 0: 242 | error_em = discord.Embed( 243 | title=f"{emoji.error} Can't untimeout users", 244 | description="\n".join([f"{emoji.bullet} **{user}**: {reason}" for user, reason in errors]), 245 | color=config.color.error, 246 | ) 247 | await ctx.respond(embed=error_em, ephemeral=True) 248 | 249 | # Mass role add 250 | @mass.command(name="role-add") 251 | @option( 252 | "users", 253 | description='Mention the users whom you want to add the role. Use "," to separate users.', 254 | required=True, 255 | ) 256 | @option( 257 | "roles", 258 | description='Mention the roles which you will add to the users. Use "," to separate roles.', 259 | required=True, 260 | ) 261 | async def mass_role_add(self, ctx: discord.ApplicationContext, users: str, roles: str): 262 | """Adds mentioned roles to mentioned users.""" 263 | await ctx.defer() 264 | users: list = users.split(",") 265 | roles: list = roles.split(",") 266 | _users: list = [] 267 | _roles: list = [] 268 | role_errors: list[tuple] = [] 269 | user_errors: list[tuple] = [] 270 | if len(users) > 10 or len(roles) > 10: 271 | error_em = discord.Embed( 272 | description=f"{emoji.error} You can only mass add 10 roles upto 10 users.", color=config.color.error 273 | ) 274 | await ctx.respond(embed=error_em, ephemeral=True) 275 | else: 276 | for role in roles: # Check roles 277 | try: 278 | _role = await commands.RoleConverter().convert(ctx, role.strip()) 279 | except Exception: 280 | role_errors.append((role.strip(), "Role not found.")) 281 | continue 282 | if _role.position >= ctx.guild.get_member(self.client.user.id).top_role.position: 283 | role_errors.append((_role.mention, "Role has same or higher position than me.")) 284 | continue 285 | else: 286 | _roles.append(_role) 287 | if len(_roles) > 0: 288 | for user in users: # Check users 289 | try: 290 | _user = await commands.MemberConverter().convert(ctx, user.strip()) 291 | except Exception: 292 | user_errors.append((user.strip(), "User not found.")) 293 | continue 294 | if _user.top_role.position >= ctx.author.top_role.position: 295 | user_errors.append((_user.mention, "User has same role or higher role than you.")) 296 | continue 297 | else: 298 | _users.append(_user.mention) 299 | for role in _roles: 300 | await _user.add_roles(role) 301 | if len(_users) > 0 and len(_roles) > 0: 302 | mass_role_add_em = discord.Embed( 303 | title=f"{emoji.plus} Mass Added Roles", 304 | description=f"Successfully added {len(_roles)} roles to {len(_users)} users.\n" 305 | + f"{emoji.bullet} **User(s)**: {', '.join(_users)}\n" 306 | + f"{emoji.bullet} **Role(s)**: {', '.join([role.mention for role in _roles])}", 307 | color=config.color.theme, 308 | ) 309 | await ctx.respond(embed=mass_role_add_em) 310 | channel_id = (await fetch_guild_settings(ctx.guild.id)).mod_cmd_log_channel_id 311 | if channel_id: 312 | log_ch = await self.client.fetch_channel(channel_id) 313 | mass_role_add_em.description += f"\n{emoji.bullet} **Moderator**: {ctx.author.mention}" 314 | await log_ch.send(embed=mass_role_add_em) 315 | if len(role_errors) > 0 or len(user_errors) > 0: 316 | error_em = discord.Embed( 317 | title=f"{emoji.error} Can't add roles", 318 | description="\n".join( 319 | [f"{emoji.bullet} **{obj}**: {reason}" for obj, reason in role_errors + user_errors] 320 | ), 321 | color=config.color.error, 322 | ) 323 | await ctx.respond(embed=error_em, ephemeral=True) 324 | 325 | # Mass role remove 326 | @mass.command(name="role-remove") 327 | @option( 328 | "users", 329 | description='Mention the users whom you want to remove the role. Use "," to separate users.', 330 | required=True, 331 | ) 332 | @option( 333 | "roles", 334 | description='Mention the roles which you will remove from the users. Use "," to separate roles.', 335 | required=True, 336 | ) 337 | async def mass_role_remove(self, ctx: discord.ApplicationContext, users: str, roles: str): 338 | """Removes mentioned roles from mentioned users.""" 339 | await ctx.defer() 340 | users: list = users.split(",") 341 | roles: list = roles.split(",") 342 | _users: list = [] 343 | _roles: list = [] 344 | role_errors: list[tuple] = [] 345 | user_errors: list[tuple] = [] 346 | if len(users) > 10 or len(roles) > 10: 347 | error_em = discord.Embed( 348 | description=f"{emoji.error} You can only mass remove 10 roles upto 10 users.", 349 | color=config.color.error, 350 | ) 351 | await ctx.respond(embed=error_em, ephemeral=True) 352 | else: 353 | for role in roles: # Check roles 354 | try: 355 | _role = await commands.RoleConverter().convert(ctx, role.strip()) 356 | except Exception: 357 | role_errors.append((role.strip(), "Role not found.")) 358 | continue 359 | if _role.position >= ctx.guild.get_member(self.client.user.id).top_role.position: 360 | role_errors.append((_role.mention, "Role has same or higher position than me.")) 361 | continue 362 | else: 363 | _roles.append(_role) 364 | if len(_roles) > 0: 365 | for user in users: # Check users 366 | try: 367 | _user = await commands.MemberConverter().convert(ctx, user.strip()) 368 | except Exception: 369 | user_errors.append((user.strip(), "User not found.")) 370 | continue 371 | if _user.top_role.position >= ctx.author.top_role.position: 372 | user_errors.append((_user.mention, "User has same role or higher role than you.")) 373 | continue 374 | else: 375 | _users.append(_user.mention) 376 | for role in _roles: 377 | await _user.remove_roles(role) 378 | if len(_users) > 0 and len(_roles) > 0: 379 | mass_role_remove_em = discord.Embed( 380 | title=f"{emoji.minus} Mass Removed Roles", 381 | description=f"Successfully removed {len(_roles)} roles from {len(_users)} users.\n" 382 | + f"{emoji.bullet} **User(s)**: {', '.join(_users)}\n" 383 | + f"{emoji.bullet} **Role(s)**: {', '.join([role.mention for role in _roles])}", 384 | color=config.color.theme, 385 | ) 386 | await ctx.respond(embed=mass_role_remove_em) 387 | channel_id = (await fetch_guild_settings(ctx.guild.id)).mod_cmd_log_channel_id 388 | if channel_id: 389 | log_ch = await self.client.fetch_channel(channel_id) 390 | mass_role_remove_em.description += f"\n{emoji.bullet} **Moderator**: {ctx.author.mention}" 391 | await log_ch.send(embed=mass_role_remove_em) 392 | if len(role_errors) > 0 or len(user_errors) > 0: 393 | error_em = discord.Embed( 394 | title=f"{emoji.error} Can't remove roles", 395 | description="\n".join( 396 | [f"{emoji.bullet} **{obj}**: {reason}" for obj, reason in role_errors + user_errors] 397 | ), 398 | color=config.color.error, 399 | ) 400 | await ctx.respond(embed=error_em, ephemeral=True) 401 | 402 | 403 | def setup(client: discord.Bot): 404 | client.add_cog(MassModeration(client)) 405 | -------------------------------------------------------------------------------- /cogs/mod_logs.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from db.funcs.guild import fetch_guild_settings 3 | from discord.ext import commands 4 | from utils import config 5 | from utils.emoji import emoji 6 | 7 | 8 | class Logs(commands.Cog): 9 | def __init__(self, client): 10 | self.client = client 11 | 12 | # Join 13 | @commands.Cog.listener() 14 | async def on_member_join(self, user: discord.Member): 15 | channel_id = (await fetch_guild_settings(user.guild.id)).mod_log_channel_id 16 | if channel_id is not None: 17 | join_ch = await self.client.fetch_channel(channel_id) 18 | join_em = discord.Embed( 19 | title=f"{emoji.plus} Member Joined", 20 | description=f"{emoji.bullet} **Name**: {user.mention}\n" 21 | + f"{emoji.bullet} **Account Created**: {discord.utils.format_dt(user.created_at, 'R')}", 22 | color=config.color.theme, 23 | ) 24 | join_em.set_thumbnail(url=f"{user.avatar.url}") 25 | await join_ch.send(embed=join_em) 26 | 27 | # Leave 28 | @commands.Cog.listener() 29 | async def on_member_remove(self, user: discord.Member): 30 | channel_id = (await fetch_guild_settings(user.guild.id)).mod_log_channel_id 31 | if channel_id is not None: 32 | leave_ch = await self.client.fetch_channel(channel_id) 33 | leave_em = discord.Embed( 34 | title=f"{emoji.minus} Member Left", 35 | description=f"{emoji.bullet2} **Name**: {user.mention}\n" 36 | + f"{emoji.bullet2} **Account Created**: {discord.utils.format_dt(user.created_at, 'R')}\n" 37 | + f"{emoji.bullet2} **Server Joined**: {discord.utils.format_dt(user.joined_at, 'R')}", 38 | color=config.color.error, 39 | ) 40 | leave_em.set_thumbnail(url=f"{user.avatar.url}") 41 | await leave_ch.send(embed=leave_em) 42 | 43 | # Ban 44 | @commands.Cog.listener() 45 | async def on_member_ban(self, user: discord.Member): 46 | channel_id = (await fetch_guild_settings(user.guild.id)).mod_log_channel_id 47 | if channel_id is not None: 48 | ban_ch = await self.client.fetch_channel(channel_id) 49 | ban_em = discord.Embed( 50 | title=f"{emoji.mod2} Member Banned", 51 | description=f"{emoji.bullet2} **Name**: {user.mention}\n" 52 | + f"{emoji.bullet2} **Account Created**: {discord.utils.format_dt(user.created_at, 'R')}\n" 53 | + f"{emoji.bullet2} **Server Joined**: {discord.utils.format_dt(user.joined_at, 'R')}", 54 | color=config.color.error, 55 | ) 56 | ban_em.set_thumbnail(url=f"{user.avatar.url}") 57 | await ban_ch.send(embed=ban_em) 58 | 59 | # Unban 60 | @commands.Cog.listener() 61 | async def on_member_unban(self, user: discord.Member): 62 | channel_id = (await fetch_guild_settings(user.guild.id)).mod_log_channel_id 63 | if channel_id is not None: 64 | unban_ch = await self.client.fetch_channel(channel_id) 65 | unban_em = discord.Embed( 66 | title=f"{emoji.mod} Member Unbanned", 67 | description=f"{emoji.bullet} **Name**: {user.mention}\n" 68 | + f"{emoji.bullet} **Account Created**: {discord.utils.format_dt(user.created_at, 'R')}", 69 | color=config.color.theme, 70 | ) 71 | unban_em.set_thumbnail(url=f"{user.avatar.url}") 72 | await unban_ch.send(embed=unban_em) 73 | 74 | # Edit 75 | @commands.Cog.listener() 76 | async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message): 77 | if msg_before.guild.id: 78 | channel_id = (await fetch_guild_settings(msg_before.guild.id)).mod_log_channel_id 79 | if msg_before.author and msg_after.author == self.client.user: 80 | return 81 | elif msg_before.author.bot: 82 | return 83 | elif channel_id is not None: 84 | edit_ch = await self.client.fetch_channel(channel_id) 85 | edit_em = discord.Embed( 86 | title=f"{emoji.edit} Message Edited", 87 | description=f"{emoji.bullet} **Author**: {msg_before.author.mention}\n" 88 | + f"{emoji.bullet} **Channel**: {msg_before.channel.mention}\n" 89 | + f"{emoji.bullet} **Message:** [Jump to Message]({msg_before.jump_url})\n" 90 | + f"{emoji.bullet2} **Original Message**: {msg_before.content}\n" 91 | + f"{emoji.bullet} **Edited Message**: {msg_after.content}", 92 | color=config.color.theme, 93 | ) 94 | if msg_before.attachments: 95 | edit_em.description += ( 96 | f"\n{emoji.bullet} **Removed Attachment**: [Click Here]({msg_before.attachments[0].url})" 97 | ) 98 | edit_em.set_image(url=msg_before.attachments[0].url) 99 | await edit_ch.send(embed=edit_em) 100 | 101 | # Delete 102 | @commands.Cog.listener() 103 | async def on_message_delete(self, msg: discord.Message): 104 | if msg.guild: 105 | channel_id = (await fetch_guild_settings(msg.guild.id)).mod_log_channel_id 106 | if msg.author == self.client.user: 107 | return 108 | elif msg.author.bot: 109 | return 110 | elif channel_id is not None: 111 | del_ch = await self.client.fetch_channel(channel_id) 112 | del_em = discord.Embed( 113 | title=f"{emoji.bin} Message Deleted", 114 | description=f"{emoji.bullet2} **Author**: {msg.author.mention}\n" 115 | + f"{emoji.bullet2} **Channel**: {msg.channel.mention}\n" 116 | + f"{emoji.bullet2} **Message**: {msg.content}", 117 | color=config.color.error, 118 | ) 119 | if msg.attachments: 120 | del_em.description += f"\n{emoji.bullet2} **Attachment(s)**: {', '.join([f'[Click Here]({attachment.url})' for attachment in msg.attachments])}" 121 | await del_ch.send(embed=del_em) 122 | # Deleted Attachments 123 | if msg.attachments: 124 | del_list: list = [] 125 | for attachment in msg.attachments: 126 | del_em = discord.Embed( 127 | title=f"{emoji.bin} Attachment Deleted", 128 | description=f"{emoji.bullet2} **Author**: {msg.author.mention}\n" 129 | + f"{emoji.bullet2} **Channel**: {msg.channel.mention}\n" 130 | + f"{emoji.bullet2} **Attachment**: [Click Here]({attachment.url})", 131 | color=config.color.error, 132 | ) 133 | del_em.set_image(url=attachment.url) 134 | del_list.append(del_em) 135 | await del_ch.send( 136 | embeds=del_list 137 | ) # Limited to send 10 embeds because of discord limitations, user: discord.Members are also limited to 10 attachments per message so this will work fine. 138 | 139 | # Bulk delete 140 | @commands.Cog.listener() 141 | async def on_bulk_message_delete(self, msgs: list[discord.Message]): 142 | if msgs[0].guild: 143 | channel_id = (await fetch_guild_settings(msgs[0].guild.id)).mod_log_channel_id 144 | if msgs[0].author == self.client.user: 145 | return 146 | elif msgs[0].author.bot: 147 | return 148 | elif channel_id is not None: 149 | bulk_ch = await self.client.fetch_channel(channel_id) 150 | bulk_em = discord.Embed( 151 | title=f"{emoji.bin} Bulk Message Deleted", 152 | description=f"{emoji.bullet2} **Author**: {msgs[0].author.mention}\n" 153 | + f"{emoji.bullet2} **Channel**: {msgs[0].channel.mention}\n" 154 | + f"{emoji.bullet2} **Messages Deleted**: {len(msgs)}", 155 | color=config.color.error, 156 | ) 157 | await bulk_ch.send(embed=bulk_em) 158 | 159 | 160 | def setup(client: discord.Bot): 161 | client.add_cog(Logs(client)) 162 | -------------------------------------------------------------------------------- /cogs/moderation.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import discord 3 | from babel.dates import format_timedelta 4 | from discord.commands import SlashCommandGroup, option, slash_command 5 | from discord.ext import commands 6 | from utils import config 7 | from utils.emoji import emoji 8 | from utils.helpers import parse_duration 9 | 10 | 11 | class Moderation(commands.Cog): 12 | def __init__(self, client): 13 | self.client = client 14 | 15 | # Purge slash cmd group 16 | purge = SlashCommandGroup( 17 | name="purge", 18 | description="Purge related commands.", 19 | default_member_permissions=discord.Permissions(manage_messages=True), 20 | ) 21 | 22 | # Purge any 23 | @purge.command(name="any") 24 | @option("amount", description="Enter an integer between 1 to 1000.") 25 | async def purge_any(self, ctx: discord.ApplicationContext, amount: int): 26 | """Purges the amount of given messages.""" 27 | amount_condition = [amount < 1, amount > 1000] 28 | await ctx.defer(ephemeral=True) 29 | if any(amount_condition): 30 | error_em = discord.Embed( 31 | description=f"{emoji.error} Amount must be between 1 to 1000.", color=config.color.error 32 | ) 33 | await ctx.respond(embed=error_em, ephemeral=True) 34 | else: 35 | await ctx.channel.purge(limit=amount) 36 | purge_em = discord.Embed( 37 | title=f"{emoji.bin} Messages Purged", 38 | description=f"Successfully purged `{amount}` message(s)", 39 | color=config.color.theme, 40 | ) 41 | await ctx.respond(embed=purge_em, ephemeral=True) 42 | 43 | # Purge humans 44 | @purge.command(name="humans") 45 | @option("amount", description="Enter an integer between 1 to 1000.") 46 | async def purge_humans(self, ctx: discord.ApplicationContext, amount: int): 47 | """Purges the amount of given messages sent by humans.""" 48 | amount_condition = [amount < 1, amount > 1000] 49 | await ctx.defer(ephemeral=True) 50 | if any(amount_condition): 51 | error_em = discord.Embed( 52 | description=f"{emoji.error} Amount must be between 1 to 1000.", color=config.color.error 53 | ) 54 | await ctx.respond(embed=error_em, ephemeral=True) 55 | else: 56 | await ctx.channel.purge(limit=amount, check=lambda m: not m.author.bot) 57 | purge_em = discord.Embed( 58 | title=f"{emoji.bin} Messages Purged", 59 | description=f"Successfully purged `{amount}` message(s) sent by humans", 60 | color=config.color.theme, 61 | ) 62 | await ctx.respond(embed=purge_em, ephemeral=True) 63 | 64 | # Purge bots 65 | @purge.command(name="bots") 66 | @option("amount", description="Enter an integer between 1 to 1000.") 67 | async def purge_bots(self, ctx: discord.ApplicationContext, amount: int): 68 | """Purges the amount of given messages sent by bots.""" 69 | amount_condition = [amount < 1, amount > 1000] 70 | await ctx.defer(ephemeral=True) 71 | if any(amount_condition): 72 | error_em = discord.Embed( 73 | description=f"{emoji.error} Amount must be between 1 to 1000.", color=config.color.error 74 | ) 75 | await ctx.respond(embed=error_em, ephemeral=True) 76 | else: 77 | await ctx.channel.purge(limit=amount, check=lambda m: m.author.bot) 78 | purge_em = discord.Embed( 79 | title=f"{emoji.bin} Messages Purged", 80 | description=f"Successfully purged `{amount}` message(s) sent by bots", 81 | color=config.color.theme, 82 | ) 83 | await ctx.respond(embed=purge_em, ephemeral=True) 84 | 85 | # Purge user 86 | @purge.command(name="user") 87 | @option("amount", description="Enter an integer between 1 to 1000.") 88 | @option("user", description="Mention the user whose messages you want to purge.") 89 | async def purge_user(self, ctx: discord.ApplicationContext, amount: int, user: discord.Member): 90 | """Purges the amount of given messages sent by the mentioned user.""" 91 | amount_condition = [amount < 1, amount > 1000] 92 | await ctx.defer(ephemeral=True) 93 | if any(amount_condition): 94 | error_em = discord.Embed( 95 | description=f"{emoji.error} Amount must be between 1 to 1000.", color=config.color.error 96 | ) 97 | await ctx.respond(embed=error_em, ephemeral=True) 98 | else: 99 | await ctx.channel.purge(limit=amount, check=lambda m: m.author.id == user.id) 100 | purge_em = discord.Embed( 101 | title=f"{emoji.bin} Messages Purged", 102 | description=f"Successfully purged `{amount}` message(s) sent by {user.mention}", 103 | color=config.color.theme, 104 | ) 105 | await ctx.respond(embed=purge_em, ephemeral=True) 106 | 107 | # Purge containing phrase 108 | @purge.command(name="contains") 109 | @option("amount", description="Enter an integer between 1 to 1000.") 110 | @option("phrase", description="Enter the phrase to purge messages containing it.") 111 | async def purge_contains(self, ctx: discord.ApplicationContext, amount: int, phrase: str): 112 | """Purges the amount of given messages containing the given phrase.""" 113 | amount_condition = [amount < 1, amount > 1000] 114 | await ctx.defer(ephemeral=True) 115 | if any(amount_condition): 116 | error_em = discord.Embed( 117 | description=f"{emoji.error} Amount must be between 1 to 1000.", color=config.color.error 118 | ) 119 | await ctx.respond(embed=error_em, ephemeral=True) 120 | else: 121 | await ctx.channel.purge(limit=amount, check=lambda m: phrase.lower() in m.content.lower()) 122 | purge_em = discord.Embed( 123 | title=f"{emoji.bin} Messages Purged", 124 | description=f"Successfully purged `{amount}` message(s) containing `{phrase}`", 125 | color=config.color.theme, 126 | ) 127 | await ctx.respond(embed=purge_em, ephemeral=True) 128 | 129 | # Kick 130 | @slash_command(name="kick") 131 | @discord.default_permissions(kick_members=True) 132 | @option("user", description="Mention the user whom you want to kick") 133 | @option("reason", description="Enter the reason for kicking the user", required=False) 134 | async def kick(self, ctx: discord.ApplicationContext, user: discord.Member, reason: str = None): 135 | """Kicks the mentioned user.""" 136 | if user == ctx.author: 137 | error_em = discord.Embed( 138 | description=f"{emoji.error} You cannot use it on yourself", color=config.color.error 139 | ) 140 | await ctx.respond(embed=error_em, ephemeral=True) 141 | elif user.top_role.position >= ctx.author.top_role.position: 142 | error_em = discord.Embed( 143 | description=f"{emoji.error} Given user has same role or higher role than you", 144 | color=config.color.error, 145 | ) 146 | await ctx.respond(embed=error_em, ephemeral=True) 147 | else: 148 | kich_em = discord.Embed( 149 | title=f"{emoji.kick} Kicked User", 150 | description=f"Successfully kicked **{user}** from the server.\n" 151 | + f"{emoji.bullet2} **Reason**: {reason}", 152 | color=config.color.error, 153 | ) 154 | await user.kick(reason=reason) 155 | await ctx.respond(embed=kich_em) 156 | 157 | # Ban 158 | @slash_command(name="ban") 159 | @discord.default_permissions(ban_members=True) 160 | @option("user", description="Mention the user whom you want to ban") 161 | @option("reason", description="Enter the reason for banning the user", required=False) 162 | async def ban(self, ctx: discord.ApplicationContext, user: discord.Member, reason: str = None): 163 | """Bans the mentioned user.""" 164 | if user == ctx.author: 165 | error_em = discord.Embed( 166 | description=f"{emoji.error} You cannot use it on yourself", color=config.color.error 167 | ) 168 | await ctx.respond(embed=error_em, ephemeral=True) 169 | elif user.top_role.position >= ctx.author.top_role.position: 170 | error_em = discord.Embed( 171 | description=f"{emoji.error} Given user has same role or higher role than you", 172 | color=config.color.error, 173 | ) 174 | await ctx.respond(embed=error_em, ephemeral=True) 175 | else: 176 | ban_em = discord.Embed( 177 | title=f"{emoji.mod2} Banned User", 178 | description=f"Successfully banned **{user}** from the server.\n" 179 | + f"{emoji.bullet2} **Reason**: {reason}", 180 | color=config.color.error, 181 | ) 182 | await user.ban(reason=reason) 183 | await ctx.respond(embed=ban_em) 184 | 185 | # Timeout user 186 | @slash_command(name="timeout") 187 | @discord.default_permissions(moderate_members=True) 188 | @option("user", description="Mention the user whom you want to timeout") 189 | @option("duration", description="Enter the duration of timeout. Ex: 1d, 2w etc...") 190 | @option("reason", description="Enter the reason for user timeout", required=False) 191 | async def timeout_user( 192 | self, ctx: discord.ApplicationContext, user: discord.Member, duration: str, reason: str = None 193 | ): 194 | """Timeouts the mentioned user.""" 195 | if user == ctx.author: 196 | error_em = discord.Embed( 197 | description=f"{emoji.error} You cannot use it on yourself.", color=config.color.error 198 | ) 199 | await ctx.respond(embed=error_em, ephemeral=True) 200 | elif user.top_role.position >= ctx.author.top_role.position: 201 | error_em = discord.Embed( 202 | description=f"{emoji.error} Given user has same role or higher role than you.", 203 | color=config.color.error, 204 | ) 205 | await ctx.respond(embed=error_em, ephemeral=True) 206 | else: 207 | try: 208 | dur: datetime.timedelta = parse_duration(duration) 209 | except ValueError as e: 210 | error_em = discord.Embed(description=f"{emoji.error} {e}", color=config.color.error) 211 | await ctx.respond(embed=error_em, ephemeral=True) 212 | return 213 | await user.timeout_for(dur, reason=reason) 214 | timeout_em = discord.Embed( 215 | title=f"{emoji.timer2} Timed out User", 216 | description=f"Successfully timed out {user.mention}.\n" 217 | + f"{emoji.bullet2} **Duration**: `{format_timedelta(dur, locale='en_IN')}`\n" 218 | + f"{emoji.bullet2} **Reason**: {reason}", 219 | color=config.color.error, 220 | ) 221 | await ctx.respond(embed=timeout_em) 222 | 223 | # Untimeout user 224 | @slash_command(name="untimeout") 225 | @discord.default_permissions(moderate_members=True) 226 | @option("user", description="Mention the user whom you want to untimeout") 227 | @option("reason", description="Enter the reason for user timeout", required=False) 228 | async def untimeout_user(self, ctx: discord.ApplicationContext, user: discord.Member, reason: str = None): 229 | """Untimeouts the mentioned user.""" 230 | if user == ctx.author: 231 | error_em = discord.Embed( 232 | description=f"{emoji.error} You cannot use it on yourself", color=config.color.error 233 | ) 234 | await ctx.respond(embed=error_em, ephemeral=True) 235 | elif user.top_role.position >= ctx.author.top_role.position: 236 | error_em = discord.Embed( 237 | description=f"{emoji.error} Given user has same role or higher role than you", 238 | color=config.color.error, 239 | ) 240 | await ctx.respond(embed=error_em, ephemeral=True) 241 | else: 242 | await user.timeout(None, reason=reason) 243 | untimeout_em = discord.Embed( 244 | title=f"{emoji.timer} Untimed out User", 245 | description=f"Successfully untimed out {user.mention}.\n" + f"{emoji.bullet} **Reason**: {reason}", 246 | color=config.color.theme, 247 | ) 248 | await ctx.respond(embed=untimeout_em) 249 | 250 | # Lock 251 | @slash_command(name="lock") 252 | @discord.default_permissions(manage_channels=True) 253 | @option("reason", description="Enter the reason for locking the channel", required=False) 254 | async def lock(self, ctx: discord.ApplicationContext, reason: str = None): 255 | """Locks the current channel.""" 256 | lock_em = discord.Embed( 257 | title=f"{emoji.lock} Channel Locked", 258 | description=f"Successfull locked {ctx.channel.mention}.\n" + f"{emoji.bullet} **Reason**: {reason}", 259 | color=config.color.theme, 260 | ) 261 | await ctx.channel.set_permissions(ctx.author, send_messages=True) 262 | await ctx.channel.set_permissions(ctx.guild.default_role, send_messages=False) 263 | await ctx.respond(embed=lock_em) 264 | 265 | # Unlock 266 | @slash_command(name="unlock") 267 | @discord.default_permissions(manage_channels=True) 268 | async def unlock(self, ctx: discord.ApplicationContext): 269 | """Unlocks the current channel.""" 270 | unlock_em = discord.Embed( 271 | title=f"{emoji.unlock} Channel Unlocked", 272 | description=f"Successfull unlocked {ctx.channel.mention}", 273 | color=config.color.theme, 274 | ) 275 | await ctx.channel.set_permissions(ctx.author, send_messages=True) 276 | await ctx.channel.set_permissions(ctx.guild.default_role, send_messages=True) 277 | await ctx.respond(embed=unlock_em) 278 | 279 | # Role slash cmd group 280 | role = SlashCommandGroup( 281 | name="role", 282 | description="Role related commands.", 283 | default_member_permissions=discord.Permissions(manage_roles=True), 284 | ) 285 | 286 | # Role add 287 | @role.command(name="add") 288 | @option("user", description="Mention the user whom you want to add the role") 289 | @option("role", description="Mention the role which you will add to the user") 290 | async def add_role(self, ctx: discord.ApplicationContext, user: discord.Member, role: discord.Role): 291 | """Adds the mentioned role to the mentioned user.""" 292 | add_role_em = discord.Embed( 293 | title=f"{emoji.plus} Role Added", 294 | description=f"Successfully added {role.mention} to {user.mention}", 295 | color=config.color.theme, 296 | ) 297 | await user.add_roles(role) 298 | await ctx.respond(embed=add_role_em) 299 | 300 | # Remove role 301 | @role.command(name="remove") 302 | @option("user", description="Mention the user whom you want to remove the role") 303 | @option("role", description="Mention the role which you will remove from the user") 304 | async def remove_role(self, ctx: discord.ApplicationContext, user: discord.Member, role: discord.Role): 305 | """Removes the mentioned role from the mentioned user.""" 306 | remove_role_em = discord.Embed( 307 | title=f"{emoji.minus} Role Removed", 308 | description=f"Successfully removed {role.mention} from {user.mention}", 309 | color=config.color.theme, 310 | ) 311 | await user.remove_roles(role) 312 | await ctx.respond(embed=remove_role_em) 313 | 314 | 315 | def setup(client: discord.Bot): 316 | client.add_cog(Moderation(client)) 317 | -------------------------------------------------------------------------------- /cogs/music.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import discord 4 | import discord.ui 5 | import lavalink 6 | import math 7 | import re 8 | from babel.dates import format_timedelta 9 | from discord.commands import option, slash_command 10 | from discord.ext import commands, tasks 11 | from music import equalizer_presets, store 12 | from music.client import LavalinkVoiceClient 13 | from music.sources import spotify 14 | from utils import config 15 | from utils.emoji import emoji 16 | 17 | # Regex 18 | url_rx = re.compile("https?:\\/\\/(?:www\\.)?.+") 19 | 20 | 21 | class MusicView(discord.ui.View): 22 | def __init__(self, client: discord.Bot, timeout: int): 23 | super().__init__(timeout=timeout, disable_on_timeout=True) 24 | self.client = client 25 | 26 | async def interaction_check(self, interaction: discord.Interaction): 27 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(interaction.guild_id) 28 | if not player.current: 29 | error_em = discord.Embed( 30 | description=f"{emoji.error} Nothing is being played at the current moment", color=config.color.error 31 | ) 32 | await interaction.response.send_message(embed=error_em, ephemeral=True) 33 | return False 34 | elif not interaction.user.voice: 35 | error_em = discord.Embed(description=f"{emoji.error} Join a voice channel first", color=config.color.error) 36 | await interaction.response.send_message(embed=error_em, ephemeral=True) 37 | return False 38 | elif player.is_connected and interaction.user.voice.channel.id != int(player.channel_id): 39 | error_em = discord.Embed( 40 | description=f"{emoji.error} You are not in my voice channel", color=config.color.error 41 | ) 42 | await interaction.response.send_message(embed=error_em, ephemeral=True) 43 | else: 44 | return True 45 | 46 | # Pause / Resume 47 | @discord.ui.button(emoji=f"{emoji.pause2}", custom_id="pause", style=discord.ButtonStyle.grey) 48 | async def pause_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 49 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(interaction.guild_id) 50 | if not player.paused: 51 | await player.set_pause(True) 52 | elif player.paused: 53 | await player.set_pause(False) 54 | button.emoji = emoji.play2 if player.paused else emoji.pause2 55 | pause_em = discord.Embed( 56 | title=f"{emoji.play if player.paused else emoji.pause} Player {'Paused' if player.paused else 'Resumed'}", 57 | description=f"{interaction.user.mention} {'Paused' if player.paused else 'Resumed'} the player", 58 | color=config.color.theme, 59 | ) 60 | await interaction.response.edit_message(view=self) 61 | await interaction.followup.send(embed=pause_em, delete_after=5) 62 | 63 | # Stop 64 | @discord.ui.button(emoji=f"{emoji.stop2}", custom_id="stop", style=discord.ButtonStyle.grey) 65 | async def stop_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 66 | guild: discord.Guild = self.client.get_guild(int(interaction.guild_id)) 67 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(int(interaction.guild_id)) 68 | if player: 69 | player.channel_id = False 70 | await player.stop() 71 | player.queue.clear() 72 | stop_embed = discord.Embed( 73 | title=f"{emoji.stop} Player Destroyed", 74 | description=f"{interaction.user.mention} Destroyed the player", 75 | color=config.color.theme, 76 | ) 77 | for child in self.children: 78 | child.disabled = True 79 | await interaction.response.edit_message(view=self) 80 | await guild.voice_client.disconnect(force=True) 81 | await interaction.followup.send(embed=stop_embed, delete_after=5) 82 | await Disable(self.client, guild.id).queue_msg() 83 | 84 | # Skip 85 | @discord.ui.button(emoji=f"{emoji.skip2}", custom_id="skip", style=discord.ButtonStyle.grey) 86 | async def skip_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 87 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(interaction.guild_id) 88 | await player.skip() 89 | skip_em = discord.Embed( 90 | title=f"{emoji.skip} Track Skipped", 91 | description=f"{interaction.user.mention} Skipped the track", 92 | color=config.color.theme, 93 | ) 94 | for child in self.children: 95 | child.disabled = True 96 | await interaction.response.edit_message(view=self) 97 | await interaction.followup.send(embed=skip_em, delete_after=5) 98 | 99 | # Loop 100 | @discord.ui.button(emoji=f"{emoji.loop3}", custom_id="loop", style=discord.ButtonStyle.grey) 101 | async def loop_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 102 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(interaction.guild_id) 103 | if player.loop == player.LOOP_NONE: 104 | player.set_loop(1) 105 | button.emoji = emoji.loop2 106 | mode = "Track" 107 | elif player.loop == player.LOOP_SINGLE and player.queue: 108 | player.set_loop(2) 109 | button.emoji = emoji.loop 110 | mode = "Queue" 111 | else: 112 | player.set_loop(0) 113 | button.emoji = emoji.loop3 114 | mode = "OFF" 115 | loop_em = discord.Embed( 116 | title=f"{button.emoji} {mode if mode != 'OFF' else ''} Loop {'Enabled' if mode != 'OFF' else 'Disabled'}", 117 | description=f"Successfully {'enabled' if mode != 'OFF' else 'disabled'} {mode if mode != 'OFF' else ''} Loop", 118 | color=config.color.theme, 119 | ) 120 | await interaction.response.edit_message(view=self) 121 | await interaction.followup.send(embed=loop_em, delete_after=5) 122 | 123 | # Shuffle 124 | @discord.ui.button(emoji=f"{emoji.shuffle2}", custom_id="shuffle", style=discord.ButtonStyle.grey) 125 | async def shuffle_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 126 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(interaction.guild_id) 127 | if not player.queue: 128 | error_em = discord.Embed(description=f"{emoji.error} Queue is empty", color=config.color.error) 129 | await interaction.response.send_message(embed=error_em, ephemeral=True) 130 | else: 131 | player.shuffle = not player.shuffle 132 | button.emoji = f"{emoji.shuffle if player.shuffle else emoji.shuffle2}" 133 | shuffle_em = discord.Embed( 134 | title=f"{button.emoji} Shuffle {'Enabled' if player.shuffle else 'Disabled'}", 135 | description=f"{interaction.user.mention} {'Enabled' if player.shuffle else 'Disabled'} shuffle", 136 | color=config.color.theme, 137 | ) 138 | await interaction.response.edit_message(view=self) 139 | await interaction.followup.send(embed=shuffle_em, delete_after=5) 140 | 141 | 142 | class QueueView(discord.ui.View): 143 | def __init__(self, client: discord.Bot, page: int, timeout: int): 144 | super().__init__(timeout=timeout, disable_on_timeout=True) 145 | self.client = client 146 | self.page = page 147 | self.items_per_page = 5 148 | 149 | async def interaction_check(self, interaction: discord.Interaction): 150 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(interaction.guild_id) 151 | if not player.queue: 152 | error_em = discord.Embed(description=f"{emoji.error} Queue is empty", color=config.color.error) 153 | await interaction.response.send_message(embed=error_em, ephemeral=True) 154 | else: 155 | return True 156 | 157 | # Start 158 | @discord.ui.button(emoji=f"{emoji.start}", custom_id="start", style=discord.ButtonStyle.grey) 159 | async def start_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 160 | self.page = 1 161 | queue_embed = await QueueEmbed(self.client, interaction, self.page).get_embed() 162 | await interaction.response.edit_message(embed=queue_embed, view=self) 163 | 164 | # Previous 165 | @discord.ui.button(emoji=f"{emoji.previous}", custom_id="previous", style=discord.ButtonStyle.grey) 166 | async def previous_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 167 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(interaction.guild_id) 168 | pages = math.ceil(len(player.queue) / self.items_per_page) 169 | if self.page <= 1: 170 | self.page = pages 171 | else: 172 | self.page -= 1 173 | queue_embed = await QueueEmbed(self.client, interaction, self.page).get_embed() 174 | await interaction.response.edit_message(embed=queue_embed, view=self) 175 | 176 | # Next 177 | @discord.ui.button(emoji=f"{emoji.next}", custom_id="next", style=discord.ButtonStyle.grey) 178 | async def next_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 179 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(interaction.guild_id) 180 | pages = math.ceil(len(player.queue) / self.items_per_page) 181 | if self.page >= pages: 182 | self.page = 1 183 | else: 184 | self.page += 1 185 | queue_embed = await QueueEmbed(self.client, interaction, self.page).get_embed() 186 | await interaction.response.edit_message(embed=queue_embed, view=self) 187 | 188 | # End 189 | @discord.ui.button(emoji=f"{emoji.end}", custom_id="end", style=discord.ButtonStyle.grey) 190 | async def end_callback(self, button: discord.ui.Button, interaction: discord.Interaction): 191 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(interaction.guild_id) 192 | pages = math.ceil(len(player.queue) / self.items_per_page) 193 | self.page = pages 194 | queue_embed = await QueueEmbed(self.client, interaction, self.page).get_embed() 195 | await interaction.response.edit_message(embed=queue_embed, view=self) 196 | 197 | 198 | class QueueEmbed: 199 | def __init__(self, client: discord.Bot, ctx: discord.ApplicationContext, page: int): 200 | self.client = client 201 | self.ctx = ctx 202 | self.page = page 203 | self.items_per_page = 5 204 | 205 | async def get_embed(self) -> discord.Embed: 206 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(self.ctx.guild_id) 207 | pages = math.ceil(len(player.queue) / self.items_per_page) 208 | start = (self.page - 1) * self.items_per_page 209 | end = start + self.items_per_page 210 | current_requester = self.ctx.guild.get_member(player.current.requester) 211 | queue_list = "" 212 | for index, track in enumerate(player.queue[start:end], start=start): 213 | requester = self.ctx.guild.get_member(track.requester) 214 | queue_list += ( 215 | f"`{index + 1}.` **[{track.title}]({track.uri})** [{requester.mention if requester else 'Unknown'}]\n" 216 | ) 217 | queue_em = discord.Embed(title=f"{emoji.playlist} {self.ctx.guild.name}'s Queue", colour=config.color.theme) 218 | queue_em.add_field( 219 | name="Now Playing", 220 | value=f"`0.` **[{player.current.title}]({player.current.uri})** [{current_requester.mention if current_requester else 'Unknown'}]", 221 | inline=False, 222 | ) 223 | queue_em.add_field(name=f"Queued {len(player.queue)} Track(s)", value=f"{queue_list}", inline=False) 224 | queue_em.set_footer(text=f"Viewing Page {self.page}/{pages}") 225 | return queue_em 226 | 227 | 228 | class Disable: 229 | def __init__(self, client: discord.Bot, guild_id: int): 230 | self.client = client 231 | self.guild_id = guild_id 232 | 233 | # Disable queue menu 234 | async def edit_messages_async(self, queue_view, messages) -> None: 235 | tasks = [msg.edit(view=queue_view) for msg in messages] 236 | await asyncio.gather(*tasks) 237 | 238 | async def queue_msg(self) -> None: 239 | if len(store.queue_msg(self.guild_id)) > 0: 240 | queue_view = QueueView(self.client, page=1, timeout=None) 241 | for child in queue_view.children: 242 | child.disabled = True 243 | await self.edit_messages_async(queue_view, store.queue_msg(self.guild_id)) 244 | store.queue_msg(self.guild_id, "clear") 245 | 246 | # Disable play message 247 | async def play_msg(self) -> None: 248 | play_msg = store.play_msg(self.guild_id) 249 | music_view = MusicView(self.client, timeout=None) 250 | for child in music_view.children: 251 | child.disabled = True 252 | await play_msg.edit(view=music_view) 253 | 254 | 255 | class Music(commands.Cog): 256 | def __init__(self, client: discord.Bot): 257 | self.client = client 258 | self.music.start() 259 | 260 | # Looping music task 261 | @tasks.loop(seconds=0) 262 | async def music(self): 263 | await self.client.wait_until_ready() 264 | if not hasattr(self.client, "lavalink"): 265 | self.client.lavalink = lavalink.Client(self.client.user.id) 266 | self.client.lavalink.add_node( 267 | config.lavalink["host"], config.lavalink["port"], config.lavalink["password"], "us", "default-node" 268 | ) 269 | self.client.lavalink.add_event_hook(self.track_hook) 270 | 271 | # Current voice 272 | def current_voice_channel(self, ctx: discord.ApplicationContext): 273 | if ctx.guild and ctx.guild.me.voice: 274 | return ctx.guild.me.voice.channel 275 | return None 276 | 277 | # Unloading cog 278 | def cog_unload(self): 279 | self.client.lavalink._event_hooks.clear() 280 | 281 | # Lavalink track hook event 282 | async def track_hook(self, event: lavalink.Event): 283 | if isinstance(event, lavalink.events.TrackStartEvent): 284 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(int(event.player.guild_id)) 285 | channel = store.play_ch_id(event.player.guild_id) 286 | requester = f"<@{player.current.requester}>" 287 | if player.current.stream: 288 | duration = "🔴 LIVE" 289 | else: 290 | duration = datetime.timedelta(milliseconds=player.current.duration) 291 | duration = format_timedelta(duration, locale="en_IN") 292 | play_em = discord.Embed( 293 | title=f"{player.current.title}", 294 | url=f"{player.current.uri}", 295 | description=f"{emoji.bullet} **Requested By**: {requester if requester else 'Unknown'}\n" 296 | + f"{emoji.bullet} **Duration**: `{duration}`", 297 | color=config.color.theme, 298 | ) 299 | if player.current.source_name == "spotify": 300 | play_em.set_thumbnail(url=player.current.extra["cover"]) 301 | elif player.current.source_name == "youtube": 302 | play_em.set_image(url=f"https://i.ytimg.com/vi/{player.current.identifier}/maxresdefault.jpg") 303 | music_view = MusicView(self.client, timeout=None) 304 | # Loop emoji 305 | if player.loop == player.LOOP_NONE: 306 | music_view.children[3].emoji = emoji.loop3 307 | elif player.loop == player.LOOP_SINGLE: 308 | music_view.children[3].emoji = emoji.loop2 309 | else: 310 | music_view.children[3].emoji = emoji.loop 311 | # Shuffle emoji 312 | if player.shuffle: 313 | music_view.children[4].emoji = emoji.shuffle 314 | else: 315 | music_view.children[4].emoji = emoji.shuffle2 316 | # Player msg 317 | play_msg = await channel.send(embed=play_em, view=music_view) 318 | store.play_msg(event.player.guild_id, play_msg, "set") 319 | if isinstance(event, lavalink.events.TrackEndEvent): 320 | await Disable(self.client, event.player.guild_id).play_msg() 321 | if isinstance(event, lavalink.events.TrackStuckEvent): 322 | channel = store.play_ch_id(event.player.guild_id) 323 | error_em = discord.Embed( 324 | description=f"{emoji.error} Error while playing the track. Please try again later.", 325 | color=config.color.error, 326 | ) 327 | await Disable(self.client, event.player.guild_id).play_msg() 328 | await channel.send(embed=error_em, delete_after=5) 329 | if isinstance(event, lavalink.events.TrackExceptionEvent): 330 | channel = store.play_ch_id(event.player.guild_id) 331 | error_em = discord.Embed( 332 | description=f"{emoji.error} Error while playing the track. Please try again later.", 333 | color=config.color.error, 334 | ) 335 | await Disable(self.client, event.player.guild_id).play_msg() 336 | await channel.send(embed=error_em, delete_after=5) 337 | if isinstance(event, lavalink.events.QueueEndEvent): 338 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.get(int(event.player.guild_id)) 339 | guild: discord.Guild = self.client.get_guild(int(event.player.guild_id)) 340 | await player.clear_filters() 341 | await guild.voice_client.disconnect(force=True) 342 | await Disable(self.client, event.player.guild_id).queue_msg() 343 | 344 | # Ensures voice parameters 345 | async def ensure_voice(self, ctx: discord.ApplicationContext): 346 | """Checks all the voice parameters.""" 347 | player: lavalink.DefaultPlayer = None 348 | if not ctx.author.voice or not ctx.author.voice.channel: 349 | error_em = discord.Embed(description=f"{emoji.error} Join a voice channel first", color=config.color.error) 350 | await ctx.respond(embed=error_em, ephemeral=True) 351 | else: 352 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.create(ctx.guild.id) 353 | permissions = ctx.author.voice.channel.permissions_for(ctx.me) 354 | if ctx.command.name in ("play") and self.current_voice_channel(ctx) is None: 355 | if self.client.lavalink.node_manager.available_nodes: 356 | await ctx.author.voice.channel.connect(cls=LavalinkVoiceClient) 357 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.create(ctx.guild.id) 358 | store.play_ch_id(ctx.guild.id, ctx.channel, "set") 359 | else: 360 | self.client.lavalink.add_node( 361 | config.lavalink["host"], 362 | config.lavalink["port"], 363 | config.lavalink["password"], 364 | "us", 365 | "default-node", 366 | ) 367 | elif self.current_voice_channel(ctx) is not None and not self.client.lavalink.node_manager.available_nodes: 368 | self.client.lavalink.add_node( 369 | config.lavalink["host"], config.lavalink["port"], config.lavalink["password"], "us", "default-node" 370 | ) 371 | elif not permissions.connect or not permissions.speak: 372 | player: lavalink.DefaultPlayer = None 373 | error_em = discord.Embed( 374 | description=f"{emoji.error} I need the `Connect` and `Speak` permissions", 375 | color=config.color.error, 376 | ) 377 | await ctx.respond(embed=error_em, ephemeral=True) 378 | elif "play" not in ctx.command.name: 379 | if not player.current: 380 | player: lavalink.DefaultPlayer = None 381 | error_em = discord.Embed( 382 | description=f"{emoji.error} Nothing is being played at the current moment", 383 | color=config.color.error, 384 | ) 385 | await ctx.respond(embed=error_em, ephemeral=True) 386 | elif ctx.author.voice.channel.id != int(player.channel_id): 387 | player: lavalink.DefaultPlayer = None 388 | error_em = discord.Embed( 389 | description=f"{emoji.error} You are not in my voice channel", color=config.color.error 390 | ) 391 | await ctx.respond(embed=error_em, ephemeral=True) 392 | return player 393 | 394 | # Search autocomplete 395 | async def search(self, ctx: discord.AutocompleteContext): 396 | """Searches a track from a given query.""" 397 | player: lavalink.DefaultPlayer = self.client.lavalink.player_manager.create(ctx.interaction.guild_id) 398 | if ctx.value != "": 399 | result = await player.node.get_tracks(f"ytsearch:{ctx.value}") 400 | tracks = [] 401 | for track in result["tracks"]: 402 | track_name = "" 403 | if len(track["info"]["title"]) >= 97: 404 | track_name = f"{track['info']['title'][:97]}..." 405 | else: 406 | track_name = track["info"]["title"] 407 | tracks.append(track_name) 408 | return tracks 409 | else: 410 | return [] 411 | 412 | # Voice state update event 413 | @commands.Cog.listener() 414 | async def on_voice_state_update(self, member: discord.Member, before, after): 415 | """Deafen yourself when joining a voice channel.""" 416 | if member.id == member.guild.me.id and after.channel is None: 417 | if member.guild.voice_client: 418 | await member.guild.voice_client.disconnect(force=True) 419 | if member.id != member.guild.me.id or not after.channel: 420 | return 421 | my_perms = after.channel.permissions_for(member) 422 | if not after.deaf and my_perms.deafen_members: 423 | await member.edit(deafen=True) 424 | 425 | # Play 426 | @slash_command(name="play") 427 | @option("query", description="Enter your track name/link or playlist link", autocomplete=search) 428 | async def play(self, ctx: discord.ApplicationContext, query: str): 429 | """Searches and plays a track from a given query.""" 430 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 431 | if player: 432 | await ctx.defer() 433 | query = query.strip("<>") 434 | embed = discord.Embed(color=config.color.theme) 435 | # Spotify 436 | if "open.spotify.com" in query: 437 | results = await spotify.SpotifySource(query, ctx.author.id).load_item(self.client.lavalink) 438 | if results["loadType"] == lavalink.LoadType.PLAYLIST: 439 | tracks = results["tracks"] 440 | for track in tracks: 441 | player.add(requester=ctx.author.id, track=track) 442 | embed.title = f"{emoji.playlist} Playlist Enqueued" 443 | embed.description = f"**{results['playlistInfo'].name}** with `{len(tracks)}` tracks" 444 | elif results["loadType"] == lavalink.LoadType.TRACK: 445 | track = results["tracks"][0] 446 | player.add(requester=ctx.author.id, track=track) 447 | embed.title = f"{emoji.music} Track Enqueued" 448 | embed.description = f"**[{track.title}]({track.uri})**" 449 | else: 450 | error_em = discord.Embed( 451 | description=f"{emoji.error} No track found from the given query", color=config.color.error 452 | ) 453 | await ctx.respond(embed=error_em, ephemeral=True) 454 | await ctx.respond(embed=embed) 455 | # Others 456 | else: 457 | if not url_rx.match(query): 458 | if "soundcloud.com" in query: 459 | query = f"scsearch:{query}" 460 | elif "music.youtube.com" in query: 461 | query = f"ytmsearch:{query}" 462 | else: 463 | query = f"ytsearch:{query}" 464 | results = await player.node.get_tracks(query) 465 | if not results or not results["tracks"]: 466 | error_em = discord.Embed( 467 | description=f"{emoji.error} No track found from the given query", color=config.color.error 468 | ) 469 | await ctx.respond(embed=error_em, ephemeral=True) 470 | if results["loadType"] == "PLAYLIST_LOADED": 471 | tracks = results["tracks"] 472 | for track in tracks: 473 | player.add(requester=ctx.author.id, track=track) 474 | embed.title = f"{emoji.playlist} Playlist Enqueued" 475 | embed.description = f"**{results['playlistInfo']['name']}** with `{len(tracks)}` tracks" 476 | await ctx.respond(embed=embed) 477 | elif results["tracks"]: 478 | track = results["tracks"][0] 479 | player.add(requester=ctx.author.id, track=track) 480 | embed.title = f"{emoji.music} Track Enqueued" 481 | embed.description = f"**[{track['info']['title']}]({track['info']['uri']})**" 482 | await ctx.respond(embed=embed) 483 | if not player.is_playing: 484 | await player.play() 485 | 486 | # Now playing 487 | @slash_command(name="now-playing") 488 | async def now_playing(self, ctx: discord.ApplicationContext): 489 | """Shows currently playing track.""" 490 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 491 | if player: 492 | requester = ctx.guild.get_member(player.current.requester) 493 | if player.current.stream: 494 | duration = "🔴 LIVE" 495 | elif not player.current.stream: 496 | bar_length = 20 497 | filled_length = int(bar_length * player.position // float(player.current.duration)) 498 | bar = emoji.filled_bar * filled_length + "" + emoji.empty_bar * (bar_length - filled_length) 499 | duration = lavalink.utils.format_time(player.current.duration) 500 | equalizer = store.equalizer(ctx.guild.id) 501 | loop = "" 502 | if player.loop == player.LOOP_NONE: 503 | loop = "Disabled" 504 | elif player.loop == player.LOOP_SINGLE: 505 | loop = "Track" 506 | elif player.loop == player.LOOP_QUEUE: 507 | loop = "Queue" 508 | play_em = discord.Embed( 509 | title=f"{player.current.title}", 510 | url=f"{player.current.uri}", 511 | description=f"{emoji.bullet} **Requested By**: {requester.mention if requester else 'Unknown'}\n" 512 | + f"{emoji.bullet} **Duration**: `{duration}`\n" 513 | + f"{emoji.bullet} **Volume**: `{player.volume}%`\n" 514 | + f"{emoji.bullet} **Loop**: {loop}\n" 515 | + f"{emoji.bullet} **Shuffle**: {'Enabled' if player.shuffle else 'Disabled'}\n" 516 | + f"{emoji.bullet} **Equalizer**: `{equalizer}`\n" 517 | + f"{bar}", 518 | color=config.color.theme, 519 | ) 520 | await ctx.respond(embed=play_em) 521 | 522 | # Equalizer 523 | @slash_command(name="equalizer") 524 | @option( 525 | "equalizer", description="Choose your equalizer", choices=list(equalizer_presets.presets.keys()) + ["Reset"] 526 | ) 527 | async def equalizer(self, ctx: discord.ApplicationContext, equalizer: str): 528 | """Equalizer to change track quality.""" 529 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 530 | if player: 531 | if equalizer == "Reset": 532 | await player.clear_filters() 533 | eq_em = discord.Embed( 534 | title=f"{emoji.equalizer} Reset Equalizer", 535 | description="Reset the equalizer", 536 | color=config.color.theme, 537 | ) 538 | store.equalizer(guild_id=ctx.guild.id, name="None", mode="set") 539 | else: 540 | for eq_name, eq_gains in equalizer_presets.presets.items(): 541 | if eq_name == equalizer: 542 | eq = lavalink.Equalizer() 543 | eq.update(bands=eq_gains) 544 | await player.set_filter(eq) 545 | eq_em = discord.Embed( 546 | title=f"{emoji.equalizer} Equalizer Changed", 547 | description=f"Added `{equalizer}` equalizer", 548 | color=config.color.theme, 549 | ) 550 | store.equalizer(guild_id=ctx.guild.id, name=equalizer, mode="set") 551 | await ctx.respond(embed=eq_em) 552 | 553 | # Stop 554 | @slash_command(name="stop") 555 | async def stop(self, ctx: discord.ApplicationContext): 556 | """Destroys the player.""" 557 | await ctx.defer() 558 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 559 | if player: 560 | try: 561 | player.queue.clear() 562 | await player.stop() 563 | await ctx.guild.voice_client.disconnect(force=True) 564 | except Exception: 565 | pass 566 | stop_embed = discord.Embed(title=f"{emoji.stop} Player Destroyed", color=config.color.theme) 567 | disable = Disable(self.client, ctx.guild.id) 568 | await disable.play_msg() 569 | await ctx.respond(embed=stop_embed) 570 | await disable.queue_msg() 571 | 572 | # Seek 573 | @slash_command(name="seek") 574 | @option("seconds", description="Enter track position in seconds") 575 | async def seek(self, ctx: discord.ApplicationContext, seconds: int): 576 | """Seeks to a given position in a track.""" 577 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 578 | if player: 579 | track_time = player.position + (seconds * 1000) 580 | if lavalink.utils.format_time(player.current.duration) > lavalink.utils.format_time(track_time): 581 | await player.seek(track_time) 582 | seek_em = discord.Embed( 583 | title=f"{emoji.seek} Track Seeked", 584 | description=f"Moved track to `{lavalink.utils.format_time(track_time)}`", 585 | color=config.color.theme, 586 | ) 587 | await ctx.respond(embed=seek_em) 588 | elif lavalink.utils.format_time(player.current.duration) <= lavalink.utils.format_time(track_time): 589 | await self.skip() 590 | 591 | # Skip 592 | @slash_command(name="skip") 593 | async def skip(self, ctx: discord.ApplicationContext): 594 | """Skips the current playing track.""" 595 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 596 | if player: 597 | skip_em = discord.Embed( 598 | title=f"{emoji.skip} Track Skipped", description="Skipped the track", color=config.color.theme 599 | ) 600 | await Disable(self.client, ctx.guild.id).play_msg() 601 | await player.skip() 602 | await ctx.respond(embed=skip_em) 603 | 604 | # Skip to 605 | @slash_command(name="skip-to") 606 | @option("track", description="Enter your track index number to skip") 607 | async def skip_to(self, ctx: discord.ApplicationContext, track: int): 608 | """Skips to a given track in the queue.""" 609 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 610 | if player: 611 | await ctx.defer() 612 | if track < 1 or track > len(player.queue): 613 | error_em = discord.Embed( 614 | description=f"{emoji.error} Track number must be between `1` and `{len(player.queue)}`", 615 | color=config.color.error, 616 | ) 617 | await ctx.respond(embed=error_em, ephemeral=True) 618 | else: 619 | player.queue = player.queue[track - 1 :] 620 | await player.skip() 621 | skip_em = discord.Embed( 622 | title=f"{emoji.skip} Track Skipped", 623 | description=f"Skipped to track `{track}`", 624 | color=config.color.theme, 625 | ) 626 | await ctx.respond(embed=skip_em) 627 | 628 | # Pause 629 | @slash_command(name="pause") 630 | async def pause(self, ctx: discord.ApplicationContext): 631 | """Pauses the player.""" 632 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 633 | if player: 634 | if player.paused: 635 | error_em = discord.Embed( 636 | description=f"{emoji.error} Player is already paused", color=config.color.error 637 | ) 638 | await ctx.respond(embed=error_em, ephemeral=True) 639 | else: 640 | await player.set_pause(True) 641 | pause_em = discord.Embed( 642 | title=f"{emoji.pause} Player Paused", 643 | description="Successfully paused the player", 644 | color=config.color.theme, 645 | ) 646 | await ctx.respond(embed=pause_em) 647 | 648 | # Resume 649 | @slash_command(name="resume") 650 | async def resume(self, ctx: discord.ApplicationContext): 651 | """Resumes the player.""" 652 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 653 | if player: 654 | if player.paused: 655 | await player.set_pause(False) 656 | resume_em = discord.Embed( 657 | title=f"{emoji.play} Player Resumed", 658 | description="Successfully resumed the player", 659 | color=config.color.theme, 660 | ) 661 | await ctx.respond(embed=resume_em) 662 | else: 663 | error_em = discord.Embed(description=f"{emoji.error} Player is not paused", color=config.color.error) 664 | await ctx.respond(embed=error_em, ephemeral=True) 665 | 666 | # Volume 667 | @slash_command(name="volume") 668 | @option("volume", description="Enter your volume amount from 1 - 100") 669 | async def volume(self, ctx: discord.ApplicationContext, volume: int): 670 | """Changes the player's volume 1 - 100.""" 671 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 672 | if player: 673 | volume_condition = [volume < 1, volume > 100] 674 | if any(volume_condition): 675 | error_em = discord.Embed( 676 | description=f"{emoji.error} Volume amount must be between `1` - `100`", color=config.color.error 677 | ) 678 | await ctx.respond(embed=error_em, ephemeral=True) 679 | else: 680 | await player.set_volume(volume) 681 | vol_em = discord.Embed( 682 | title=f"{emoji.volume} Volume Changed", 683 | description=f"Successfully changed volume to `{player.volume}%`", 684 | color=config.color.theme, 685 | ) 686 | await ctx.respond(embed=vol_em) 687 | 688 | # Queue 689 | @slash_command(name="queue") 690 | @option("page", description="Enter queue page number", default=1, required=False) 691 | async def queue(self, ctx: discord.ApplicationContext, page: int = 1): 692 | """Shows the player's queue.""" 693 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 694 | if player: 695 | items_per_page = 5 696 | pages = math.ceil(len(player.queue) / items_per_page) 697 | if not player.queue: 698 | error_em = discord.Embed(description=f"{emoji.error} Queue is empty", color=config.color.error) 699 | await ctx.respond(embed=error_em, ephemeral=True) 700 | elif page > pages or page < 1: 701 | error_em = discord.Embed( 702 | description=f"{emoji.error} Page has to be between `1` to `{pages}`", color=config.color.error 703 | ) 704 | await ctx.respond(embed=error_em, ephemeral=True) 705 | else: 706 | queue_obj = QueueEmbed(self.client, ctx, page) 707 | queue_em = await queue_obj.get_embed() 708 | if pages > 1: 709 | queue_view = QueueView(client=self.client, page=page, timeout=60) 710 | queue_msg = await ctx.respond(embed=queue_em, view=queue_view) 711 | store.queue_msg(ctx.guild.id, queue_msg, "set") 712 | else: 713 | await ctx.respond(embed=queue_em) 714 | 715 | # Shuffle 716 | @slash_command(name="shuffle") 717 | async def shuffle(self, ctx: discord.ApplicationContext): 718 | """Shuffles the player's queue.""" 719 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 720 | if player: 721 | if not player.queue: 722 | error_em = discord.Embed(description=f"{emoji.error} Queue is empty", color=config.color.error) 723 | await ctx.respond(embed=error_em, ephemeral=True) 724 | else: 725 | player.shuffle = not player.shuffle 726 | shuffle_em = discord.Embed( 727 | title=f"{emoji.shuffle if player.shuffle else emoji.shuffle2} Shuffle {'Enabled' if player.shuffle else 'Disabled'}", 728 | description=f"Successfully {'enabled' if player.shuffle else 'disabled'} shuffle", 729 | color=config.color.theme, 730 | ) 731 | await ctx.respond(embed=shuffle_em) 732 | 733 | # Loop 734 | @slash_command(name="loop") 735 | @option("mode", description="Enter loop mode", choices=["OFF", "Queue", "Track"]) 736 | async def loop(self, ctx: discord.ApplicationContext, mode: str): 737 | """Loops the current queue until the command is invoked again or until a new track is enqueued.""" 738 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 739 | if player: 740 | _emoji = "" 741 | if mode == "OFF": 742 | player.set_loop(0) 743 | _emoji = emoji.loop3 744 | elif mode == "Track": 745 | player.set_loop(1) 746 | _emoji = emoji.loop2 747 | elif mode == "Queue": 748 | if not player.queue: 749 | error_em = discord.Embed(description=f"{emoji.error} Queue is empty", color=config.color.error) 750 | await ctx.respond(embed=error_em, ephemeral=True) 751 | return 752 | else: 753 | player.set_loop(2) 754 | _emoji = emoji.loop 755 | loop_em = discord.Embed( 756 | title=f"{_emoji} {mode if mode != 'OFF' else ''} Loop {'Enabled' if mode != 'OFF' else 'Disabled'}", 757 | description=f"Successfully {'enabled' if mode != 'OFF' else 'disabled'} {mode if mode != 'OFF' else ''} Loop", 758 | color=config.color.theme, 759 | ) 760 | await ctx.respond(embed=loop_em) 761 | 762 | # Remove 763 | @slash_command(name="remove") 764 | @option("index", description="Enter your track index number") 765 | async def remove(self, ctx: discord.ApplicationContext, index: int): 766 | """Removes a track from the player's queue with the given index.""" 767 | player: lavalink.DefaultPlayer = await self.ensure_voice(ctx) 768 | if player: 769 | if ctx.author.id == player.queue[index - 1].requester: 770 | if not player.queue: 771 | error_em = discord.Embed(description=f"{emoji.error} Queue is empty", color=config.color.error) 772 | await ctx.respond(embed=error_em, ephemeral=True) 773 | elif index > len(player.queue) or index < 1: 774 | error_em = discord.Embed( 775 | description=f"{emoji.error} Index has to be between `1` to `{len(player.queue)}`", 776 | color=config.color.error, 777 | ) 778 | await ctx.respond(embed=error_em, ephemeral=True) 779 | else: 780 | removed = player.queue.pop(index - 1) 781 | remove_em = discord.Embed( 782 | title=f"{emoji.playlist} Track Removed", 783 | description=f"**{removed.title}**", 784 | color=config.color.theme, 785 | ) 786 | await ctx.respond(embed=remove_em) 787 | else: 788 | error_em = discord.Embed( 789 | description=f"{emoji.error} Only requester can remove from the list", color=config.color.error 790 | ) 791 | await ctx.respond(embed=error_em, ephemeral=True) 792 | 793 | 794 | def setup(client: discord.Bot): 795 | client.add_cog(Music(client)) 796 | -------------------------------------------------------------------------------- /cogs/settings.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from db.funcs.guild import ( 3 | fetch_guild_settings, 4 | remove_guild, 5 | set_autorole, 6 | set_mod_cmd_log_channel, 7 | set_mod_log_channel, 8 | set_msg_log_channel, 9 | set_ticket_cmds, 10 | set_ticket_log_channel, 11 | ) 12 | from discord.commands import SlashCommandGroup, option, slash_command 13 | from discord.ext import commands 14 | from utils import config 15 | from utils.emoji import emoji 16 | 17 | 18 | class Settings(commands.Cog): 19 | def __init__(self, client: discord.Bot): 20 | self.client = client 21 | 22 | # Settings 23 | @slash_command(name="settings") 24 | @discord.default_permissions(manage_channels=True) 25 | async def settings(self, ctx: discord.ApplicationContext): 26 | """Shows server settings.""" 27 | 28 | # Fetch channel mention util func 29 | async def mention_ch(channel_id): 30 | return f"<#{channel_id}>" if channel_id else emoji.off 31 | 32 | guild_settings = await fetch_guild_settings(ctx.guild.id) 33 | 34 | mod_log_channel = await mention_ch(guild_settings.mod_log_channel_id) 35 | mod_log_cmd_channel = await mention_ch(guild_settings.mod_cmd_log_channel_id) 36 | msg_log_channel = await mention_ch(guild_settings.msg_log_channel_id) 37 | ticket = emoji.on if guild_settings.ticket_cmds else emoji.off 38 | ticket_log_channel = await mention_ch(guild_settings.ticket_log_channel_id) 39 | 40 | role_id = guild_settings.autorole 41 | autorole = ctx.guild.get_role(role_id).mention if (role_id and ctx.guild.get_role(role_id)) else emoji.off 42 | if role_id and not ctx.guild.get_role(role_id): 43 | await set_autorole(ctx.guild.id, None) # Reset autorole if role doesn't exist 44 | 45 | set_em = discord.Embed( 46 | title=f"{emoji.settings} {ctx.guild.name}'s Settings", 47 | description=f"{emoji.bullet} **Mod Log Channel**: {mod_log_channel}\n" 48 | + f"{emoji.bullet} **Mod Command Log Channel**: {mod_log_cmd_channel}\n" 49 | + f"{emoji.bullet} **Message Log Channel**: {msg_log_channel}\n" 50 | + f"{emoji.bullet} **Ticket Commands**: {ticket}\n" 51 | + f"{emoji.bullet} **Ticket Log Channel**: {ticket_log_channel}\n" 52 | + f"{emoji.bullet} **Autorole**: {autorole}", 53 | color=config.color.theme, 54 | ) 55 | await ctx.respond(embed=set_em) 56 | 57 | # Settings slash cmd group 58 | setting = SlashCommandGroup( 59 | name="setting", 60 | description="Server settings commands.", 61 | default_member_permissions=discord.Permissions(manage_channels=True, moderate_members=True), 62 | ) 63 | 64 | # Reset 65 | @setting.command(name="reset") 66 | @option( 67 | "setting", 68 | description="Setting to reset", 69 | choices=["All", "Mod Log", "Mod Command Log", "Message Log", "Ticket Commands", "Ticket Log", "Auto Role"], 70 | ) 71 | async def reset_settings(self, ctx: discord.ApplicationContext, setting: str): 72 | """Resets server settings.""" 73 | if setting.lower() == "all": 74 | await remove_guild(ctx.guild.id) 75 | else: 76 | match setting.lower(): 77 | case "mod log": 78 | await set_mod_cmd_log_channel(ctx.guild.id, None) 79 | case "mod command log": 80 | await set_mod_cmd_log_channel(ctx.guild.id, None) 81 | case "message log": 82 | await set_msg_log_channel(ctx.guild.id, None) 83 | case "ticket commands": 84 | await set_ticket_cmds(ctx.guild.id, False) 85 | case "ticket log": 86 | await set_ticket_log_channel(ctx.guild.id, None) 87 | case "auto role": 88 | await set_autorole(ctx.guild.id, None) 89 | reset_em = discord.Embed( 90 | title=f"{emoji.settings} Reset Settings", 91 | description=f"Successfully reset the {setting.lower()} settings.", 92 | color=config.color.theme, 93 | ) 94 | await ctx.respond(embed=reset_em) 95 | 96 | # Set mod log 97 | @setting.command(name="mod-log") 98 | @option("channel", description="Mention the mod log channel") 99 | async def set_mod_log(self, ctx: discord.ApplicationContext, channel: discord.TextChannel): 100 | """Sets mod log channel.""" 101 | await set_mod_log_channel(ctx.guild.id, channel.id) 102 | logging_em = discord.Embed( 103 | title=f"{emoji.settings} Mod Log Settings", 104 | description=f"Successfully set mod log channel to {channel.mention}.", 105 | color=config.color.theme, 106 | ) 107 | await ctx.respond(embed=logging_em) 108 | 109 | # Set mod cmd log 110 | @setting.command(name="mod-command-log") 111 | @option("channel", description="Mention the mod command log channel") 112 | async def set_mod_cmd_log(self, ctx: discord.ApplicationContext, channel: discord.TextChannel): 113 | """Sets mod command log channel.""" 114 | await set_mod_cmd_log_channel(ctx.guild.id, channel.id) 115 | logging_em = discord.Embed( 116 | title=f"{emoji.settings} Mod Command Log Settings", 117 | description=f"Successfully set mod command log channel to {channel.mention}.", 118 | color=config.color.theme, 119 | ) 120 | await ctx.respond(embed=logging_em) 121 | 122 | # Set message log 123 | @setting.command(name="message-log") 124 | @option("channel", description="Mention the message log channel") 125 | async def set_msg_log(self, ctx: discord.ApplicationContext, channel: discord.TextChannel): 126 | """Sets message log channel.""" 127 | await set_msg_log_channel(ctx.guild.id, channel.id) 128 | logging_em = discord.Embed( 129 | title=f"{emoji.settings} Message Log Settings", 130 | description=f"Successfully set message log channel to {channel.mention}.", 131 | color=config.color.theme, 132 | ) 133 | await ctx.respond(embed=logging_em) 134 | 135 | # Set ticket cmds 136 | @setting.command(name="ticket-commands") 137 | @option("status", description="Enable or disable ticket commands", choices=["Enable", "Disable"]) 138 | async def set_ticket_cmds(self, ctx: discord.ApplicationContext, status: str): 139 | """Enables or disables ticket commands.""" 140 | match status.lower(): 141 | case "enable": 142 | await set_ticket_cmds(ctx.guild.id, True) 143 | case "disable": 144 | await set_ticket_cmds(ctx.guild.id, False) 145 | ticket_cmds_em = discord.Embed( 146 | title=f"{emoji.settings} Ticket Commands Settings", 147 | description=f"Successfully {status.lower()}d ticket commands.", 148 | color=config.color.theme, 149 | ) 150 | await ctx.respond(embed=ticket_cmds_em) 151 | 152 | # Set ticket log 153 | @setting.command(name="ticket-log") 154 | @option("channel", description="Mention the ticket log channel") 155 | async def set_ticket_log(self, ctx: discord.ApplicationContext, channel: discord.TextChannel): 156 | """Sets ticket log channel.""" 157 | await set_ticket_log_channel(ctx.guild.id, channel.id) 158 | logging_em = discord.Embed( 159 | title=f"{emoji.settings} Ticket Log Settings", 160 | description=f"Successfully set ticket log channel to {channel.mention}.", 161 | color=config.color.theme, 162 | ) 163 | await ctx.respond(embed=logging_em) 164 | 165 | # Set autorole 166 | @setting.command(name="auto-role") 167 | @option("role", description="Mention the autorole") 168 | async def set_auto_role(self, ctx: discord.ApplicationContext, role: discord.Role): 169 | """Sets autorole. The bot will assign this role to new members.""" 170 | if role >= ctx.guild.me.top_role: 171 | error_em = discord.Embed( 172 | description=f"{emoji.error} I can't assign roles higher than my top role.", color=config.color.error 173 | ) 174 | await ctx.respond(embed=error_em, ephemeral=True) 175 | elif role.name == "@everyone": 176 | error_em = discord.Embed( 177 | description=f"{emoji.error} I can't assign the @everyone role.", color=config.color.error 178 | ) 179 | await ctx.respond(embed=error_em, ephemeral=True) 180 | else: 181 | await set_autorole(ctx.guild.id, role.id) 182 | autorole_em = discord.Embed( 183 | title=f"{emoji.settings} Auto Role Settings", 184 | description=f"Successfully set autorole to {role.mention}.", 185 | color=config.color.theme, 186 | ) 187 | await ctx.respond(embed=autorole_em) 188 | 189 | 190 | def setup(client: discord.Bot): 191 | client.add_cog(Settings(client)) 192 | -------------------------------------------------------------------------------- /cogs/tickets.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | import io 4 | from db.funcs.guild import fetch_guild_settings 5 | from discord.commands import SlashCommandGroup, option 6 | from discord.ext import commands 7 | from utils import config 8 | from utils.emoji import emoji 9 | 10 | 11 | class TicketTranscript: 12 | def __init__(self, channel: discord.TextChannel): 13 | self.channel = channel 14 | 15 | async def create(self): 16 | messages = await self.channel.history(limit=500).flatten() 17 | with io.StringIO() as file: 18 | for message in reversed(messages): 19 | if message.content: 20 | file.write(f"{message.author}: {message.content}\n") 21 | else: 22 | file.write(f"{message.author}: Embed/Attachment\n") 23 | file.seek(0) 24 | return discord.File(file, filename=f"ticket_{self.channel.id}.txt") 25 | 26 | 27 | class TicketView(discord.ui.View): 28 | def __init__(self): 29 | super().__init__(timeout=None) 30 | 31 | # Interaction check 32 | async def interaction_check(self, interaction: discord.Interaction): 33 | if interaction.user.guild_permissions.manage_channels: 34 | return True 35 | else: 36 | ticket_check_em = discord.Embed( 37 | description=f"{emoji.error} You don't have `Manage Channels` permission to use this command.", 38 | color=config.color.error, 39 | ) 40 | await interaction.response.send_message(embed=ticket_check_em, ephemeral=True) 41 | return False 42 | 43 | # Ticket close button 44 | @discord.ui.button(label="Close", emoji=emoji.lock, style=discord.ButtonStyle.grey, custom_id="close_ticket") 45 | async def close_ticket(self, button: discord.ui.Button, interaction: discord.Interaction): 46 | self.disable_all_items() 47 | await interaction.response.edit_message(view=self) 48 | close_em = discord.Embed( 49 | title=f"{emoji.ticket2} Closing Ticket", 50 | description="Closing ticket in 5 seconds.\n" 51 | + f"{emoji.bullet} **Author**: <@{interaction.channel.name.split('-')[1]}>\n" 52 | + f"{emoji.bullet} **Closed By**: {interaction.user.mention}", 53 | color=config.color.theme, 54 | ) 55 | await interaction.followup.send(embed=close_em) 56 | await asyncio.sleep(5) 57 | await interaction.channel.delete() 58 | channel_id = (await fetch_guild_settings(interaction.guild.id)).ticket_log_channel_id 59 | if channel_id is not None: 60 | logging_ch = await interaction.channel.guild.fetch_channel(channel_id) 61 | close_log_em = discord.Embed( 62 | title=f"{emoji.ticket2} Ticket Closed", 63 | description=f"{emoji.bullet} **Author**: <@{interaction.channel.name.split('-')[1]}>\n" 64 | + f"{emoji.bullet} **Closed By**: {interaction.user.mention}", 65 | color=config.color.theme, 66 | ) 67 | await logging_ch.send(embed=close_log_em) 68 | 69 | # Ticket summary 70 | @discord.ui.button( 71 | label="Transcript", emoji=emoji.embed, style=discord.ButtonStyle.grey, custom_id="ticket_summary" 72 | ) 73 | async def ticket_summary(self, button: discord.ui.Button, interaction: discord.Interaction): 74 | button.disabled = True 75 | await interaction.response.edit_message(view=self) 76 | await interaction.channel.trigger_typing() 77 | file = await TicketTranscript(interaction.channel).create() 78 | await interaction.followup.send( 79 | embed=discord.Embed(description=f"**Requested by**: {interaction.user.mention}", color=config.color.theme), 80 | file=file, 81 | ) 82 | await asyncio.sleep(2) 83 | button.disabled = False 84 | await interaction.message.edit(view=self) 85 | 86 | 87 | class Tickets(commands.Cog): 88 | def __init__(self, client): 89 | self.client = client 90 | 91 | # Ticket slash cmd group 92 | ticket = SlashCommandGroup(name="ticket", description="Ticket related commands.") 93 | 94 | # Ticket create 95 | @ticket.command(name="create") 96 | @option("reason", description="Enter your reason for creating the ticket", required=False) 97 | async def create_ticket(self, ctx: discord.ApplicationContext, reason: str = "No reason provided"): 98 | """Creates a ticket.""" 99 | ticket_status = (await fetch_guild_settings(ctx.guild.id)).ticket_cmds 100 | if not ticket_status: 101 | error_em = discord.Embed( 102 | description=f"{emoji.error} Ticket commands are disabled", color=config.color.error 103 | ) 104 | await ctx.respond(embed=error_em, ephemeral=True) 105 | else: 106 | await ctx.defer() 107 | category = discord.utils.get(ctx.guild.categories, name="Tickets") 108 | if category is None: 109 | category = await ctx.guild.create_category("Tickets") 110 | await category.set_permissions(ctx.guild.default_role, view_channel=False) 111 | await category.set_permissions(ctx.guild.me, view_channel=True) 112 | create_ch = await category.create_text_channel(f"ticket-{ctx.author.id}") 113 | await create_ch.set_permissions( 114 | ctx.author, 115 | view_channel=True, 116 | send_messages=True, 117 | read_messages=True, 118 | add_reactions=True, 119 | embed_links=True, 120 | attach_files=True, 121 | read_message_history=True, 122 | external_emojis=True, 123 | ) 124 | await create_ch.set_permissions( 125 | ctx.guild.me, 126 | view_channel=True, 127 | send_messages=True, 128 | read_messages=True, 129 | add_reactions=True, 130 | embed_links=True, 131 | attach_files=True, 132 | read_message_history=True, 133 | external_emojis=True, 134 | ) 135 | await create_ch.set_permissions( 136 | ctx.guild.default_role, view_channel=False, send_messages=False, read_messages=False 137 | ) 138 | 139 | create_em = discord.Embed( 140 | title=f"{emoji.ticket} Ticket Created", 141 | description="Thank you for creating the ticket. Your problem will be solved soon! Stay tuned!\n" 142 | + f"{emoji.bullet} **Author**: {ctx.author.mention}\n" 143 | + f"{emoji.bullet} **Reason**: {reason}", 144 | color=config.color.theme, 145 | ) 146 | await create_ch.send(ctx.author.mention, embed=create_em, view=TicketView()) 147 | create_done_em = discord.Embed( 148 | title=f"{emoji.ticket} Ticket Created", 149 | description=f"Successfully created {create_ch.mention}.", 150 | color=config.color.theme, 151 | ) 152 | await ctx.respond(embed=create_done_em) 153 | 154 | log_ch_id = (await fetch_guild_settings(ctx.guild.id)).ticket_log_channel_id 155 | if log_ch_id is not None: 156 | logging_ch = await self.client.fetch_channel(log_ch_id) 157 | create_log_em = discord.Embed( 158 | title=f"{emoji.ticket} Ticket Created", 159 | description=f"{emoji.bullet} **Author**: {ctx.author.mention}\n" 160 | + f"{emoji.bullet} **Reason**: {reason}", 161 | color=config.color.theme, 162 | ) 163 | await logging_ch.send(embed=create_log_em) 164 | 165 | # Ticket close 166 | @ticket.command(name="close") 167 | async def close_ticket(self, ctx: discord.ApplicationContext): 168 | """Closes a created ticket.""" 169 | ticket_status = (await fetch_guild_settings(ctx.guild.id)).ticket_cmds 170 | if not ticket_status: 171 | error_em = discord.Embed( 172 | description=f"{emoji.error} Ticket commands are disabled", color=config.color.error 173 | ) 174 | await ctx.respond(embed=error_em, ephemeral=True) 175 | else: 176 | if (ctx.channel.name == f"ticket-{ctx.author.id}") or ( 177 | ctx.channel.name.startswith("ticket-") and ctx.author.guild_permissions.manage_channels 178 | ): 179 | close_em = discord.Embed( 180 | title=f"{emoji.ticket2} Closing Ticket", 181 | description="Closing ticket in 5 seconds.\n" + f"{emoji.bullet} **Author**: {ctx.author.mention}", 182 | color=config.color.theme, 183 | ) 184 | await ctx.respond(embed=close_em) 185 | await asyncio.sleep(5) 186 | await ctx.channel.delete() 187 | log_ch_id = (await fetch_guild_settings(ctx.guild.id)).ticket_log_channel_id 188 | if log_ch_id is not None: 189 | logging_ch = await self.client.fetch_channel(log_ch_id) 190 | close_log_em = discord.Embed( 191 | title=f"{emoji.ticket2} Ticket Closed", 192 | description=f"{emoji.bullet} **Author**: <@{ctx.channel.name.split('-')[1]}>\n" 193 | + f"{emoji.bullet} **Closed By**: {ctx.author.mention}", 194 | color=config.color.theme, 195 | ) 196 | await logging_ch.send(embed=close_log_em) 197 | else: 198 | error_em = discord.Embed( 199 | description=f"{emoji.error} This is not a ticket channel", color=config.color.error 200 | ) 201 | await ctx.respond(embed=error_em, ephemeral=True) 202 | 203 | # Ticket transcript 204 | @ticket.command(name="transcript") 205 | @commands.cooldown(1, 10, commands.BucketType.user) 206 | async def transcript_ticket(self, ctx: discord.ApplicationContext): 207 | """Transcript an opened ticket.""" 208 | if (ctx.channel.name == f"ticket-{ctx.author.id}") or ( 209 | ctx.channel.name.startswith("ticket-") and ctx.author.guild_permissions.manage_channels 210 | ): 211 | await ctx.defer() 212 | file = await TicketTranscript(ctx.channel).create() 213 | await ctx.respond(file=file) 214 | else: 215 | error_em = discord.Embed( 216 | description=f"{emoji.error} This is not a ticket channel", color=config.color.error 217 | ) 218 | await ctx.respond(embed=error_em, ephemeral=True) 219 | 220 | # Add ticket view on restart 221 | @commands.Cog.listener() 222 | async def on_ready(self): 223 | self.client.add_view(TicketView()) 224 | 225 | 226 | def setup(client: discord.Bot): 227 | client.add_cog(Tickets(client)) 228 | -------------------------------------------------------------------------------- /db/__init__.py: -------------------------------------------------------------------------------- 1 | from rich.progress import Progress, SpinnerColumn 2 | from tortoise import Tortoise 3 | from utils.config import db_url 4 | 5 | TORTOISE_ORM = { 6 | "connections": { 7 | "default": db_url, 8 | }, 9 | "apps": {"models": {"models": ["db.schema", "aerich.models"], "default_connection": "default"}}, 10 | } 11 | 12 | 13 | class DB: 14 | """Database class to handle Tortoise ORM initialization and connection management.""" 15 | 16 | async def init(self): 17 | """Initialize the database connection and generate schemas.""" 18 | db_prog = Progress( 19 | SpinnerColumn(style="yellow", finished_text="[green bold]✓[/]"), 20 | "[progress.description]{task.description}", 21 | ) 22 | 23 | with db_prog as prog: 24 | db_task = prog.add_task("Initializing Database", total=1) 25 | await Tortoise.init(TORTOISE_ORM) 26 | await Tortoise.generate_schemas() 27 | prog.update(db_task, description="[green]Initialized Database[/]", completed=1) 28 | 29 | async def close(self): 30 | """Close the database connection.""" 31 | await Tortoise.close_connections() 32 | -------------------------------------------------------------------------------- /db/funcs/dev.py: -------------------------------------------------------------------------------- 1 | from ..schema import DevTable 2 | 3 | 4 | async def fetch_dev_ids() -> list[int]: 5 | """Fetches all developer user IDs from the database.""" 6 | devs = await DevTable.all().values_list("user_id", flat=True) 7 | return list(devs) 8 | 9 | 10 | async def add_dev(user_id: int) -> None: 11 | """ 12 | Adds a developer user ID to the database. 13 | 14 | Parameters: 15 | user_id (int): The user ID to perform action on. 16 | """ 17 | await DevTable.get_or_create(user_id=user_id) 18 | 19 | 20 | async def remove_dev(user_id: int) -> None: 21 | """ 22 | Removes a developer user ID from the database. 23 | 24 | Parameters: 25 | user_id (int): The user ID to perform action on. 26 | """ 27 | dev = await DevTable.filter(user_id=user_id).first() 28 | if dev: 29 | await dev.delete() 30 | else: 31 | raise ValueError(f"User ID {user_id} is not a developer.") 32 | -------------------------------------------------------------------------------- /db/funcs/guild.py: -------------------------------------------------------------------------------- 1 | from ..schema import GuildTable 2 | 3 | 4 | async def fetch_guild_ids() -> list[int]: 5 | """Fetches all guild IDs from the database.""" 6 | guilds = await GuildTable.all().values_list("guild_id", flat=True) 7 | return list(guilds) 8 | 9 | 10 | async def add_guild(guild_id: int) -> GuildTable: 11 | """ 12 | Adds a guild to the database. 13 | 14 | Parameters: 15 | guild_id (int): The guild ID to perform action on. 16 | """ 17 | return (await GuildTable.get_or_create(guild_id=guild_id))[0] 18 | 19 | 20 | async def remove_guild(guild_id: int) -> None: 21 | """ 22 | Removes a guild from the database. 23 | 24 | Parameters: 25 | guild_id (int): The guild ID to perform action on. 26 | """ 27 | guild = await GuildTable.filter(guild_id=guild_id).first() 28 | if guild: 29 | await guild.delete() 30 | else: 31 | pass 32 | 33 | 34 | async def fetch_guild_settings(guild_id: int) -> GuildTable: 35 | """ 36 | Fetches settings for a specific guild. 37 | 38 | Parameters: 39 | guild_id (int): The guild ID to fetch settings for. 40 | """ 41 | guild = await GuildTable.filter(guild_id=guild_id).first() 42 | if not guild: 43 | guild = guild = await add_guild(guild_id) 44 | return guild 45 | 46 | 47 | async def set_mod_log_channel(guild_id: int, channel_id: int) -> None: 48 | """ 49 | Sets the mod log channel for a guild. 50 | 51 | Parameters: 52 | guild_id (int): The guild ID to perform action on. 53 | channel_id (int): The channel ID to set as the mod log channel. 54 | """ 55 | guild = await GuildTable.filter(guild_id=guild_id).first() 56 | if not guild: 57 | guild = await add_guild(guild_id) 58 | guild.mod_log_channel_id = channel_id 59 | await guild.save() 60 | 61 | 62 | async def set_mod_cmd_log_channel(guild_id: int, channel_id: int) -> None: 63 | """ 64 | Sets the mod command log channel for a guild. 65 | 66 | Parameters: 67 | guild_id (int): The guild ID to perform action on. 68 | channel_id (int): The channel ID to set as the mod command log channel. 69 | """ 70 | guild = await GuildTable.filter(guild_id=guild_id).first() 71 | if not guild: 72 | guild = await add_guild(guild_id) 73 | guild.mod_cmd_log_channel_id = channel_id 74 | await guild.save() 75 | 76 | 77 | async def set_msg_log_channel(guild_id: int, channel_id: int) -> None: 78 | """ 79 | Sets the message log channel for a guild. 80 | 81 | Parameters: 82 | guild_id (int): The guild ID to perform action on. 83 | channel_id (int): The channel ID to set as the message log channel. 84 | """ 85 | guild = await GuildTable.filter(guild_id=guild_id).first() 86 | if not guild: 87 | guild = await add_guild(guild_id) 88 | guild.msg_log_channel_id = channel_id 89 | await guild.save() 90 | 91 | 92 | async def set_ticket_cmds(guild_id: int, enabled: bool) -> None: 93 | """ 94 | Enables or disables ticket commands for a guild. 95 | 96 | Parameters: 97 | guild_id (int): The guild ID to perform action on. 98 | enabled (bool): Whether to enable or disable ticket commands. 99 | """ 100 | guild = await GuildTable.filter(guild_id=guild_id).first() 101 | if not guild: 102 | guild = await add_guild(guild_id) 103 | guild.ticket = enabled 104 | await guild.save() 105 | 106 | 107 | async def set_ticket_log_channel(guild_id: int, channel_id: int) -> None: 108 | """ 109 | Sets the ticket log channel for a guild. 110 | 111 | Parameters: 112 | guild_id (int): The guild ID to perform action on. 113 | channel_id (int): The channel ID to set as the ticket log channel. 114 | """ 115 | guild = await GuildTable.filter(guild_id=guild_id).first() 116 | if not guild: 117 | guild = await add_guild(guild_id) 118 | guild.ticket_log_channel_id = channel_id 119 | await guild.save() 120 | 121 | 122 | async def set_autorole(guild_id: int, role_id: int) -> None: 123 | """ 124 | Sets the autorole for a guild. 125 | 126 | Parameters: 127 | guild_id (int): The guild ID to perform action on. 128 | role_id (int): The role ID to set as the autorole. 129 | """ 130 | guild = await GuildTable.filter(guild_id=guild_id).first() 131 | if not guild: 132 | guild = await add_guild(guild_id) 133 | guild.autorole = role_id 134 | await guild.save() 135 | -------------------------------------------------------------------------------- /db/schema.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields 2 | from tortoise.models import Model 3 | 4 | 5 | class DevTable(Model): 6 | id = fields.IntField(primary_key=True) 7 | user_id = fields.BigIntField(unique=True) 8 | joined_at = fields.DatetimeField(auto_now_add=True) 9 | 10 | class Meta: 11 | table = "dev" 12 | 13 | 14 | class GuildTable(Model): 15 | id = fields.IntField(primary_key=True) 16 | guild_id = fields.BigIntField(unique=True) 17 | mod_log_channel_id = fields.BigIntField(null=True) 18 | mod_cmd_log_channel_id = fields.BigIntField(null=True) 19 | msg_log_channel_id = fields.BigIntField(null=True) 20 | ticket_cmds = fields.BooleanField(default=True) 21 | ticket_log_channel_id = fields.BigIntField(null=True) 22 | autorole = fields.BigIntField(null=True) 23 | 24 | class Meta: 25 | table = "guild" 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: square 2 | 3 | services: 4 | bot: 5 | build: . 6 | container_name: "square" 7 | restart: unless-stopped 8 | -------------------------------------------------------------------------------- /example.config.toml: -------------------------------------------------------------------------------- 1 | owner-id = 0 2 | owner-guild-ids = [] 3 | system-channel-id = 0 4 | support-server-url = "https://discord.gg" # Replace with your support server URL. 5 | emoji = "default" 6 | 7 | bot-token = "" 8 | database-url = "asyncpg://..." # If it starts with "postgresql://", replace it with "asyncpg://". 9 | 10 | [colors] 11 | theme = "#1FCEEC" 12 | error = "#E74C3C" 13 | 14 | [lavalink] 15 | host = "" 16 | port = 0 17 | password = "" 18 | secure = false 19 | 20 | [spotify] 21 | client_id = "" 22 | client_secret = "" 23 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import os 3 | from db import DB 4 | from pyfiglet import Figlet 5 | from rich import print 6 | from rich.progress import Progress, SpinnerColumn 7 | from utils import config 8 | 9 | # Discord vars 10 | status = discord.Status.online 11 | activity = discord.Activity(type=discord.ActivityType.listening, name="Discord") 12 | intents = discord.Intents.all() 13 | client = discord.Bot(status=status, activity=activity, intents=intents, help_command=None) 14 | 15 | # Startup printing 16 | figlted_txt = Figlet(font="standard", justify="center").renderText("Discord Bot") 17 | print(f"[cyan]{figlted_txt}[/]") 18 | 19 | 20 | # On ready event 21 | @client.event 22 | async def on_ready(): 23 | print(f"[green][bold]✓[/] Logged in as [cyan]{client.user}[/] [ID: {client.user.id}][/]") 24 | print(f"[green][bold]✓[/] Connected to {len(client.guilds)} guild{'' if len(client.guilds) <= 1 else 's'}[/]") 25 | 26 | 27 | # Loading all files 28 | def load_cogs(): 29 | cogs_prog = Progress( 30 | SpinnerColumn(style="yellow", finished_text="[green bold]✓[/]"), 31 | "[progress.description]{task.description} [progress.percentage]{task.percentage:>3.1f}%", 32 | ) 33 | with cogs_prog as prog: 34 | file_count = len([file for file in os.listdir("./cogs") if file.endswith(".py")]) 35 | task = cogs_prog.add_task("Loading Cogs", total=file_count) 36 | for filename in os.listdir("./cogs"): 37 | if filename.endswith(".py"): 38 | prog.update(task, advance=1) 39 | client.load_extension(f"cogs.{filename[:-3]}") 40 | prog.update(task, description="[green]Loaded Cogs[/]", completed=file_count) 41 | 42 | 43 | # Shutdown 44 | async def shutdown(): 45 | """Shutdown the bot gracefully.""" 46 | shutdown_prog = Progress( 47 | SpinnerColumn(style="yellow", finished_text="[yellow bold]✓[/]"), 48 | "[progress.description]{task.description}", 49 | ) 50 | 51 | with shutdown_prog as prog: 52 | task = prog.add_task("Shutting down", total=2) 53 | await DB().close() 54 | prog.advance(task, advance=1) 55 | if not client.is_closed(): 56 | await client.close() 57 | prog.advance(task, advance=1) 58 | prog.update(task, description="[yellow]Bot has been shut down[/]", completed=2) 59 | 60 | 61 | # Main func to run the bot 62 | async def main(): 63 | try: 64 | await DB().init() 65 | load_cogs() 66 | await client.start(config.bot_token) 67 | finally: 68 | await shutdown() 69 | 70 | 71 | # Execute the main func 72 | try: 73 | client.loop.run_until_complete(main()) 74 | except Exception as e: 75 | print(f"[red][bold]✗[/] Unable to login due to {e}[/]") 76 | -------------------------------------------------------------------------------- /music/client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | import lavalink 4 | from utils import config 5 | 6 | 7 | class LavalinkVoiceClient(discord.VoiceProtocol): 8 | def __init__(self, client: discord.Bot, channel: discord.abc.Connectable): 9 | self.client = client 10 | self.channel = channel 11 | self.connect_event = asyncio.Event() 12 | 13 | async def on_voice_server_update(self, data): 14 | lavalink_data = {"t": "VOICE_SERVER_UPDATE", "d": data} 15 | await self.lavalink.voice_update_handler(lavalink_data) 16 | 17 | async def on_voice_state_update(self, data): 18 | lavalink_data = {"t": "VOICE_STATE_UPDATE", "d": data} 19 | await self.lavalink.voice_update_handler(lavalink_data) 20 | 21 | # Connect 22 | async def connect(self, *, timeout: float, reconnect: bool) -> None: 23 | await self.channel.guild.change_voice_state(channel=self.channel) 24 | try: 25 | self.lavalink: lavalink.Client = self.client.lavalink 26 | except AttributeError: 27 | self.client.lavalink = self.lavalink = lavalink.Client(self.client.user.id) 28 | self.client.lavalink.add_node( 29 | host=config.lavalink["host"], 30 | port=config.lavalink["port"], 31 | password=config.lavalink["password"], 32 | name="default-node", 33 | ssl=config.lavalink["secure"], 34 | ) 35 | 36 | # Disconnect 37 | async def disconnect(self, *, force: bool) -> None: 38 | await self.channel.guild.change_voice_state(channel=None) 39 | player: lavalink.DefaultPlayer = self.lavalink.player_manager.get(self.channel.guild.id) 40 | if player: 41 | player.channel_id = False 42 | await player.stop() 43 | self.cleanup() 44 | -------------------------------------------------------------------------------- /music/equalizer_presets.py: -------------------------------------------------------------------------------- 1 | presets = { 2 | "Bass Boost": [(0, 0.10), (1, 0.15), (2, 0.20), (3, 0.25), (4, 0.35), (5, 0.45), (6, 0.50), (7, 0.55)], 3 | "Jazz": [(0, 0.18), (1, 0.16), (2, 0.12), (3, 0.09), (4, 0.08), (5, 0.08), (6, 0.12), (7, 0.18)], 4 | "Pop": [(0, 0.18), (1, 0.14), (2, 0.10), (3, 0.08), (4, 0.06), (5, 0.06), (6, 0.10), (7, 0.18)], 5 | "Treble": [(0, 0.55), (1, 0.50), (2, 0.40), (3, 0.30), (4, 0.20), (5, 0.10), (6, 0.05), (7, 0.02)], 6 | "Nightcore": [(0, 0.30), (1, 0.30), (2, 0.30), (3, 0.30), (4, 0.30), (5, 0.30), (6, 0.30), (7, 0.30)], 7 | "Super Bass": [(0, 0.20), (1, 0.30), (2, 0.40), (3, 0.50), (4, 0.60), (5, 0.70), (6, 0.80), (7, 0.90)], 8 | } 9 | -------------------------------------------------------------------------------- /music/sources/spotify.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import lavalink 3 | import spotipy 4 | from spotipy.oauth2 import SpotifyClientCredentials 5 | from utils import config 6 | 7 | sp = spotipy.Spotify( 8 | auth_manager=SpotifyClientCredentials( 9 | client_id=config.spotify["client_id"], 10 | client_secret=config.spotify["client_secret"], 11 | cache_handler=spotipy.cache_handler.MemoryCacheHandler(), 12 | ) 13 | ) 14 | 15 | 16 | class SpotifyAudioTrack(lavalink.DeferredAudioTrack): 17 | async def load(self, client: discord.Bot): 18 | result: lavalink.LoadResult = await client.get_tracks(f"ytsearch:{self.title} {self.author}") 19 | if result.load_type != lavalink.LoadType.SEARCH or not result.tracks: 20 | raise lavalink.LoadError 21 | first_track = result.tracks[0] 22 | base64 = first_track.track 23 | self.track = base64 24 | return base64 25 | 26 | 27 | class SpotifySource(lavalink.Source): 28 | def __init__(self, url: str, requester: int): 29 | self.url = url 30 | self.requester = requester 31 | super().__init__(name="spotify") 32 | 33 | # Spotify source 34 | async def get(self): 35 | return {"playlist": sp.playlist, "album": sp.album, "track": sp.track}.get( 36 | self.url.split("/")[-2], lambda _: None 37 | )(self.url) 38 | 39 | # Load playlist 40 | async def _load_pl(self) -> tuple[list[SpotifyAudioTrack], lavalink.PlaylistInfo]: 41 | pl = await self.get() 42 | tracks = [] 43 | for track in pl["tracks"]["items"]: 44 | tracks.append( 45 | SpotifyAudioTrack( 46 | { 47 | "identifier": track["track"]["id"], 48 | "isSeekable": True, 49 | "author": ", ".join([artist["name"] for artist in track["track"]["artists"]]), 50 | "length": track["track"]["duration_ms"], 51 | "isStream": False, 52 | "title": track["track"]["name"], 53 | "uri": track["track"]["external_urls"]["spotify"], 54 | "sourceName": "spotify", 55 | }, 56 | requester=self.requester, 57 | cover=track["track"]["album"]["images"][0]["url"], 58 | ) 59 | ) 60 | pl_info = lavalink.PlaylistInfo(name=pl["name"]) 61 | return tracks, pl_info 62 | 63 | # Load album 64 | async def _load_al(self) -> tuple[list[SpotifyAudioTrack], lavalink.PlaylistInfo]: 65 | al = await self.get() 66 | tracks = [] 67 | for track in al["tracks"]["items"]: 68 | tracks.append( 69 | SpotifyAudioTrack( 70 | { 71 | "identifier": track["id"], 72 | "isSeekable": True, 73 | "author": ", ".join([artist["name"] for artist in track["artists"]]), 74 | "length": track["duration_ms"], 75 | "isStream": False, 76 | "title": track["name"], 77 | "uri": track["external_urls"]["spotify"], 78 | "sourceName": "spotify", 79 | }, 80 | requester=self.requester, 81 | cover=track["album"]["images"][0]["url"], 82 | ) 83 | ) 84 | pl_info = lavalink.PlaylistInfo(name=al["name"]) 85 | return tracks, pl_info 86 | 87 | # Load track 88 | async def _load_track(self) -> SpotifyAudioTrack: 89 | track = await self.get() 90 | return SpotifyAudioTrack( 91 | { 92 | "identifier": track["id"], 93 | "isSeekable": True, 94 | "author": ", ".join([artist["name"] for artist in track["artists"]]), 95 | "length": track["duration_ms"], 96 | "isStream": False, 97 | "title": track["name"], 98 | "uri": track["external_urls"]["spotify"], 99 | "sourceName": "spotify", 100 | }, 101 | requester=self.requester, 102 | cover=track["album"]["images"][0]["url"], 103 | ) 104 | 105 | # Load items 106 | async def load_item(self, client: discord.Bot): 107 | if "playlist" in self.url: 108 | pl, pl_info = await self._load_pl() 109 | return lavalink.LoadResult(lavalink.LoadType.PLAYLIST, pl, pl_info) 110 | if "album" in self.url: 111 | al, al_info = await self._load_al() 112 | return lavalink.LoadResult(lavalink.LoadType.PLAYLIST, al, al_info) 113 | if "track" in self.url: 114 | track = await self._load_track() 115 | return lavalink.LoadResult(lavalink.LoadType.TRACK, [track], lavalink.PlaylistInfo.none()) 116 | -------------------------------------------------------------------------------- /music/store.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | obj = {} 4 | 5 | 6 | # Play channel ID 7 | def play_ch_id(guild_id: int, channel_id: Any | None = None, mode: Literal["get", "set"] = "get"): 8 | """ 9 | Gets or sets the play channel ID for a guild. 10 | 11 | Parameters: 12 | guild_id (int): The ID of the guild. 13 | channel_id (Any | None): The channel ID to set, or None to get the current value. 14 | mode (str): The operation mode, either "get" or "set". 15 | """ 16 | match mode: 17 | case "get": 18 | return obj.get(f"{str(guild_id)}-play_ch_id", None) 19 | case "set": 20 | obj.update({f"{str(guild_id)}-play_ch_id": channel_id}) 21 | 22 | 23 | # Play msg ID 24 | def play_msg(guild_id: int, msg: Any | None = None, mode: Literal["get", "set"] = "get"): 25 | """ 26 | Gets or sets the play message ID for a guild. 27 | 28 | Parameters: 29 | guild_id (int): The ID of the guild. 30 | msg (Any | None): The message ID to set, or None to get the current value. 31 | mode (str): The operation mode, either "get" or "set". 32 | """ 33 | match mode: 34 | case "get": 35 | return obj.get(f"{str(guild_id)}-play_msg", None) 36 | case "set": 37 | obj.update({f"{str(guild_id)}-play_msg": msg}) 38 | 39 | 40 | # Queue msg object 41 | def queue_msg(guild_id: int, msg: Any | None = None, mode: Literal["get", "set", "clear"] = "get"): 42 | """ 43 | Gets or sets the queue message for a guild. 44 | 45 | Parameters: 46 | guild_id (int): The ID of the guild. 47 | msg (Any | None): The message object to set, or None to get the current value. 48 | mode (str): The operation mode, either "get", "set", or "clear". 49 | """ 50 | match mode: 51 | case "get": 52 | if obj.__contains__(f"{str(guild_id)}-queue_msgs"): 53 | return obj.get(f"{str(guild_id)}-queue_msgs", None) 54 | else: 55 | return [] 56 | case "set": 57 | if obj.__contains__(f"{str(guild_id)}-queue_msgs"): 58 | obj[f"{str(guild_id)}-queue_msgs"].append(msg) 59 | else: 60 | obj.update({f"{str(guild_id)}-queue_msgs": [msg]}) 61 | case "clear": 62 | obj.update({f"{str(guild_id)}-queue_msgs": []}) 63 | 64 | 65 | # Equalizer 66 | def equalizer(guild_id: int, name: str = None, mode: Literal["get", "set"] = "get"): 67 | """ 68 | Gets or sets the equalizer settings for a guild. 69 | 70 | Parameters: 71 | guild_id (int): The ID of the guild. 72 | name (str | None): The name of the equalizer to set, or None to get the current value. 73 | mode (str): The operation mode, either "get" or "set". 74 | """ 75 | match mode: 76 | case "get": 77 | return obj.get(f"{str(guild_id)}-equalizer", None) 78 | case "set": 79 | obj.update({f"{str(guild_id)}-equalizer": name}) 80 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "square" 3 | version = "1.1.0" 4 | description = "Advanced multipurpose discord bot for all your needs." 5 | license = "GPL-3.0" 6 | readme = "README.md" 7 | authors = [{ name = "Swayam" }] 8 | requires-python = ">=3.13" 9 | dependencies = [ 10 | "aerich[toml]>=0.9.0", 11 | "aiohttp>=3.12.4", 12 | "babel>=2.17.0", 13 | "lavalink>=5.9.0", 14 | "py-cord", 15 | "pyfiglet>=1.0.2", 16 | "requests>=2.32.3", 17 | "rich>=14.0.0", 18 | "spotipy>=2.25.1", 19 | "toml>=0.10.2", 20 | "tortoise-orm[asyncpg]>=0.25.0", 21 | ] 22 | 23 | [tool.uv.sources.py-cord] 24 | git = "https://github.com/Pycord-Development/pycord" 25 | 26 | [tool.ruff] 27 | line-length = 120 28 | 29 | [tool.ruff.lint] 30 | select = ["E", "W", "F", "I", "B", "C4", "UP"] 31 | ignore = ["E501"] 32 | 33 | [tool.ruff.lint.isort] 34 | no-sections = true 35 | 36 | [tool.ruff.format] 37 | quote-style = "double" 38 | indent-style = "space" 39 | docstring-code-format = true 40 | 41 | [tool.aerich] 42 | tortoise_orm = "db.TORTOISE_ORM" 43 | location = "./migrations" 44 | src_folder = "./." 45 | 46 | [dependency-groups] 47 | dev = ["ruff>=0.11.12"] 48 | -------------------------------------------------------------------------------- /utils/check.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from db.funcs.dev import fetch_dev_ids 3 | from discord.ext import commands 4 | from utils import config 5 | 6 | 7 | def is_owner(): 8 | """Check if the command invoker is the bot owner.""" 9 | 10 | async def predicate(ctx: discord.ApplicationContext): 11 | owner_id = config.owner_id 12 | if ctx.author.id == owner_id: 13 | return True 14 | else: 15 | raise commands.CommandError("You are not authorized to use this command.") 16 | 17 | return commands.check(predicate) 18 | 19 | 20 | def is_dev(): 21 | """Check if the command invoker is a developer or the bot owner.""" 22 | 23 | async def predicate(ctx: discord.ApplicationContext): 24 | owner_id = config.owner_id 25 | dev_ids = await fetch_dev_ids() 26 | if ctx.author.id == owner_id or ctx.author.id in dev_ids: 27 | return True 28 | else: 29 | raise commands.MissingPermissions("You are not authorized to use this command.") 30 | 31 | return commands.check(predicate) 32 | -------------------------------------------------------------------------------- /utils/config.py: -------------------------------------------------------------------------------- 1 | import toml 2 | from attr import dataclass 3 | from typing import TypedDict 4 | 5 | config_file_path = "./config.toml" 6 | 7 | # Load the configuration file 8 | with open(config_file_path) as f: 9 | data = toml.load(f) 10 | 11 | 12 | # Bot configuration 13 | owner_id: int = data["owner-id"] 14 | owner_guild_ids: list[int] = data["owner-guild-ids"] 15 | system_channel_id: int = data["system-channel-id"] 16 | support_server_url: str = data["support-server-url"] 17 | emoji_type: str = data["emoji"] 18 | bot_token: str = data["bot-token"] 19 | db_url: str = data["database-url"] 20 | 21 | 22 | # Colors 23 | @dataclass 24 | class ColorConfig: 25 | theme: int 26 | error: int 27 | 28 | 29 | def colors() -> ColorConfig: 30 | """Returns the color configuration.""" 31 | color = data["colors"] 32 | for key, value in color.items(): 33 | color[key] = int(value.replace("#", ""), 16) 34 | return ColorConfig(**color) 35 | 36 | 37 | color = colors() 38 | 39 | 40 | # Lavalink configuration 41 | class LavalinkConfig(TypedDict): 42 | host: str 43 | port: int 44 | password: str 45 | secure: bool 46 | 47 | 48 | lavalink: LavalinkConfig = data["lavalink"] 49 | 50 | 51 | # Spotify configuration 52 | class SpotifyConfig(TypedDict): 53 | client_id: str 54 | client_secret: str 55 | 56 | 57 | spotify: SpotifyConfig = data["spotify"] 58 | -------------------------------------------------------------------------------- /utils/emoji.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from attr import dataclass 4 | from rich import print 5 | from utils import config 6 | 7 | custom_emoji_file_path = "./.cache/emoji.json" 8 | if not any([config.emoji_type == "custom", config.emoji_type == "default"]): 9 | print(f"[red][bold]✗[/] Invalid emoji type in [cyan]config.toml[/]: {config.emoji_type}[/]") 10 | print("[yellow][bold]![/] Please choose either [green]custom[/] or [green]default[/].[/]") 11 | exit(1) 12 | 13 | 14 | # Dataclass 15 | @dataclass 16 | class Emoji: 17 | bullet: str = "▸" 18 | bullet2: str = "▹" 19 | success: str = "✅" 20 | error: str = "❌" 21 | 22 | on: str = "🟢" 23 | off: str = "🔴" 24 | 25 | embed: str = "📜" 26 | edit: str = "✏️" 27 | bin: str = "🗑️" 28 | 29 | plus: str = "➕" 30 | minus: str = "➖" 31 | next: str = "➡️" 32 | previous: str = "⬅️" 33 | start: str = "⏮️" 34 | end: str = "⏭️" 35 | 36 | kick: str = "🦵🏻" 37 | info: str = "📑" 38 | mod: str = "🔨" 39 | mod2: str = "🔨" 40 | mass_mod: str = "💪🏻" 41 | timer: str = "🕛" 42 | timer2: str = "🕛" 43 | lock: str = "🔒" 44 | unlock: str = "🔓" 45 | settings: str = "⚙️" 46 | 47 | ticket: str = "🎟️" 48 | ticket2: str = "🎟️" 49 | 50 | music: str = "🎵" 51 | play: str = "▶️" 52 | play2: str = "▶️" 53 | pause: str = "⏸️" 54 | pause2: str = "⏸️" 55 | stop: str = "⏹️" 56 | stop2: str = "⏹️" 57 | skip: str = "⏭️" 58 | skip2: str = "⏭️" 59 | shuffle: str = "🔀" 60 | shuffle2: str = "🔀" 61 | seek: str = "⏩" 62 | loop: str = "🔁" 63 | loop2: str = "🔂" 64 | loop3: str = "🔁" 65 | playlist: str = "📃" 66 | volume: str = "🔊" 67 | equalizer: str = "🎶" 68 | filled_bar: str = "⬜" 69 | empty_bar: str = "🟥" 70 | 71 | upload: str = "📤" 72 | console: str = "⌨️" 73 | restart: str = "🔄️" 74 | shutdown: str = "🛑" 75 | 76 | @staticmethod 77 | def from_json(file_path: str) -> "Emoji": 78 | try: 79 | with open(file_path, encoding="utf8") as emoji_file: 80 | emoji_data = json.load(emoji_file) 81 | 82 | # Validate keys 83 | missing_keys = [key for key in Emoji.__annotations__.keys() if key not in emoji_data] 84 | extra_keys = [key for key in emoji_data if key not in Emoji.__annotations__.keys()] 85 | 86 | if missing_keys: 87 | print(f"[red][bold]✗[/] Missing keys in emoji JSON: [cyan]{missing_keys}[/][/]") 88 | exit(1) 89 | if extra_keys: 90 | print(f"[yellow][bold]![/] Extra keys in emoji JSON: [cyan]{extra_keys}[/][/]") 91 | 92 | # Create Emoji instance 93 | return Emoji(**{key: emoji_data.get(key, "") for key in Emoji.__annotations__.keys()}) 94 | 95 | except FileNotFoundError: 96 | if config.emoji_type == "custom": 97 | print(f"[red][bold]✗[/] Custom emoji file not found: {file_path}[/]") 98 | print( 99 | "[yellow][bold]![/] Make sure to run [cyan]/emoji upload[/] command and upload emojis to the discord bot and run [cyan]/emoji sync[/] to create required config files.[/]" 100 | ) 101 | print( 102 | "[yellow][bold]![/] If already uploaded, run [cyan]/emoji sync[/] to create required config files.[/]" 103 | ) 104 | print( 105 | "[yellow][bold]![/] If you want to use default emojis, change the emoji type in [cyan]config.toml[/] to [green]default[/].[/]" 106 | ) 107 | else: 108 | print(f"[red][bold]✗[/] Emoji file not found: {file_path}[/]") 109 | print( 110 | "[yellow][bold]![/] Seems like default emoji file is missing. Please download the default emoji file from the repository and place it in the [green]configs[/] folder.[/]" 111 | ) 112 | exit(1) 113 | except json.JSONDecodeError: 114 | print(f"[red][bold]✗[/] Invalid JSON format in file: {file_path}[/]") 115 | 116 | @staticmethod 117 | def create_custom_emoji_config(emojis: dict) -> dict: 118 | os.makedirs(os.path.dirname(custom_emoji_file_path), exist_ok=True) 119 | with open(custom_emoji_file_path, "w", encoding="utf8") as emoji_file: 120 | missing_keys = [key for key in Emoji.__annotations__.keys() if key not in emojis] 121 | extra_keys = [emojis[key] for key in emojis if key not in Emoji.__annotations__.keys()] 122 | if missing_keys: 123 | return {"status": "error", "missing_keys": missing_keys} 124 | else: 125 | emojis = {key: emojis[key] for key in Emoji.__annotations__.keys()} 126 | json.dump(emojis, emoji_file, ensure_ascii=False, indent=4) 127 | msg = {"status": "success"} 128 | if extra_keys: 129 | msg["extra_keys"] = extra_keys 130 | return msg 131 | 132 | 133 | emoji = Emoji.from_json(custom_emoji_file_path) if config.emoji_type == "custom" else Emoji() 134 | -------------------------------------------------------------------------------- /utils/helpers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | 4 | 5 | def parse_duration(duration: str) -> datetime.timedelta: 6 | """ 7 | Parse a duration string into a timedelta object. 8 | 9 | Parameters: 10 | duration (str): A string representing the duration, e.g., "2w3d4h5m6s". 11 | """ 12 | pattern = re.compile(r"(?P\d+)(?P[wdhms])") 13 | matches = pattern.findall(duration) 14 | 15 | if not matches: 16 | raise ValueError( 17 | "Invalid duration format.\n-# Use `w` for weeks, `d` for days, `h` for hours, `m` for minutes, and `s` for seconds." 18 | ) 19 | 20 | total_duration = datetime.timedelta() 21 | funcs = { 22 | "w": lambda x: datetime.timedelta(weeks=x), 23 | "d": lambda x: datetime.timedelta(days=x), 24 | "h": lambda x: datetime.timedelta(hours=x), 25 | "m": lambda x: datetime.timedelta(minutes=x), 26 | "s": lambda x: datetime.timedelta(seconds=x), 27 | } 28 | 29 | for value, unit in matches: 30 | value = int(value) 31 | if value <= 0: 32 | raise ValueError("Duration values must be positive.") 33 | total_duration += funcs[unit](value) 34 | 35 | if total_duration.total_seconds() <= 0: 36 | raise ValueError("Total duration must be positive.") 37 | elif total_duration.days > 28: 38 | raise ValueError("Total duration must be less than `28 days`.") 39 | 40 | return total_duration 41 | 42 | 43 | def fmt_perms(perms: list[str]) -> str: 44 | """ 45 | Format a list of permissions into a human-readable string. 46 | 47 | Parameters: 48 | perms (list[str]): A list of permission names. 49 | 50 | Returns: 51 | str: A formatted string of permissions. 52 | """ 53 | perms = [perm.replace("_", " ").replace("guild", "server").title() for perm in perms] 54 | if len(perms) > 2: 55 | return "{}, and {}".format("**, **".join(perms[:-1]), perms[-1]) 56 | elif len(perms) == 2: 57 | return "{} and {}".format("**".join(perms[:-1]), perms[-1]) 58 | elif perms: 59 | return perms[0] 60 | else: 61 | return "No permissions" 62 | --------------------------------------------------------------------------------