├── .gitignore ├── LICENSE ├── README.md ├── docref ├── __init__.py ├── docref.py ├── errors.py ├── info.json └── types.py ├── errorlogs ├── __init__.py ├── errorlogs.py ├── info.json └── reaction_menu.py ├── info.json ├── poetry.lock ├── pyproject.toml ├── reactkarma ├── __init__.py ├── info.json └── reactkarma.py ├── sticky ├── __init__.py ├── info.json └── sticky.py ├── streamroles ├── __init__.py ├── info.json ├── streamroles.py └── types.py ├── strikes ├── __init__.py ├── data │ └── ddl.sql ├── info.json └── strikes.py ├── updatered ├── __init__.py ├── info.json └── updatered.py └── welcomecount ├── __init__.py ├── info.json └── welcomecount.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # vscode 108 | .vscode/ 109 | 110 | # IDEA 111 | .idea/ 112 | -------------------------------------------------------------------------------- /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 | # Tobo-Cogs 2 | Tobo's Cogs for [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot). 3 | 4 | The *V3* branch keeps in line with Red V3 stable (i.e. the latest release). 5 | 6 | ### Status of this repository 7 | It's true that I'm not as active as I once was in the Red community. New features are more-or-less on hold, however I'll still try to keep the cogs functional when I can. 8 | 9 | ## Installation 10 | To add this repository, use this command in discord (replace [p] with your bot's prefix): 11 | 12 | [p]repo add Tobo-Cogs https://github.com/Tobotimus/Tobo-Cogs 13 | 14 | To install a cog, use this command (replace [cog] with the name of the cog you wish to install): 15 | 16 | [p]cog install Tobo-Cogs [cog] 17 | 18 | ## Support 19 | 20 | Support for these cogs is available in the [#support_tobo-cogs](https://discord.gg/c2YXKZF) channel of Red's Cog Support server. 21 | 22 | ## Contributing 23 | 24 | I love new Issues and Pull Requests! If you think something needs to change about a cog, open an issue describing what you'd like to change. 25 | 26 | If you feel that you can make changes yourself, I'd appreciate it if you opened the issue anyway, so we can discuss it, then you're welcome to open a pull request. We can also discuss it in the support channel linked above if you prefer. 27 | 28 | You're also welcome to fork and modify the cogs, as long as the [GPL v3.0 license](LICENSE) is adhered to. However, it's nice when your enhancements and improvements get shared with the community without fragmenting it. 29 | -------------------------------------------------------------------------------- /docref/__init__.py: -------------------------------------------------------------------------------- 1 | """DocRef - Search for references on documentation webpages generated by Sphinx.""" 2 | import asyncio 3 | 4 | from redbot.core.bot import Red 5 | 6 | from .docref import DocRef 7 | from .errors import AlreadyUpToDate # noqa: F401 8 | from .types import NodeRef # noqa: F401 9 | 10 | 11 | async def setup(bot: Red): 12 | cog = DocRef() 13 | if asyncio.iscoroutinefunction(bot.add_cog): 14 | await bot.add_cog(cog) 15 | else: 16 | bot.add_cog(cog) 17 | -------------------------------------------------------------------------------- /docref/docref.py: -------------------------------------------------------------------------------- 1 | """Module for the DocRef cog.""" 2 | import asyncio 3 | import shutil 4 | import pathlib 5 | import re 6 | import tempfile 7 | import urllib.parse 8 | from typing import Dict, Iterator, List, Match, Optional, Tuple, cast 9 | 10 | import aiohttp 11 | import discord 12 | import sphinx.util.inventory as sphinx_inv 13 | from redbot.core import Config, checks, commands, data_manager 14 | from redbot.core.utils import chat_formatting as chatutils 15 | 16 | from .errors import ( 17 | AlreadyUpToDate, 18 | Forbidden, 19 | HTTPError, 20 | InternalError, 21 | InvNotAvailable, 22 | NoMoreRefs, 23 | NotFound, 24 | ) 25 | from .types import ( 26 | FilterFunc, 27 | InvData, 28 | InvMetaData, 29 | MatchesDict, 30 | NodeRef, 31 | RawInvData, 32 | RawInvMetaData, 33 | RefDict, 34 | RefSpec, 35 | ) 36 | 37 | UNIQUE_ID = 0x178AC710 38 | 39 | 40 | class DocRef(commands.Cog): 41 | """Search for references on documentation webpages. 42 | 43 | I need to be able to embed links for this cog to be useful! 44 | """ 45 | 46 | def __init__(self): 47 | super().__init__() 48 | self.conf: Config = Config.get_conf( 49 | self, identifier=UNIQUE_ID, force_registration=True 50 | ) 51 | self.conf.register_global(sites={}, inv_metadata={}) 52 | self.conf.register_guild(sites={}) 53 | self.invs_data: Dict[str, InvData] = {} 54 | self.invs_dir: pathlib.Path = data_manager.cog_data_path(self) / "invs" 55 | self.invs_dir.mkdir(parents=True, exist_ok=True) 56 | self.session: aiohttp.ClientSession = aiohttp.ClientSession() 57 | 58 | @commands.command(aliases=["ref", "rtd", "rtfm"]) 59 | async def docref(self, ctx: commands.Context, sitename: str, *, node_ref: NodeRef): 60 | """Search for a reference in documentation webpages. 61 | 62 | This will display a list hyperlinks to possible matches for the 63 | provided node reference. 64 | 65 | `` is the name for the documentation webpage. This is set 66 | when the webpage is added with `[p]addsite`. 67 | 68 | `` is a reference to a sphinx node in reST syntax, however 69 | most of the syntactic operators can be omitted for a more vague 70 | reference. 71 | 72 | For example, all of these commands will return the same result: 73 | 74 | ``[p]docref pydocs :py:class:`int`\u200B`` 75 | ``[p]docref pydocs :class:`int`\u200B`` 76 | ``[p]docref pydocs :class:int`` 77 | ``[p]docref pydocs `int`\u200B`` 78 | ``[p]docref pydocs int`` 79 | 80 | """ 81 | # First we get the base URL and inventory data 82 | try: 83 | url, inv_data = await self.get_inv_data(sitename, ctx.guild) 84 | except InvNotAvailable: 85 | await ctx.send(f'Couldn\'t find the site name "{sitename}".') 86 | return 87 | except NotFound: 88 | await ctx.send( 89 | f'It appears as though the "{sitename}" site\'s URL is now a 404.' 90 | ) 91 | return 92 | 93 | # Now we need to filter the data according to our node_ref 94 | 95 | filter_func: FilterFunc = self._get_filter_func(node_ref) 96 | 97 | reftypes: Iterator[str] = filter(filter_func, inv_data.keys()) 98 | 99 | exact_matches: MatchesDict = {} 100 | partial_matches: MatchesDict = {} 101 | 102 | # If the reftype is bogus, the filter result will be empty 103 | # Thus, we'll never enter the loop 104 | valid_reftype = False 105 | 106 | for reftype in reftypes: 107 | valid_reftype = True 108 | 109 | ref_dict = inv_data[reftype] 110 | tup = self.get_matches(node_ref.refname, ref_dict) 111 | matches: List[RefSpec] = tup[0] 112 | exact: bool = tup[1] 113 | 114 | if not matches: 115 | continue 116 | 117 | if exact: 118 | assert matches # just double check our subroutine didn't do a poopoo 119 | exact_matches[reftype] = matches 120 | elif exact_matches: 121 | # we've already found closer matches than these, discard 122 | continue 123 | else: 124 | partial_matches[reftype] = matches 125 | 126 | if not valid_reftype: 127 | await ctx.send( 128 | f"Couldn't find any references with the `:{node_ref.reftype}:` " 129 | f"directive." 130 | ) 131 | return 132 | 133 | matches: MatchesDict = exact_matches or partial_matches 134 | 135 | if not matches: 136 | await ctx.send( 137 | f"Couldn't find any references matching ``{node_ref}\u200B``." 138 | ) 139 | return 140 | 141 | metadata = await self.get_inv_metadata(url) 142 | embed_list = self._new_match_embed( 143 | metadata, 144 | matches, 145 | exact=bool(exact_matches), 146 | colour=await ctx.embed_colour(), 147 | ) 148 | 149 | for embed in embed_list: 150 | await ctx.send(embed=embed) 151 | 152 | @commands.command() 153 | @checks.admin_or_permissions(administrator=True) 154 | async def addsite(self, ctx: commands.Context, sitename: str, url: str, scope=None): 155 | """Add a new documentation site. 156 | 157 | `` must be resolved to an actual docs webpage, and not a redirect 158 | URL. For example, `https://docs.python.org` is invalid, however the 159 | URL it redirects to, `https://docs.python.org/3/`, is valid. 160 | 161 | `` is an owner-only argument and specifies where this site can 162 | be accessed from. Defaults to `server` for everyone except the bot 163 | owner, whose scope defaults to `global`. 164 | """ 165 | if not url.startswith("https://"): 166 | await ctx.send("Must be an HTTPS URL.") 167 | return 168 | if not url.endswith("/"): 169 | url += "/" 170 | 171 | is_owner = await ctx.bot.is_owner(ctx.author) 172 | if scope is not None and not is_owner: 173 | await ctx.send("Only bot owners can specify the scope.") 174 | return 175 | elif scope is None: 176 | if is_owner: 177 | scope = "global" 178 | else: 179 | scope = "guild" 180 | 181 | scope = scope.lower() 182 | if scope in ("server", "guild"): 183 | if ctx.guild is None: 184 | await ctx.send(f"Can't add to {scope} scope from DM.") 185 | return 186 | conf_group = self.conf.guild(ctx.guild).sites 187 | elif scope == "global": 188 | conf_group = self.conf.sites 189 | else: 190 | await ctx.send(f'Unknown scope "{scope}".') 191 | return 192 | 193 | try: 194 | async with ctx.typing(): 195 | await self.update_inv(url) 196 | except NotFound: 197 | await ctx.send("Couldn't find an inventory from that URL.") 198 | return 199 | except HTTPError as exc: 200 | await ctx.send( 201 | f"Something went wrong whilst trying to download the " 202 | f"inventory file. HTTP response code {exc.code}." 203 | ) 204 | return 205 | else: 206 | existing_url = await conf_group.get_raw(sitename, default=None) 207 | if existing_url is not None: 208 | await self._decref(existing_url) 209 | 210 | await conf_group.set_raw(sitename, value=url) 211 | await self._incref(url) 212 | await ctx.tick() 213 | 214 | @commands.command(aliases=["removesite"]) 215 | @checks.admin_or_permissions(administrator=True) 216 | async def delsite(self, ctx: commands.Context, sitename: str): 217 | """Remove a documentation site. 218 | 219 | This command will remove just one site, and if there are multiple 220 | sites with the same name, it will remove the most local one. 221 | 222 | Only bot owners can delete global sites. 223 | """ 224 | is_owner = await ctx.bot.is_owner(ctx.author) 225 | try: 226 | await self.remove_site(sitename, ctx.guild, is_owner) 227 | except InvNotAvailable: 228 | await ctx.send(f"Couldn't find a site by the name `{sitename}`.") 229 | except Forbidden as exc: 230 | await ctx.send(exc.args[0]) 231 | else: 232 | await ctx.tick() 233 | 234 | @commands.command() 235 | async def docsites(self, ctx: commands.Context): 236 | """List all installed and available documentation websites.""" 237 | sites = await self.conf.sites() 238 | if ctx.guild is not None: 239 | sites.update(await self.conf.guild(ctx.guild).sites()) 240 | 241 | lines: List[str] = [] 242 | for name, url in sites.items(): 243 | try: 244 | metadata = await self.get_inv_metadata(url) 245 | except InvNotAvailable: 246 | continue 247 | 248 | lines.append(f"`{name}` - [{metadata}]({url})") 249 | 250 | if not lines: 251 | await ctx.send("No sites are available.") 252 | 253 | description = "\n".join(lines) 254 | 255 | for page in chatutils.pagify(description, page_length=2048): 256 | await ctx.send( 257 | embed=discord.Embed(description=page, colour=await ctx.embed_colour()) 258 | ) 259 | 260 | @commands.command() 261 | @checks.is_owner() 262 | async def forceupdate(self, ctx: commands.Context, sitename: str): 263 | """Force a documentation webpage to be updated. 264 | 265 | Updates are checked for every time you use `[p]docref`. However, 266 | the inventory cache isn't actually updated unless we have an old 267 | version number. 268 | 269 | This command will force the site to be updated irrespective of the 270 | version number. 271 | """ 272 | url: str = await self.get_url(sitename) 273 | if url is None: 274 | await ctx.send(f'Couldn\'t find the site name "{sitename}".') 275 | return 276 | try: 277 | async with ctx.typing(): 278 | await self.update_inv(url, force=True) 279 | except NotFound: 280 | await ctx.send( 281 | f'It appears as though the "{sitename}" site\'s URL is now a 404.' 282 | ) 283 | else: 284 | await ctx.tick() 285 | 286 | @staticmethod 287 | def get_matches(refname: str, ref_dict: RefDict) -> Tuple[List[RefSpec], bool]: 288 | """Get a list of matching references. 289 | 290 | First this function will look for exact matches (for which there will 291 | only be one), and if it can't find any, it will look for references 292 | whose name ends with the given ``refname``. 293 | 294 | Arguments 295 | --------- 296 | refname 297 | The name of the reference being looked for. 298 | ref_dict 299 | A mapping from references to `RefSpec` objects. 300 | 301 | Returns 302 | ------- 303 | Tuple[List[RefSpec], bool] 304 | The `bool` will be ``True`` if the matches returned are exact. 305 | 306 | """ 307 | # first look for an exact match 308 | if refname in ref_dict: 309 | return [ref_dict[refname]], True 310 | 311 | # look for references ending with the refname 312 | return ( 313 | [ 314 | ref_spec 315 | for cur_refname, ref_spec in ref_dict.items() 316 | if cur_refname.endswith(refname) 317 | ], 318 | False, 319 | ) 320 | 321 | async def get_inv_data( 322 | self, site: str, guild: Optional[discord.Guild] = None 323 | ) -> Tuple[str, InvData]: 324 | """Get data for an inventory by its user-defined name and scope. 325 | 326 | Also updates the locally cached inventory if necessary. 327 | 328 | Returns 329 | ------- 330 | Tuple[str, InvData] 331 | A tuple in the form (url, data). 332 | 333 | """ 334 | url = await self.get_url(site, guild) 335 | if url is None: 336 | raise InvNotAvailable() 337 | await self.update_inv(url) 338 | return url, self.invs_data[url] 339 | 340 | async def get_url( 341 | self, sitename: str, guild: Optional[discord.Guild] = None 342 | ) -> Optional[str]: 343 | """Get a URL by its sitename and scope. 344 | 345 | Arguments 346 | --------- 347 | sitename : str 348 | The user-defined site name. 349 | guild : Optional[discord.Guild] 350 | The guild from who's data the URL is being retreived. 351 | 352 | Returns 353 | ------- 354 | Optional[str] 355 | The URL for the requested site. ``None`` if no site is found. 356 | 357 | """ 358 | if guild is not None: 359 | url = await self.conf.guild(guild).sites.get_raw(sitename, default=None) 360 | if url is not None: 361 | return url 362 | return await self.conf.sites.get_raw(sitename, default=None) 363 | 364 | async def remove_site( 365 | self, sitename: str, guild: Optional[discord.Guild], is_owner: bool 366 | ) -> None: 367 | """Remove a site from the given scope. 368 | 369 | Only removes one site at a time. If there is a site with the same name 370 | in both the guild and global scope, only the guild one will be 371 | removed. 372 | 373 | Arguments 374 | --------- 375 | sitename 376 | The user-defined site name. 377 | guild 378 | The guild from who's data is being mutated. 379 | is_owner 380 | Whether or not the user doing the action is the bot owner. 381 | 382 | Raises 383 | ------ 384 | InvNotAvailable 385 | If no site with that name is available in the given scope. 386 | Forbidden 387 | If the user does not have the right privelages to remove the site. 388 | 389 | """ 390 | url = await self.get_url(sitename, guild) 391 | if url is None: 392 | raise InvNotAvailable() 393 | 394 | if guild is not None: 395 | sites = await self.conf.guild(guild).sites() 396 | if sitename in sites: 397 | del sites[sitename] 398 | await self.conf.guild(guild).sites.set(sites) 399 | await self._decref(url) 400 | return 401 | 402 | if not is_owner: 403 | raise Forbidden("Only bot owners can delete global sites.") 404 | 405 | async with self.conf.sites() as sites: 406 | del sites[sitename] 407 | await self._decref(url) 408 | 409 | async def update_inv(self, url: str, *, force: bool = False) -> InvData: 410 | """Update a locally cached inventory. 411 | 412 | Unless ``force`` is ``True``, this won't update the cache unless the 413 | metadata for the inventory does not match. 414 | 415 | Arguments 416 | --------- 417 | url : str 418 | The URL for the docs website. This is the path to the webpage, and 419 | not to the inventory file. 420 | force : bool 421 | Whether or not we should force the update. Defaults to ``False``. 422 | 423 | Returns 424 | ------- 425 | InvData 426 | The up-to-date data for the inventory. 427 | 428 | """ 429 | try: 430 | data = await self.get_inv_from_url(url, force_update=force) 431 | except AlreadyUpToDate: 432 | try: 433 | data = self.invs_data[url] 434 | except KeyError: 435 | path = self._get_inv_path(url) 436 | data = self.load_inv_file(path, url) 437 | self.invs_data[url] = data 438 | else: 439 | self.invs_data[url] = data 440 | 441 | return data 442 | 443 | def _get_inv_path(self, url: str) -> pathlib.Path: 444 | return self.invs_dir / f"{safe_filename(url)}.inv" 445 | 446 | async def get_inv_from_url( 447 | self, url: str, *, force_update: bool = False 448 | ) -> InvData: 449 | """Gets inventory data from its URL. 450 | 451 | Arguments 452 | --------- 453 | url : str 454 | The URL for the docs website. 455 | force_update : bool 456 | Whether or not the inventory should be force updated. Defaults to 457 | ``False``. 458 | 459 | Returns 460 | ------- 461 | InvData 462 | The data for the requested inventory. 463 | 464 | Raises 465 | ------ 466 | AlreadyUpToDate 467 | If the inventory was already up to date, and ``force_update`` was 468 | ``False``. 469 | 470 | """ 471 | inv_path = await self.download_inv_file(url, force_update=force_update) 472 | return self.load_inv_file(inv_path, url) 473 | 474 | def load_inv_file(self, file_path: pathlib.Path, url: str) -> InvData: 475 | """Load an inventory file from its filepath. 476 | 477 | Returns 478 | ------- 479 | InvData 480 | The data from the inventory file. 481 | 482 | """ 483 | inv_data = self._load_inv_file_raw(file_path, url) 484 | return self._format_raw_inv_data(inv_data) 485 | 486 | @staticmethod 487 | def _load_inv_file_raw(file_path: pathlib.Path, url: str) -> RawInvData: 488 | with file_path.open("rb") as stream: 489 | inv_data = sphinx_inv.InventoryFile.load(stream, url, urllib.parse.urljoin) 490 | return inv_data 491 | 492 | async def download_inv_file( 493 | self, url: str, *, force_update: bool = False 494 | ) -> pathlib.Path: 495 | """Download the inventory file from a URL. 496 | 497 | Arguments 498 | --------- 499 | url : str 500 | The URL for the docs website. This is the path to the webpage, and 501 | not to the inventory file. 502 | force_update : bool 503 | Whether or not the data should be forcibly updated. Defaults to 504 | ``False``. 505 | 506 | Raises 507 | ------ 508 | AlreadyUpToDate 509 | If the local version matches that of the remote, and 510 | ``force_update`` is False. 511 | 512 | Returns 513 | ------- 514 | pathlib.Path 515 | The path to the local inventory file. 516 | 517 | """ 518 | inv_path = self._get_inv_path(url) 519 | inv_url = urllib.parse.urljoin(url, "objects.inv") 520 | async with self.session.get(inv_url) as resp: 521 | self._check_response(resp) 522 | # read header comments to get version 523 | header_lines: List[bytes] = [] 524 | idx = 0 525 | async for line in resp.content: 526 | header_lines.append(cast(bytes, line)) 527 | idx += 1 528 | if idx > 2: 529 | break 530 | projname = header_lines[1].rstrip()[11:].decode() 531 | version = header_lines[2].rstrip()[11:].decode() 532 | metadata = InvMetaData(projname, version) 533 | if not force_update and await self._inv_metadata_matches(url, metadata): 534 | raise AlreadyUpToDate() 535 | 536 | fd, filename = tempfile.mkstemp() 537 | with open(fd, "wb") as stream: 538 | async with self.session.get(inv_url) as resp: 539 | chunk = await resp.content.read(1024) 540 | while chunk: 541 | stream.write(chunk) 542 | chunk = await resp.content.read(1024) 543 | shutil.move(filename, inv_path) 544 | 545 | await self.set_inv_metadata(url, metadata) 546 | 547 | return inv_path 548 | 549 | @staticmethod 550 | def _check_response(resp: aiohttp.ClientResponse) -> None: 551 | """Checks a response to an HTTP request and raises the appropriate error. 552 | 553 | Raises 554 | ------ 555 | NotFound 556 | If the response code is 404. 557 | HTTPError 558 | If there was an unexpected response code. 559 | 560 | """ 561 | if resp.status == 200: 562 | return 563 | elif resp.status == 404: 564 | error_cls = NotFound 565 | else: 566 | error_cls = HTTPError 567 | raise error_cls(resp.status, resp.reason, resp) 568 | 569 | async def _inv_metadata_matches(self, url: str, metadata: InvMetaData) -> bool: 570 | try: 571 | existing_metadata: InvMetaData = await self.get_inv_metadata(url) 572 | except InvNotAvailable: 573 | return False 574 | else: 575 | return metadata == existing_metadata 576 | 577 | async def get_inv_metadata(self, url: str) -> InvMetaData: 578 | """Get metadata for an inventory. 579 | 580 | Arguments 581 | --------- 582 | url : str 583 | The URL for the docs website. 584 | 585 | Returns 586 | ------- 587 | InvMetaData 588 | The metadata for the inventory. 589 | 590 | Raises 591 | ------ 592 | InvNotAvailable 593 | If there is no inventory matching that URL. 594 | 595 | """ 596 | try: 597 | raw_metadata: RawInvMetaData = await self.conf.inv_metadata.get_raw(url) 598 | except KeyError: 599 | raise InvNotAvailable 600 | else: 601 | return InvMetaData(**raw_metadata) 602 | 603 | async def set_inv_metadata(self, url: str, metadata: InvMetaData) -> None: 604 | """Set metadata for an inventory. 605 | 606 | Arguments 607 | --------- 608 | url : str 609 | The URL for the docs website. 610 | metadata : InvMetaData 611 | The inventory's metadata. 612 | 613 | """ 614 | await self.conf.inv_metadata.set_raw(url, value=metadata.to_dict()) 615 | 616 | @staticmethod 617 | def _format_raw_inv_data(inv_data: RawInvData) -> InvData: 618 | ret: InvData = {} 619 | for ref_type, refs_dict in inv_data.items(): 620 | new_refs_dict: RefDict = {} 621 | for ref_name, raw_ref_spec in refs_dict.items(): 622 | ref_url: str = raw_ref_spec[2] 623 | display_name: str = raw_ref_spec[3] 624 | if display_name == "-": 625 | display_name = ref_name 626 | else: 627 | display_name = f"{ref_name} - {display_name}" 628 | new_refs_dict[ref_name] = RefSpec(ref_url, display_name) 629 | ret[ref_type] = new_refs_dict 630 | return ret 631 | 632 | @staticmethod 633 | def _new_match_embed( 634 | metadata: InvMetaData, 635 | matches: MatchesDict, 636 | *, 637 | exact: bool, 638 | colour: Optional[discord.Colour] = None, 639 | ) -> List[discord.Embed]: 640 | count = 0 641 | match_type = "exact" if exact else "possible" 642 | 643 | lines: List[str] = [] 644 | for reftype, refspec_list in matches.items(): 645 | lines.append(chatutils.bold(reftype)) 646 | for refspec in refspec_list: 647 | count += 1 648 | # The zero-width space is necessary to make sure discord doesn't remove 649 | # leading spaces at the start of an embed. 650 | lines.append( 651 | "\u200b" + (" " * 4) + f"[{refspec.display_name}]({refspec.url})" 652 | ) 653 | 654 | plural = "es" if count > 1 else "" 655 | description = "\n".join(lines) 656 | ret: List[discord.Embed] = [] 657 | 658 | for page in chatutils.pagify(description, page_length=2048): 659 | # my little hack to make sure pagify doesn't strip the initial indent 660 | if not page.startswith("**"): 661 | page = " " * 4 + page 662 | 663 | ret.append( 664 | discord.Embed(description=page, colour=colour or discord.Embed.Empty) 665 | ) 666 | 667 | ret[0].title = f"Found {count} {match_type} match{plural}." 668 | ret[-1].set_footer(text=f"{metadata.projname} {metadata.version}") 669 | return ret 670 | 671 | @staticmethod 672 | def _get_filter_func(node_ref: NodeRef) -> FilterFunc: 673 | if node_ref.role == "any": 674 | 675 | if node_ref.lang is not None: 676 | # Some weirdo did a :lang:any: search 677 | 678 | def _filter(reftype: str) -> bool: 679 | lang_and_role = reftype.split(":") 680 | # This should return a sequence in the form [lang, role] 681 | # But we should check and make sure just in case 682 | if len(lang_and_role) != 2: 683 | raise InternalError( 684 | f"Unexpected reftype in inventory data {reftype}" 685 | ) 686 | 687 | lang = lang_and_role[0] 688 | return lang == node_ref.lang 689 | 690 | else: 691 | # If the role is just :any: we don't filter at all 692 | 693 | def _filter(_: str) -> bool: 694 | return True 695 | 696 | elif node_ref.role and node_ref.lang: 697 | 698 | def _filter(reftype: str) -> bool: 699 | return reftype == f"{node_ref.lang}:{node_ref.role}" 700 | 701 | elif node_ref.role and not node_ref.lang: 702 | 703 | def _filter(reftype: str) -> bool: 704 | lang_and_role = reftype.split(":") 705 | if len(lang_and_role) != 2: 706 | raise InternalError( 707 | f"Unexpected reftype in inventory data {reftype}" 708 | ) 709 | 710 | role = lang_and_role[1] 711 | return node_ref.role == role 712 | 713 | else: 714 | # We shouldn't have got here 715 | raise InternalError(f"Unexpected NodeRef {node_ref!r}") 716 | 717 | return _filter 718 | 719 | async def _decref(self, url: str) -> None: 720 | metadata = await self.get_inv_metadata(url) 721 | try: 722 | metadata.dec_refcount() 723 | except NoMoreRefs: 724 | await self._destroy_inv(url) 725 | else: 726 | await self.set_inv_metadata(url, metadata) 727 | 728 | async def _incref(self, url: str) -> None: 729 | metadata = await self.get_inv_metadata(url) 730 | metadata.inc_refcount() 731 | await self.set_inv_metadata(url, metadata) 732 | 733 | async def _destroy_inv(self, url: str) -> None: 734 | async with self.conf.inv_metadata() as inv_metadata: 735 | del inv_metadata[url] 736 | try: 737 | del self.invs_data[url] 738 | except KeyError: 739 | pass 740 | inv_file = self._get_inv_path(url) 741 | if inv_file.exists(): 742 | inv_file.unlink() 743 | 744 | def cog_unload(self) -> None: 745 | asyncio.create_task(self.session.close()) 746 | 747 | 748 | _INVALID_CHARSET = re.compile("[^A-z0-9_]") 749 | 750 | 751 | def _replace_invalid_char(match: Match[str]) -> str: 752 | return str(ord(match[0])) 753 | 754 | 755 | def safe_filename(instr: str) -> str: 756 | """Generates a filename-friendly string. 757 | 758 | Useful for creating filenames unique to URLs. 759 | """ 760 | return "_" + _INVALID_CHARSET.sub(_replace_invalid_char, instr) 761 | -------------------------------------------------------------------------------- /docref/errors.py: -------------------------------------------------------------------------------- 1 | """Errors for the docref package.""" 2 | from typing import Any 3 | 4 | __all__ = ( 5 | "DocRefException", 6 | "AlreadyUpToDate", 7 | "InvNotAvailable", 8 | "NoMoreRefs", 9 | "Forbidden", 10 | "InternalError", 11 | "HTTPError", 12 | "NotFound", 13 | ) 14 | 15 | 16 | class DocRefException(Exception): 17 | """Base exception for this package.""" 18 | 19 | 20 | class AlreadyUpToDate(DocRefException): 21 | """Tried to update inventory but we already have the latest version.""" 22 | 23 | 24 | class InvNotAvailable(DocRefException): 25 | """Inventory is not available in the current scope, or it isn't installed.""" 26 | 27 | 28 | class NoMoreRefs(DocRefException): 29 | """Inventory no longer has any references, and can be un-cached.""" 30 | 31 | 32 | class Forbidden(DocRefException): 33 | """The user tried to do something they're not allowed to do.""" 34 | 35 | 36 | class InternalError(DocRefException): 37 | """An internal error occurred. 38 | 39 | This is most likely due to a bug or data corruption. 40 | """ 41 | 42 | 43 | class HTTPError(DocRefException): 44 | """An error occurred during a HTTP request. 45 | 46 | Attributes 47 | ---------- 48 | code : int 49 | The HTTP response code. 50 | 51 | """ 52 | 53 | def __init__(self, code: int, *args: Any): 54 | self.code = code 55 | super().__init__(*args) 56 | 57 | 58 | class NotFound(HTTPError): 59 | """The resource was not found.""" 60 | -------------------------------------------------------------------------------- /docref/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["Tobotimus"], 3 | "description": "DocRef allows you to look up references on documentation webpages by their sphinx object names - this includes any documented python object! The documentation webpages must be added to the bot with a site name (of your own choice) and URL. The documentation webpages must be generated by sphinx (this includes almost every documentation page on ReadTheDocs).", 4 | "install_msg": "Thanks for installing `docref`. See `[p]help DocRef` for details.", 5 | "short": "Search for references on documentation webpages.", 6 | "hidden": false, 7 | "disabled": false, 8 | "required_cogs": {}, 9 | "requirements": ["sphinx"], 10 | "tags": ["docs", "dev", "coding", "sphinx", "rtd", "rtfd", "rtfm", "readthedocs"], 11 | "type": "COG" 12 | } 13 | -------------------------------------------------------------------------------- /docref/types.py: -------------------------------------------------------------------------------- 1 | """Module containing various classes used in docref.""" 2 | import re 3 | from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union 4 | 5 | from redbot.core import commands 6 | 7 | from .errors import InternalError, NoMoreRefs 8 | 9 | __all__ = ( 10 | "NodeRef", 11 | "RawRefSpec", 12 | "RefSpec", 13 | "RawInvMetaData", 14 | "InvMetaData", 15 | "RawRefDict", 16 | "RefDict", 17 | "RawInvData", 18 | "InvData", 19 | "FilterFunc", 20 | "MatchesDict", 21 | ) 22 | 23 | 24 | class RefSpec(NamedTuple): 25 | """Container for data relating to a reference. 26 | 27 | This class is simply a `collections.namedtuple`, and thus it is immutable. 28 | 29 | Attributes 30 | ---------- 31 | url : str 32 | A direct URL to the reference. 33 | display_name : str 34 | The reference's display name (often the same as the normal reference 35 | name. 36 | 37 | """ 38 | 39 | url: str 40 | display_name: str 41 | 42 | 43 | class InvMetaData: 44 | """Metadata for a sphinx inventory.""" 45 | 46 | __slots__ = ("_projname", "_version", "_refcount") 47 | 48 | def __init__(self, projname: str, version: str, refcount: int = 0): 49 | self._projname: str = projname 50 | self._version: str = version 51 | self._refcount: int = refcount 52 | 53 | @property 54 | def projname(self) -> str: 55 | """(str) : The name of the project which contains this inventory.""" 56 | return self._projname 57 | 58 | @property 59 | def version(self) -> str: 60 | """(str) : The version of the project.""" 61 | return self._version 62 | 63 | @property 64 | def refcount(self) -> int: 65 | """(int) : The reference count for this inventory.""" 66 | return self._refcount 67 | 68 | def inc_refcount(self) -> None: 69 | """Increment this inventory's refcount.""" 70 | self._refcount += 1 71 | 72 | def dec_refcount(self) -> None: 73 | """Decrement this inventory's refcount. 74 | 75 | Raises 76 | ------ 77 | NoMoreRefs 78 | If the refcount has reached zero. 79 | 80 | """ 81 | self._refcount -= 1 82 | if self._refcount == 0: 83 | raise NoMoreRefs() 84 | if self._refcount < 0: 85 | raise InternalError("Tried to decref on an inventory with no refs.") 86 | 87 | def to_dict(self) -> "RawInvMetaData": 88 | """Return this metadata object as a dict.""" 89 | return { 90 | "projname": self.projname, 91 | "version": self.version, 92 | "refcount": self.refcount, 93 | } 94 | 95 | def __eq__(self, other: Any) -> bool: 96 | """Check if this metadata object is equal to another. 97 | 98 | Returns 99 | ------- 100 | bool 101 | ``True`` if `projname` and `version` match. 102 | 103 | """ 104 | if not isinstance(other, self.__class__): 105 | return False 106 | return self.projname == other.projname and self.version == other.version 107 | 108 | def __ne__(self, other: Any) -> bool: 109 | """Check if this metadata object is not equal to another. 110 | 111 | Returns 112 | ------- 113 | bool 114 | ``False`` if `projname` and `version` match. 115 | 116 | """ 117 | return not self.__eq__(other) 118 | 119 | def __str__(self) -> str: 120 | """Get a string representation of this metadata. 121 | 122 | Returns 123 | ------- 124 | str 125 | ``{projname} {version}`` 126 | 127 | """ 128 | return f"{self.projname} {self.version}" 129 | 130 | 131 | class NodeRef: 132 | """Class for a reference to a sphinx node. 133 | 134 | Attributes 135 | ---------- 136 | refname : str 137 | The reference name to search for. 138 | role : str 139 | The role for the reference. Can be ``"any"`` to be totally ambiguous. 140 | lang : Optional[str] 141 | The language to match the role. ``None`` if omitted - this is not 142 | often needed. 143 | 144 | """ 145 | 146 | REF_PATTERN = re.compile( 147 | r"(?P:[a-z\-]+:)?(?P[a-z\-]+:)?`?(?P.*)`?$" 148 | ) 149 | STD_ROLES = ("doc", "label", "term", "cmdoption", "envvar", "opcode", "token") 150 | 151 | def __init__(self, refname: str, role: str, lang: Optional[str]): 152 | self.refname: str = refname.strip() 153 | self.role: str = role 154 | self.lang: Optional[str] = lang 155 | 156 | @classmethod 157 | async def convert(cls, ctx: commands.Context, argument: str) -> "NodeRef": 158 | """Convert from a string argument to a NodeRef.""" 159 | argument = argument.strip("`") 160 | 161 | match = cls.REF_PATTERN.match(argument) 162 | # make sure the refname exists 163 | refname = match["refname"] 164 | if refname is None: 165 | raise commands.BadArgument( 166 | f'Failed to parse reference "{argument}" - ' 167 | f"see `{ctx.prefix}help {ctx.invoked_with}` for details." 168 | ) 169 | # try to line up the lang:role syntax 170 | if match["dir1"] and match["dir2"]: 171 | lang = match["dir1"].strip(":") 172 | role = match["dir2"].strip(":") 173 | else: 174 | lang = None 175 | if match["dir1"]: 176 | role = match["dir1"].strip(":") 177 | elif match["dir2"]: 178 | role = match["dir2"].strip(":") 179 | else: 180 | role = "any" 181 | 182 | if role in cls.STD_ROLES: 183 | lang = "std" 184 | 185 | return cls(refname, role, lang) 186 | 187 | @property 188 | def reftype(self) -> str: 189 | """(str) : Get this reference's full directive as ``lang:role``.""" 190 | if self.lang is None: 191 | return f"{self.role}" 192 | else: 193 | return f"{self.lang}:{self.role}" 194 | 195 | def __str__(self) -> str: 196 | """Get a string representation of this node reference. 197 | 198 | Returns 199 | ------- 200 | str 201 | ``:lang:role:`refname```. 202 | 203 | """ 204 | return f":{self.reftype}:`{self.refname}`" 205 | 206 | def __repr__(self) -> str: 207 | """Get a string representation suitable for debugging. 208 | 209 | Returns 210 | ------- 211 | str 212 | ```` 213 | 214 | """ 215 | return ( 216 | f"" 217 | ) 218 | 219 | 220 | # These are just for type-hints 221 | 222 | RawInvMetaData = Dict[str, Union[str, int]] 223 | # {"projname": str, "version" : str, "refcount": int} 224 | 225 | RawRefSpec = Tuple[str, str, str, str] 226 | # (projname, version, url, display_name) 227 | 228 | RawRefDict = Dict[str, RawRefSpec] 229 | RefDict = Dict[str, RefSpec] 230 | # {refname: refspec} 231 | 232 | RawInvData = Dict[str, RawRefDict] 233 | InvData = Dict[str, RefDict] 234 | # {reftype: refdict} 235 | 236 | FilterFunc = Callable[[str], bool] 237 | # filter_func(reftype) 238 | 239 | MatchesDict = Dict[str, List[RefSpec]] 240 | # {reftype: [refspec, ...]} 241 | -------------------------------------------------------------------------------- /errorlogs/__init__.py: -------------------------------------------------------------------------------- 1 | """ErrorLogs, a cog for logging command errors to a discord channel.""" 2 | import asyncio 3 | 4 | from redbot.core.bot import Red 5 | 6 | from .errorlogs import ErrorLogs 7 | 8 | __red_end_user_data_statement__ = "This cog does not store end user data." 9 | 10 | 11 | async def setup(bot: Red): 12 | cog = ErrorLogs() 13 | if asyncio.iscoroutinefunction(bot.add_cog): 14 | await bot.add_cog(cog) 15 | else: 16 | bot.add_cog(cog) 17 | -------------------------------------------------------------------------------- /errorlogs/errorlogs.py: -------------------------------------------------------------------------------- 1 | """Module for the ErrorLogs cog.""" 2 | import asyncio 3 | import contextlib 4 | import re 5 | import traceback 6 | from typing import Dict, List, Tuple, Union 7 | 8 | import discord 9 | from redbot.core import Config, checks, commands, data_manager 10 | from redbot.core.utils.chat_formatting import box, pagify 11 | 12 | from .reaction_menu import LogScrollingMenu 13 | 14 | __all__ = ["UNIQUE_ID", "ErrorLogs"] 15 | 16 | UNIQUE_ID = 0xD0A3CCBF 17 | IGNORED_ERRORS = ( 18 | commands.UserInputError, 19 | commands.DisabledCommand, 20 | commands.CommandNotFound, 21 | commands.CheckFailure, 22 | commands.NoPrivateMessage, 23 | commands.CommandOnCooldown, 24 | commands.MaxConcurrencyReached, 25 | ) 26 | LATEST_LOG_RE = re.compile(r"latest(?:-part(?P\d+))?\.log") 27 | 28 | 29 | class ErrorLogs(commands.Cog): 30 | """Log tracebacks of command errors in discord channels.""" 31 | 32 | def __init__(self): 33 | self.conf = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True) 34 | self.conf.register_channel(enabled=False, global_errors=False) 35 | 36 | self._tasks: List[asyncio.Task] = [] 37 | super().__init__() 38 | 39 | async def red_delete_data_for_user(self, **kwargs): 40 | pass # Nothing needs to be done as no end user data is stored, but this function needs to exist so the data API knows that a possible deletion would have been handled. 41 | 42 | @checks.is_owner() 43 | @commands.group(autohelp=False) 44 | async def errorlogs(self, ctx: commands.Context): 45 | """Manage error logs.""" 46 | if not ctx.invoked_subcommand: 47 | await ctx.send_help() 48 | settings = self.conf.channel(ctx.channel) 49 | await ctx.send( 50 | box( 51 | "Enabled in this channel: {}\n" 52 | "Errors are logged from: {}".format( 53 | await settings.enabled(), 54 | "Everywhere" 55 | if await settings.global_errors() 56 | else "This server only", 57 | ) 58 | ) 59 | ) 60 | 61 | @errorlogs.command(name="enabled") 62 | async def _errorlogs_enable(self, ctx: commands.Context, true_or_false: bool): 63 | """Enable or disable error logging.""" 64 | settings = self.conf.channel(ctx.channel) 65 | await settings.enabled.set(true_or_false) 66 | await ctx.send( 67 | "Done. Error logging is now {} in this channel.".format( 68 | "enabled" if true_or_false else "disabled" 69 | ) 70 | ) 71 | 72 | @errorlogs.command(name="global") 73 | async def _errorlogs_global(self, ctx: commands.Context, true_or_false: bool): 74 | """Enable or disable errors from all servers.""" 75 | settings = self.conf.channel(ctx.channel) 76 | await settings.global_errors.set(true_or_false) 77 | await ctx.send( 78 | "Done. From now, {} will be logged in this channel.".format( 79 | "all errors" if true_or_false else "only errors in this server" 80 | ) 81 | ) 82 | 83 | @errorlogs.command(name="scroll", aliases=["history"]) 84 | async def _errorlogs_scroll( 85 | self, ctx: commands.Context, page_size: int = 25, num_pages: int = 15 86 | ): 87 | """Scroll through the console's history. 88 | 89 | __**Arguments**__ 90 | `page_size`: (integer) The initial number of lines in each 91 | page. 92 | `num_pages`: (integer) The number of pages to read into the 93 | buffer. 94 | """ 95 | latest_logs = [] 96 | for path in (data_manager.core_data_path() / "logs").iterdir(): 97 | match = LATEST_LOG_RE.match(path.name) 98 | if match: 99 | latest_logs.append(path) 100 | 101 | if not latest_logs: 102 | await ctx.send("Nothing seems to have been logged yet!") 103 | return 104 | 105 | latest_logs.sort(reverse=True) 106 | 107 | task = asyncio.create_task( 108 | LogScrollingMenu.send(ctx, latest_logs, page_size, num_pages) 109 | ) 110 | task.add_done_callback(self._remove_task) 111 | self._tasks.append(task) 112 | 113 | @commands.Cog.listener() 114 | async def on_command_error( 115 | self, ctx: commands.Context, error: commands.CommandError 116 | ): 117 | """Fires when a command error occurs and logs them.""" 118 | if isinstance(error, IGNORED_ERRORS): 119 | return 120 | all_dict = await self.conf.all_channels() 121 | if not all_dict: 122 | return 123 | channels_and_settings = self._get_channels_and_settings(ctx, all_dict) 124 | if not channels_and_settings: 125 | return 126 | 127 | error_title = f"Exception in command `{ctx.command.qualified_name}` ¯\\_(ツ)_/¯" 128 | log = "".join( 129 | traceback.format_exception(type(error), error, error.__traceback__) 130 | ) 131 | msg_url = ctx.message.jump_url 132 | 133 | embed = discord.Embed( 134 | title=error_title, 135 | colour=discord.Colour.red(), 136 | timestamp=ctx.message.created_at, 137 | description=f"[Jump to message]({msg_url})", 138 | ) 139 | embed.add_field(name="Invoker", value=f"{ctx.author.mention}\n{ctx.author}\n") 140 | embed.add_field(name="Content", value=ctx.message.content) 141 | _channel_disp = ( 142 | "{}\n({})".format(ctx.channel.mention, ctx.channel.name) 143 | if ctx.guild is not None 144 | else str(ctx.channel) 145 | ) 146 | embed.add_field(name="Channel", value=_channel_disp) 147 | 148 | nonembed_context = f"Invoker: {ctx.author}\nContent: {ctx.message.content}\n" 149 | 150 | if ctx.guild is not None: 151 | embed.add_field(name="Server", value=ctx.guild.name) 152 | nonembed_context += ( 153 | f"Channel: #{ctx.channel.name}\nServer: {ctx.guild.name}" 154 | ) 155 | else: 156 | nonembed_context += "Channel " + str(ctx.channel) 157 | 158 | nonembed_message = f"{error_title} {msg_url} " + box( 159 | nonembed_context, lang="yaml" 160 | ) 161 | 162 | for channel, settings in channels_and_settings: 163 | diff_guild = not settings.get("global_errors") and ( 164 | channel.guild is None or channel.guild.id != ctx.guild.id 165 | ) 166 | if diff_guild: 167 | continue 168 | if channel.permissions_for( 169 | getattr(channel, "guild", channel).me 170 | ).embed_links: 171 | await channel.send(embed=embed) 172 | else: 173 | await channel.send(nonembed_message) 174 | for page in pagify(log): 175 | await channel.send(box(page, lang="py")) 176 | 177 | def cog_unload(self): 178 | for task in self._tasks: 179 | task.cancel() 180 | self._tasks.clear() 181 | 182 | def _remove_task(self, task: asyncio.Task) -> None: 183 | with contextlib.suppress(ValueError): 184 | self._tasks.remove(task) 185 | 186 | @staticmethod 187 | def _get_channels_and_settings( 188 | ctx: commands.Context, all_dict: Dict[int, Dict[str, bool]] 189 | ) -> List[Tuple[Union[discord.TextChannel, discord.DMChannel], Dict[str, bool]]]: 190 | ret: List[Tuple[discord.TextChannel, Dict[str, bool]]] = [] 191 | for channel_id, channel_settings in all_dict.items(): 192 | channel = ctx.bot.get_channel(channel_id) 193 | if channel is None or not channel_settings.get("enabled"): 194 | continue 195 | if not channel_settings.get("global_errors"): 196 | if ctx.guild != getattr(channel, "guild", ...): 197 | continue 198 | ret.append((channel, channel_settings)) 199 | return ret 200 | -------------------------------------------------------------------------------- /errorlogs/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["Tobotimus"], 3 | "description": "ErrorLogs automatically outputs tracebacks of command errors in any text channel(s) you specify. It includes per-channel settings to determine whether global or server-wide errors should be posted.", 4 | "install_msg": "Thanks for installing `errorlogs`. See `[p]help errorlogs` for details.", 5 | "short": "Log error tracebacks in text channels.", 6 | "hidden": false, 7 | "disabled": false, 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["error", "debug", "log"], 11 | "type": "COG", 12 | "end_user_data_statement":"This cog does not store end user data." 13 | } 14 | -------------------------------------------------------------------------------- /errorlogs/reaction_menu.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import inspect 4 | import itertools 5 | import pathlib 6 | from collections import deque 7 | from typing import Awaitable, Callable, Dict, List 8 | 9 | import discord 10 | from redbot.core import commands 11 | from redbot.core.utils import chat_formatting as chatutils, menus as menutils 12 | 13 | MAX_CONTENT_SIZE = 2000 - len("```ini```\n\n") 14 | 15 | 16 | def button(emoji: str): 17 | def decorator(func): 18 | try: 19 | func.__react_to__.append(emoji) 20 | except AttributeError: 21 | func.__react_to__ = [emoji] 22 | return func 23 | 24 | return decorator 25 | 26 | 27 | # noinspection PyUnusedLocal 28 | class LogScrollingMenu: 29 | 30 | _handlers: Dict[ 31 | str, 32 | Callable[["LogScrollingMenu", discord.RawReactionActionEvent], Awaitable[None]], 33 | ] = {} 34 | 35 | def __init__(self, ctx: commands.Context, lines: List[str], page_size: int) -> None: 36 | self.ctx = ctx 37 | self.message = None 38 | 39 | self._lines = lines 40 | self._page_size = page_size 41 | self._end_pos = len(self._lines) 42 | self._start_pos = self._end_pos - page_size 43 | self._done_event = asyncio.Event() 44 | 45 | @classmethod 46 | async def send( 47 | cls, 48 | ctx: commands.Context, 49 | logfiles: List[pathlib.Path], 50 | page_size: int = 25, 51 | num_pages: int = 15, 52 | ): 53 | lines = deque(maxlen=num_pages * page_size + 2) 54 | for logfile_path in logfiles: 55 | new_lines = deque(maxlen=lines.maxlen - len(lines)) 56 | with logfile_path.open() as fs: 57 | new_lines.extend(fs.readlines()) 58 | lines = deque( 59 | iterable=itertools.chain(new_lines, lines), maxlen=lines.maxlen 60 | ) 61 | del new_lines 62 | if len(lines) >= lines.maxlen: 63 | break 64 | lines.popleft() 65 | lines.popleft() 66 | lines.appendleft("# START OF LOG BUFFER\n") 67 | lines.append("# END OF LOG\n") 68 | 69 | self = cls(ctx, list(lines), page_size) 70 | 71 | self.ctx.bot.add_listener(self.on_raw_reaction, "on_raw_reaction_add") 72 | self.ctx.bot.add_listener(self.on_raw_reaction, "on_raw_reaction_remove") 73 | try: 74 | await asyncio.shield(self.wait()) 75 | except asyncio.CancelledError: 76 | if not self._done_event.is_set() and self.message is not None: 77 | with contextlib.suppress(discord.NotFound): 78 | await self.message.delete() 79 | finally: 80 | self.ctx.bot.remove_listener(self.on_raw_reaction, "on_raw_reaction_add") 81 | self.ctx.bot.remove_listener(self.on_raw_reaction, "on_raw_reaction_remove") 82 | 83 | async def wait(self) -> None: 84 | await self._update_message() 85 | await self._done_event.wait() 86 | 87 | async def on_raw_reaction(self, payload: discord.RawReactionActionEvent) -> None: 88 | if not self._same_context(payload): 89 | return 90 | 91 | try: 92 | handler = self._handlers[payload.emoji.name] 93 | except KeyError: 94 | return 95 | else: 96 | await handler(self, payload) 97 | 98 | @button("\N{UPWARDS BLACK ARROW}") 99 | async def scroll_up(self, payload: discord.RawReactionActionEvent) -> None: 100 | if self._start_pos <= 0: 101 | return 102 | self._start_pos -= 1 103 | self._end_pos = self._start_pos + self._page_size 104 | await self._update_message(pin="start") 105 | 106 | @button("\N{DOWNWARDS BLACK ARROW}") 107 | async def scroll_down(self, payload: discord.RawReactionActionEvent) -> None: 108 | if self._end_pos >= len(self._lines): 109 | return 110 | self._end_pos += 1 111 | self._start_pos = self._end_pos - self._page_size 112 | await self._update_message(pin="end") 113 | 114 | @button("\N{BLACK UP-POINTING DOUBLE TRIANGLE}") 115 | async def page_up(self, payload: discord.RawReactionActionEvent) -> None: 116 | if self._start_pos <= 0: 117 | return 118 | self._end_pos = self._start_pos 119 | self._start_pos = max(self._end_pos - self._page_size, 0) 120 | await self._update_message(pin="end") 121 | 122 | @button("\N{BLACK DOWN-POINTING DOUBLE TRIANGLE}") 123 | async def page_down(self, payload: discord.RawReactionActionEvent) -> None: 124 | if self._end_pos >= len(self._lines): 125 | return 126 | self._start_pos = self._end_pos 127 | self._end_pos = self._start_pos + self._page_size 128 | await self._update_message(pin="start") 129 | 130 | @button("\N{UP DOWN ARROW}") 131 | async def expand(self, payload: discord.RawReactionActionEvent) -> None: 132 | self._page_size += 2 133 | if self._start_pos <= 0 and self._end_pos >= len(self._lines): 134 | return 135 | self._start_pos = max(self._start_pos - 1, 0) 136 | self._end_pos = min(self._end_pos + 1, len(self._lines)) 137 | await self._update_message() 138 | 139 | @button("\N{END WITH LEFTWARDS ARROW ABOVE}") 140 | async def go_to_end(self, payload: discord.RawReactionActionEvent) -> None: 141 | if self._end_pos >= len(self._lines): 142 | return 143 | self._end_pos = len(self._lines) 144 | self._start_pos = self._end_pos - self._page_size 145 | await self._update_message(pin="end") 146 | 147 | @button("\N{CROSS MARK}") 148 | async def exit_menu(self, payload: discord.RawReactionActionEvent) -> None: 149 | self._done_event.set() 150 | await self.message.delete() 151 | 152 | def _same_context(self, payload: discord.RawReactionActionEvent) -> bool: 153 | return ( 154 | payload.message_id == self.message.id 155 | and payload.user_id == self.ctx.author.id 156 | ) 157 | 158 | async def _update_message(self, *, pin: str = "end") -> None: 159 | joined_lines = "".join(self._lines[self._start_pos : self._end_pos]) 160 | 161 | if len(joined_lines) > MAX_CONTENT_SIZE: 162 | if pin == "start": 163 | cutoff = joined_lines.rfind("\n", 0, MAX_CONTENT_SIZE) 164 | joined_lines = joined_lines[:cutoff] 165 | else: 166 | cutoff = joined_lines.find("\n", -MAX_CONTENT_SIZE) 167 | joined_lines = joined_lines[cutoff + 1 :] 168 | 169 | rendered_page_size = joined_lines.count("\n") 170 | if pin == "start": 171 | self._end_pos = self._start_pos + rendered_page_size 172 | if self._end_pos >= len(self._lines) and pin == "start": 173 | while rendered_page_size < self._page_size: 174 | try: 175 | new_line = self._lines[self._start_pos - 1] 176 | except IndexError: 177 | break 178 | else: 179 | if len(joined_lines) + len(new_line) > MAX_CONTENT_SIZE: 180 | break 181 | joined_lines = new_line + joined_lines 182 | self._start_pos -= 1 183 | rendered_page_size += 1 184 | elif pin == "end": 185 | self._start_pos = self._end_pos - rendered_page_size 186 | if self._start_pos <= 0 and pin == "end": 187 | while rendered_page_size < self._page_size: 188 | try: 189 | new_line = self._lines[self._end_pos] 190 | except IndexError: 191 | break 192 | else: 193 | if len(joined_lines) + len(new_line) > MAX_CONTENT_SIZE: 194 | break 195 | joined_lines += new_line 196 | self._end_pos += 1 197 | rendered_page_size += 1 198 | 199 | content = chatutils.box(joined_lines, lang="ini") 200 | if self.message is None: 201 | self.message = await self.ctx.send(content) 202 | menutils.start_adding_reactions(self.message, self._handlers.keys()) 203 | else: 204 | try: 205 | await self.message.edit(content=content) 206 | except discord.NotFound: 207 | self._done_event.set() 208 | 209 | 210 | for _, _method in reversed( 211 | inspect.getmembers(LogScrollingMenu, inspect.iscoroutinefunction) 212 | ): 213 | try: 214 | _emojis = _method.__react_to__ 215 | except AttributeError: 216 | continue 217 | else: 218 | for _emoji in _emojis: 219 | # noinspection PyProtectedMember 220 | LogScrollingMenu._handlers[_emoji] = _method 221 | -------------------------------------------------------------------------------- /info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tobo-Cogs", 3 | "author": ["Tobotimus"], 4 | "short": "Cogs made by Tobotimus.", 5 | "description": "Various cogs for bot management and customisation, server administration and moderation, and some fun stuff for users too. All cogs are made to be easy for anyone to use!", 6 | "install_msg": "Hi, thanks for installing my humble repository! If you ever need help with the cog, or you think you found something wrong, come let me know in the *#support_tobo-cogs* channel over at the Red Cog Support server: .\n\n - Tobotimus", 7 | "requirements": [], 8 | "tags": [] 9 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "Tobo-Cogs" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Toby Harradine "] 6 | license = "GPL-3.0" 7 | 8 | [tool.poetry.dependencies] 9 | python = ">=3.8.1,<3.10" 10 | "discord.py" = "*" 11 | Red-DiscordBot = "*" 12 | sphinx = "*" 13 | tabulate = { version = "*", extras = [ "widechars" ] } 14 | 15 | [tool.poetry.dev-dependencies] 16 | black = "*" 17 | "discord.py-stubs" = "*" 18 | 19 | [build-system] 20 | requires = ["poetry-core>=1.0.0"] 21 | build-backend = "poetry.core.masonry.api" 22 | -------------------------------------------------------------------------------- /reactkarma/__init__.py: -------------------------------------------------------------------------------- 1 | """ReactKarma - Upvote and downvote messages to give people karma.""" 2 | import asyncio 3 | from redbot.core.bot import Red 4 | 5 | from .reactkarma import ReactKarma 6 | 7 | 8 | async def setup(bot: Red): 9 | cog = ReactKarma() 10 | if asyncio.iscoroutinefunction(bot.add_cog): 11 | await bot.add_cog(cog) 12 | else: 13 | bot.add_cog(cog) 14 | -------------------------------------------------------------------------------- /reactkarma/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["Tobotimus"], 3 | "description": "ReactKarma is a cog for counting upvotes and downvotes of everyone the bot sees. The karma leaderboard is global, however the cog can be effectively enabled and disabled per-server.", 4 | "install_msg": "Thanks for installing `reactkarma`. See `[p]help ReactKarma` for details.", 5 | "short": "Like Reddit Karma, but with discord reactions!", 6 | "hidden": false, 7 | "disabled": false, 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["karma", "upvote", "downvote"], 11 | "type": "COG" 12 | } 13 | -------------------------------------------------------------------------------- /reactkarma/reactkarma.py: -------------------------------------------------------------------------------- 1 | """Module for the ReactKarma cog.""" 2 | import asyncio 3 | import logging 4 | from collections import namedtuple 5 | 6 | import discord 7 | from redbot.core import Config, checks, commands 8 | from redbot.core.utils.chat_formatting import box, pagify 9 | 10 | log = logging.getLogger("red.reactkarma") 11 | 12 | __all__ = ["UNIQUE_ID", "ReactKarma"] 13 | 14 | UNIQUE_ID = 0x9C02DCC7 15 | MemberInfo = namedtuple("MemberInfo", "id name karma") 16 | 17 | 18 | class ReactKarma(getattr(commands, "Cog", object)): 19 | """Keep track of karma for all users in the bot's scope. 20 | 21 | Emojis which affect karma are customised by the owner. 22 | Upvotes add 1 karma. Downvotes subtract 1 karma. 23 | """ 24 | 25 | def __init__(self): 26 | self.conf = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True) 27 | self.conf.register_user(karma=0) 28 | self.conf.register_guild(upvote=None, downvote=None) 29 | 30 | @commands.command() 31 | @commands.guild_only() 32 | async def upvote(self, ctx: commands.Context): 33 | """See this server's upvote emoji.""" 34 | emoji = await self.conf.guild(ctx.guild).upvote() 35 | if isinstance(emoji, int): 36 | emoji = ctx.bot.get_emoji(emoji) 37 | if emoji is None: 38 | reply = ( 39 | "The upvote emoji in this server is not set." 40 | " Use `{0}setupvote` to do so (requires `manage emojis`" 41 | " permission).".format(ctx.prefix) 42 | ) 43 | else: 44 | reply = "The upvote emoji in this server is {!s}".format(emoji) 45 | await ctx.send(reply) 46 | 47 | @commands.command() 48 | @commands.guild_only() 49 | async def downvote(self, ctx: commands.Context): 50 | """See this server's downvote emoji.""" 51 | emoji = await self.conf.guild(ctx.guild).downvote() 52 | if isinstance(emoji, int): 53 | emoji = ctx.bot.get_emoji(emoji) 54 | if emoji is None: 55 | reply = ( 56 | "The downvote emoji in this server is not set. Admins use" 57 | " `{0}setdownvote` to do so (requires `manage emojis`" 58 | " permission).".format(ctx.prefix) 59 | ) 60 | else: 61 | reply = "The downvote emoji in this server is {!s}".format(emoji) 62 | await ctx.send(reply) 63 | 64 | @commands.command() 65 | async def karmaboard(self, ctx: commands.Context, top: int = 10): 66 | """Prints out the karma leaderboard. 67 | 68 | Defaults to top 10. Use negative numbers to reverse the leaderboard. 69 | """ 70 | reverse = True 71 | if top == 0: 72 | top = 10 73 | elif top < 0: 74 | reverse = False 75 | top = -top 76 | members_sorted = sorted( 77 | await self._get_all_members(ctx.bot), key=lambda x: x.karma, reverse=reverse 78 | ) 79 | if len(members_sorted) < top: 80 | top = len(members_sorted) 81 | topten = members_sorted[:top] 82 | highscore = "" 83 | place = 1 84 | for member in topten: 85 | highscore += str(place).ljust(len(str(top)) + 1) 86 | highscore += "{} | ".format(member.name).ljust(18 - len(str(member.karma))) 87 | highscore += str(member.karma) + "\n" 88 | place += 1 89 | if highscore != "": 90 | for page in pagify(highscore, shorten_by=12): 91 | await ctx.send(box(page, lang="py")) 92 | else: 93 | await ctx.send("No one has any karma 🙁") 94 | 95 | @commands.command(name="karma") 96 | @commands.guild_only() 97 | async def get_karma(self, ctx: commands.Context, user: discord.Member = None): 98 | """Check a user's karma. 99 | 100 | Leave [user] blank to see your own karma. 101 | """ 102 | if user is None: 103 | user = ctx.author 104 | karma = await self.conf.user(user).karma() 105 | await ctx.send("{0} has {1} karma.".format(user.display_name, karma)) 106 | 107 | @commands.command(name="setupvote") 108 | @commands.guild_only() 109 | @checks.admin_or_permissions(manage_emojis=True) 110 | async def set_upvote(self, ctx: commands.Context): 111 | """Set the upvote emoji in this server. 112 | 113 | Only the first reaction from the command author will be added. 114 | """ 115 | await self._interactive_emoji_setup(ctx, "upvote") 116 | 117 | @commands.command(name="setdownvote") 118 | @commands.guild_only() 119 | @checks.admin_or_permissions(manage_emojis=True) 120 | async def set_downvote(self, ctx: commands.Context): 121 | """Add a downvote emoji by reacting to the bot's response. 122 | 123 | Only the first reaction from the command author will be added. 124 | """ 125 | await self._interactive_emoji_setup(ctx, "downvote") 126 | 127 | async def _interactive_emoji_setup(self, ctx: commands.Context, type_: str): 128 | msg = await ctx.send("React to my message with the new {} emoji!".format(type_)) 129 | try: 130 | reaction, _ = await ctx.bot.wait_for( 131 | "reaction_add", 132 | check=lambda r, u: u == ctx.author and r.message.id == msg.id, 133 | timeout=30.0, 134 | ) 135 | except asyncio.TimeoutError: 136 | await ctx.send("Setting the {} emoji was cancelled.".format(type_)) 137 | return 138 | emoji = reaction.emoji 139 | if isinstance(emoji, discord.Emoji): 140 | save = emoji.id 141 | elif isinstance(emoji, discord.PartialEmoji): 142 | await ctx.send( 143 | "Setting the {} failed. This is a custom emoji" 144 | " which I cannot see.".format(type_) 145 | ) 146 | return 147 | else: 148 | save = emoji 149 | value = getattr(self.conf.guild(ctx.guild), type_) 150 | await value.set(save) 151 | await ctx.send( 152 | "Done! The {} emoji in this server is now {!s}".format(type_, emoji) 153 | ) 154 | 155 | @commands.command(name="resetkarma") 156 | @checks.is_owner() 157 | async def reset_karma(self, ctx: commands.Context, user: discord.Member): 158 | """Resets a user's karma.""" 159 | log.debug("Resetting %s's karma", str(user)) 160 | # noinspection PyTypeChecker 161 | await self.conf.user(user).karma.set(0) 162 | await ctx.send("{}'s karma has been reset to 0.".format(user.display_name)) 163 | 164 | @commands.Cog.listener() 165 | async def on_reaction_add(self, reaction: discord.Reaction, user: discord.User): 166 | """Fires when the bot sees a reaction being added, and updates karma. 167 | 168 | Ignores Private Channels and users reacting to their own message. 169 | """ 170 | await self._check_reaction(reaction, user, added=True) 171 | 172 | @commands.Cog.listener() 173 | async def on_reaction_remove(self, reaction: discord.Reaction, user: discord.User): 174 | """Fires when the bot sees a reaction being removed, and updates karma. 175 | 176 | Ignores Private Channels and users reacting to their own message. 177 | """ 178 | await self._check_reaction(reaction, user, added=False) 179 | 180 | async def _check_reaction( 181 | self, reaction: discord.Reaction, user: discord.User, *, added: bool 182 | ): 183 | message = reaction.message 184 | (author, channel, guild) = (message.author, message.channel, message.guild) 185 | if ( 186 | author == user 187 | or user.bot 188 | or isinstance(channel, discord.abc.PrivateChannel) 189 | ): 190 | return 191 | emoji = reaction.emoji 192 | upvote = await self._is_upvote(guild, emoji) 193 | if upvote is not None: 194 | await self._add_karma(author, 1 if upvote == added else -1) 195 | 196 | async def _add_karma(self, user: discord.User, amount: int): 197 | settings = self.conf.user(user) 198 | karma = await settings.karma() 199 | await settings.karma.set(karma + amount) 200 | 201 | async def _get_emoji_id(self, guild: discord.Guild, *, upvote: bool): 202 | if upvote: 203 | emoji = await self.conf.guild(guild).upvote() 204 | else: 205 | emoji = await self.conf.guild(guild).downvote() 206 | return emoji 207 | 208 | async def _is_upvote(self, guild: discord.Guild, emoji): 209 | """Check if the given emoji is an upvote. 210 | 211 | Returns True if the emoji is the upvote emoji, False f it is the 212 | downvote emoji, and None otherwise. 213 | """ 214 | upvote = await self.conf.guild(guild).upvote() 215 | downvote = await self.conf.guild(guild).downvote() 216 | if isinstance(upvote, int) and isinstance(emoji, discord.Emoji): 217 | if emoji.id == upvote: 218 | return True 219 | if emoji == downvote: 220 | return False 221 | if emoji == upvote: 222 | return True 223 | elif emoji == downvote: 224 | return False 225 | 226 | async def _get_all_members(self, bot): 227 | """Get a list of members which have karma. 228 | 229 | Returns a list of named tuples with values for `name`, `id`, `karma`. 230 | """ 231 | ret = [] 232 | for user_id, conf in (await self.conf.all_users()).items(): 233 | karma = conf.get("karma") 234 | if not karma: 235 | continue 236 | user = bot.get_user(user_id) 237 | if user is None: 238 | continue 239 | ret.append(MemberInfo(id=user_id, name=str(user), karma=karma)) 240 | return ret 241 | -------------------------------------------------------------------------------- /sticky/__init__.py: -------------------------------------------------------------------------------- 1 | """Sticky - Sticky messages to a channel.""" 2 | import asyncio 3 | from redbot.core.bot import Red 4 | 5 | from .sticky import Sticky 6 | 7 | 8 | async def setup(bot: Red): 9 | """Load Sticky.""" 10 | cog = Sticky(bot) 11 | if asyncio.iscoroutinefunction(bot.add_cog): 12 | await bot.add_cog(cog) 13 | else: 14 | bot.add_cog(cog) 15 | -------------------------------------------------------------------------------- /sticky/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["Tobotimus"], 3 | "description": "Sticky is a cog for sticking a message at the bottom of a channel. When a user sends a message in that channel, the bot will delete its old message and repost it, so it 'sticks' to the bottom.", 4 | "install_msg": "Thanks for installing `sticky`. See `[p]help Sticky` for details.", 5 | "short": "Sticky messages for your channels!", 6 | "hidden": false, 7 | "disabled": false, 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["moderation", "channeltools"], 11 | "type": "COG" 12 | } 13 | -------------------------------------------------------------------------------- /sticky/sticky.py: -------------------------------------------------------------------------------- 1 | """Module for the Sticky cog.""" 2 | import asyncio 3 | import contextlib 4 | import logging 5 | from datetime import datetime, timezone 6 | from typing import Any, Dict, Optional, cast 7 | 8 | import discord 9 | from redbot.core import Config, checks, commands 10 | from redbot.core.utils.menus import start_adding_reactions 11 | from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate 12 | 13 | UNIQUE_ID = 0x6AFE8000 14 | 15 | log = logging.getLogger("red.sticky") 16 | 17 | 18 | class Sticky(commands.Cog): 19 | """Sticky messages to your channels.""" 20 | 21 | REPOST_COOLDOWN = 3 22 | 23 | def __init__(self, bot): 24 | super().__init__() 25 | 26 | self.bot = bot 27 | self.conf = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True) 28 | self.conf.register_channel( 29 | stickied=None, # This is for [p]sticky 30 | header_enabled=True, 31 | advstickied={"content": None, "embed": {}}, # This is for [p]stickyexisting 32 | last=None, 33 | ) 34 | self.locked_channels = set() 35 | self._channel_cvs: Dict[discord.TextChannel, asyncio.Condition] = {} 36 | 37 | @checks.mod_or_permissions(manage_messages=True) 38 | @commands.guild_only() 39 | @commands.group(invoke_without_command=True) 40 | async def sticky(self, ctx: commands.Context, *, content: str): 41 | """Sticky a message to this channel.""" 42 | channel = ctx.channel 43 | settings = self.conf.channel(channel) 44 | 45 | async with settings.all() as settings_dict: 46 | settings_dict = cast(Dict[str, Any], settings_dict) 47 | 48 | settings_dict.pop("advstickied", None) 49 | settings_dict["stickied"] = content 50 | 51 | msg = await self._send_stickied_message(channel, settings_dict) 52 | 53 | if settings_dict["last"] is not None: 54 | last_message = channel.get_partial_message(settings_dict["last"]) 55 | with contextlib.suppress(discord.NotFound): 56 | await last_message.delete() 57 | 58 | settings_dict["last"] = msg.id 59 | 60 | @checks.mod_or_permissions(manage_messages=True) 61 | @commands.guild_only() 62 | @sticky.command(name="existing") 63 | async def sticky_existing( 64 | self, ctx: commands.Context, *, message_id_or_url: discord.Message 65 | ): 66 | """Sticky an existing message to this channel. 67 | 68 | This will try to sticky the content and embed of the message. 69 | Attachments will not be added to the stickied message. 70 | 71 | Stickying messages with multiple embeds may result in unexpected 72 | behaviour, as the bot cannot send multiple rich embeds in a 73 | single message. 74 | """ 75 | message = message_id_or_url 76 | del message_id_or_url 77 | channel = ctx.channel 78 | settings = self.conf.channel(channel) 79 | if not (message.content or message.embeds): 80 | await ctx.send("That message doesn't have any content or embed!") 81 | return 82 | embed = next(iter(message.embeds), None) 83 | content = message.content or None 84 | embed_data = embed.to_dict() if embed is not None else None 85 | 86 | async with settings.all() as settings_dict: 87 | settings_dict = cast(Dict[str, Any], settings_dict) 88 | 89 | settings_dict.pop("stickied", None) 90 | settings_dict["advstickied"] = {"content": content, "embed": embed_data} 91 | 92 | msg = await self._send_stickied_message(channel, settings_dict) 93 | 94 | if settings_dict["last"] is not None: 95 | last_message = channel.get_partial_message(settings_dict["last"]) 96 | with contextlib.suppress(discord.NotFound): 97 | await last_message.delete() 98 | 99 | settings_dict["last"] = msg.id 100 | 101 | @checks.mod_or_permissions(manage_messages=True) 102 | @commands.guild_only() 103 | @sticky.command(name="toggleheader") 104 | async def sticky_toggleheader(self, ctx: commands.Context, true_or_false: bool): 105 | """Toggle the header for stickied messages in this channel. 106 | 107 | The header is enabled by default. 108 | """ 109 | await self.conf.channel(ctx.channel).header_enabled.set(true_or_false) 110 | await ctx.tick() 111 | 112 | @checks.mod_or_permissions(manage_messages=True) 113 | @commands.guild_only() 114 | @commands.command() 115 | async def unsticky(self, ctx: commands.Context, force: bool = False): 116 | """Remove the sticky message from this channel. 117 | 118 | Deleting the sticky message will also unsticky it. 119 | 120 | Do `[p]unsticky yes` to skip the confirmation prompt. 121 | """ 122 | channel = ctx.channel 123 | settings = self.conf.channel(channel) 124 | async with self._lock_channel(channel): 125 | last_id = await settings.last() 126 | if last_id is None: 127 | await ctx.send("There is no stickied message in this channel.") 128 | return 129 | 130 | if not (force or await self._confirm_unsticky(ctx)): 131 | return 132 | 133 | await settings.set( 134 | # Preserve the header setting 135 | {"header_enabled": await settings.header_enabled()} 136 | ) 137 | last = channel.get_partial_message(last_id) 138 | with contextlib.suppress(discord.HTTPException): 139 | await last.delete() 140 | 141 | await ctx.tick() 142 | 143 | @commands.Cog.listener() 144 | async def on_message(self, message: discord.Message): 145 | """Event which checks for sticky messages to resend.""" 146 | channel = message.channel 147 | if isinstance(channel, discord.abc.PrivateChannel): 148 | return 149 | 150 | await self._maybe_repost_stickied_message( 151 | channel, 152 | responding_to_message=message, 153 | delete_last=True, 154 | ) 155 | 156 | @commands.Cog.listener() 157 | async def on_raw_message_delete( 158 | self, payload: discord.raw_models.RawMessageDeleteEvent 159 | ): 160 | """If the stickied message was deleted, re-post it.""" 161 | channel = self.bot.get_channel(payload.channel_id) 162 | settings = self.conf.channel(channel) 163 | if payload.message_id != await settings.last(): 164 | return 165 | 166 | await self._maybe_repost_stickied_message(channel) 167 | 168 | async def _maybe_repost_stickied_message( 169 | self, 170 | channel: discord.TextChannel, 171 | responding_to_message: Optional[discord.Message] = None, 172 | *, 173 | delete_last: bool = False, 174 | ) -> None: 175 | cv = self._channel_cvs.setdefault(channel, asyncio.Condition()) 176 | settings = self.conf.channel(channel) 177 | 178 | async with cv: 179 | await cv.wait_for(lambda: channel not in self.locked_channels) 180 | 181 | settings_dict = await settings.all() 182 | last_message_id = settings_dict["last"] 183 | if last_message_id is None: 184 | return 185 | 186 | last_message = channel.get_partial_message(last_message_id) 187 | if responding_to_message and ( 188 | # We don't want to respond to our own message, and we 189 | # don't want to respond to a message older than our last 190 | # message. 191 | responding_to_message.id == last_message_id 192 | or responding_to_message.created_at < last_message.created_at 193 | ): 194 | return 195 | 196 | # Discord.py 2.0 uses timezone-aware timestamps, so we need 197 | # to do the same to compare the timestamps. 198 | if last_message.created_at.tzinfo is None: 199 | utcnow = datetime.utcnow() 200 | else: 201 | utcnow = datetime.now(timezone.utc) 202 | 203 | time_since = utcnow - last_message.created_at 204 | time_to_wait = self.REPOST_COOLDOWN - time_since.total_seconds() 205 | if time_to_wait > 0: 206 | await asyncio.sleep(time_to_wait) 207 | 208 | if not ( 209 | settings_dict["stickied"] or any(settings_dict["advstickied"].values()) 210 | ): 211 | # There's nothing to send 212 | await settings.last.clear() 213 | return 214 | 215 | new = await self._send_stickied_message(channel, settings_dict) 216 | 217 | await settings.last.set(new.id) 218 | 219 | if delete_last: 220 | with contextlib.suppress(discord.NotFound): 221 | await last_message.delete() 222 | 223 | @staticmethod 224 | async def _send_stickied_message( 225 | channel: discord.TextChannel, settings_dict: Dict[str, Any] 226 | ): 227 | """Send the content and/or embed as a stickied message.""" 228 | embed = None 229 | header_enabled = settings_dict["header_enabled"] 230 | header_text = "__***Stickied Message***__" 231 | if settings_dict.get("stickied") is not None: 232 | content = settings_dict["stickied"] 233 | if header_enabled: 234 | content = f"{header_text}\n\n{content}" 235 | else: 236 | content = settings_dict["advstickied"]["content"] 237 | embed_dict = settings_dict["advstickied"]["embed"] 238 | if embed_dict: 239 | embed = discord.Embed.from_dict(embed_dict) 240 | if header_enabled: 241 | content = f"{header_text}\n\n{content}" if content else header_text 242 | 243 | return await channel.send(content, embed=embed) 244 | 245 | @contextlib.asynccontextmanager 246 | async def _lock_channel(self, channel: discord.TextChannel) -> None: 247 | cv = self._channel_cvs.setdefault(channel, asyncio.Condition()) 248 | async with cv: 249 | self.locked_channels.add(channel) 250 | try: 251 | yield 252 | finally: 253 | with contextlib.suppress(KeyError): 254 | self.locked_channels.remove(channel) 255 | cv.notify_all() 256 | 257 | @staticmethod 258 | async def _confirm_unsticky(ctx: commands.Context) -> bool: 259 | msg_content = ( 260 | "This will unsticky the current sticky message from " 261 | "this channel. Are you sure you want to do this?" 262 | ) 263 | if not ctx.channel.permissions_for(ctx.me).add_reactions: 264 | event = "message" 265 | msg = await ctx.send(f"{msg_content} (y/n)") 266 | predicate = MessagePredicate.yes_or_no(ctx) 267 | else: 268 | event = "reaction_add" 269 | msg = await ctx.send( 270 | "This will unsticky the current sticky message from " 271 | "this channel. Are you sure you want to do this?" 272 | ) 273 | predicate = ReactionPredicate.yes_or_no(msg, ctx.author) 274 | start_adding_reactions(msg, emojis=ReactionPredicate.YES_OR_NO_EMOJIS) 275 | 276 | try: 277 | resp = await ctx.bot.wait_for(event, check=predicate, timeout=30) 278 | except asyncio.TimeoutError: 279 | resp = None 280 | if resp is None or not predicate.result: 281 | with contextlib.suppress(discord.NotFound): 282 | await msg.delete() 283 | 284 | return predicate.result 285 | -------------------------------------------------------------------------------- /streamroles/__init__.py: -------------------------------------------------------------------------------- 1 | """StreamRoles - Give roles to streaming users.""" 2 | import asyncio 3 | import logging 4 | from redbot.core.bot import Red 5 | 6 | from .streamroles import StreamRoles 7 | 8 | log = logging.getLogger("red.streamroles") 9 | 10 | 11 | async def setup(bot: Red): 12 | cog = StreamRoles(bot) 13 | await cog.initialize() 14 | 15 | if asyncio.iscoroutinefunction(bot.add_cog): 16 | await bot.add_cog(cog) 17 | else: 18 | bot.add_cog(cog) 19 | -------------------------------------------------------------------------------- /streamroles/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["Tobotimus"], 3 | "description": "StreamRoles is a cog for automatically assigning roles to users streaming on Twitch. The cog is also able to only assign a streaming role to users of a particular game. Specific users can also be blacklisted and whitelisted.\n\nDisclaimer: The first streamrole cog was made by tmerc, as some of you may know. This cog was written completely from the ground up by myself, and although I was unaware he had written a similar cog already, he should be given credit for coming up with the idea first.", 4 | "install_msg": "Thanks for installing `streamroles`. See `[p]help streamroles` for details.", 5 | "short": "Hoist users streaming on Twitch.", 6 | "hidden": false, 7 | "disabled": false, 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["streamers", "hoist", "twitch"], 11 | "type": "COG" 12 | } 13 | -------------------------------------------------------------------------------- /streamroles/streamroles.py: -------------------------------------------------------------------------------- 1 | """Module for the StreamRoles cog.""" 2 | import asyncio 3 | import contextlib 4 | import logging 5 | from typing import List, Optional, Tuple, Union 6 | 7 | import discord 8 | from redbot.core import Config, checks, commands 9 | from redbot.core.bot import Red 10 | from redbot.core.utils import chat_formatting as chatutils, menus, predicates 11 | 12 | from .types import FilterList 13 | 14 | log = logging.getLogger("red.streamroles") 15 | 16 | UNIQUE_ID = 0x923476AF 17 | 18 | _alerts_channel_sentinel = object() 19 | 20 | 21 | class StreamRoles(commands.Cog): 22 | """Give current twitch streamers in your server a role.""" 23 | 24 | # Set using [p]eval or something rather and the streamrole will be assigned simply 25 | # whenever someone is streaming, regardless of whether or not they have a linked 26 | # Twitch account. Makes for easier black-box testing. 27 | DEBUG_MODE = False 28 | 29 | def __init__(self, bot: Red): 30 | super().__init__() 31 | self.bot: Red = bot 32 | self.conf = Config.get_conf(self, force_registration=True, identifier=UNIQUE_ID) 33 | self.conf.register_guild( 34 | streamer_role=None, 35 | game_whitelist=[], 36 | mode=str(FilterList.blacklist), 37 | alerts__enabled=False, 38 | alerts__channel=None, 39 | alerts__autodelete=True, 40 | ) 41 | self.conf.register_member( 42 | blacklisted=False, whitelisted=False, alert_messages={} 43 | ) 44 | self.conf.register_role(blacklisted=False, whitelisted=False) 45 | 46 | async def initialize(self) -> None: 47 | """Initialize the cog.""" 48 | for guild in self.bot.guilds: 49 | await self._update_guild(guild) 50 | 51 | @checks.admin_or_permissions(manage_roles=True) 52 | @commands.guild_only() 53 | @commands.group(autohelp=True, aliases=["streamroles"]) 54 | async def streamrole(self, ctx: commands.Context): 55 | """Manage settings for StreamRoles.""" 56 | pass 57 | 58 | @streamrole.command() 59 | async def setmode(self, ctx: commands.Context, *, mode: FilterList): 60 | """Set the user filter mode to blacklist or whitelist.""" 61 | await self.conf.guild(ctx.guild).mode.set(str(mode)) 62 | await self._update_guild(ctx.guild) 63 | await ctx.tick() 64 | 65 | @streamrole.group(autohelp=True) 66 | async def whitelist(self, ctx: commands.Context): 67 | """Manage the whitelist.""" 68 | pass 69 | 70 | @whitelist.command(name="add") 71 | async def white_add( 72 | self, 73 | ctx: commands.Context, 74 | *, 75 | user_or_role: Union[discord.Member, discord.Role], 76 | ): 77 | """Add a member or role to the whitelist.""" 78 | await self._update_filter_list_entry(user_or_role, FilterList.whitelist, True) 79 | await ctx.tick() 80 | 81 | @whitelist.command(name="remove") 82 | async def white_remove( 83 | self, 84 | ctx: commands.Context, 85 | *, 86 | user_or_role: Union[discord.Member, discord.Role], 87 | ): 88 | """Remove a member or role from the whitelist.""" 89 | await self._update_filter_list_entry(user_or_role, FilterList.whitelist, False) 90 | await ctx.tick() 91 | 92 | @checks.bot_has_permissions(embed_links=True) 93 | @whitelist.command(name="show") 94 | async def white_show(self, ctx: commands.Context): 95 | """Show the whitelisted members and roles in this server.""" 96 | members, roles = await self._get_filter_list(ctx.guild, FilterList.whitelist) 97 | if not (members or roles): 98 | await ctx.send("The whitelist is empty.") 99 | return 100 | embed = discord.Embed( 101 | title="StreamRoles Whitelist", colour=await ctx.embed_colour() 102 | ) 103 | if members: 104 | embed.add_field(name="Members", value="\n".join(map(str, members))) 105 | if roles: 106 | embed.add_field(name="Roles", value="\n".join(map(str, roles))) 107 | await ctx.send(embed=embed) 108 | 109 | @streamrole.group(autohelp=True) 110 | async def blacklist(self, ctx: commands.Context): 111 | """Manage the blacklist.""" 112 | pass 113 | 114 | @blacklist.command(name="add") 115 | async def black_add( 116 | self, 117 | ctx: commands.Context, 118 | *, 119 | user_or_role: Union[discord.Member, discord.Role], 120 | ): 121 | """Add a member or role to the blacklist.""" 122 | await self._update_filter_list_entry(user_or_role, FilterList.blacklist, True) 123 | await ctx.tick() 124 | 125 | @blacklist.command(name="remove") 126 | async def black_remove( 127 | self, 128 | ctx: commands.Context, 129 | *, 130 | user_or_role: Union[discord.Member, discord.Role], 131 | ): 132 | """Remove a member or role from the blacklist.""" 133 | await self._update_filter_list_entry(user_or_role, FilterList.blacklist, False) 134 | await ctx.tick() 135 | 136 | @checks.bot_has_permissions(embed_links=True) 137 | @blacklist.command(name="show") 138 | async def black_show(self, ctx: commands.Context): 139 | """Show the blacklisted members and roles in this server.""" 140 | members, roles = await self._get_filter_list(ctx.guild, FilterList.blacklist) 141 | if not (members or roles): 142 | await ctx.send("The blacklist is empty.") 143 | return 144 | embed = discord.Embed( 145 | title="StreamRoles Blacklist", colour=await ctx.embed_colour() 146 | ) 147 | if members: 148 | embed.add_field(name="Members", value="\n".join(map(str, members))) 149 | if roles: 150 | embed.add_field(name="Roles", value="\n".join(map(str, roles))) 151 | await ctx.send(embed=embed) 152 | 153 | @streamrole.group(autohelp=True) 154 | async def games(self, ctx: commands.Context): 155 | """Manage the game whitelist. 156 | 157 | Adding games to the whitelist will make the bot only add the streamrole 158 | to members streaming those games. If the game whitelist is empty, the 159 | game being streamed won't be checked before adding the streamrole. 160 | """ 161 | pass 162 | 163 | @games.command(name="add") 164 | async def games_add(self, ctx: commands.Context, *, game: str): 165 | """Add a game to the game whitelist. 166 | 167 | This should *exactly* match the name of the game being played 168 | by the streamer as shown in Discord or on Twitch. 169 | """ 170 | async with self.conf.guild(ctx.guild).game_whitelist() as whitelist: 171 | whitelist.append(game) 172 | await self._update_guild(ctx.guild) 173 | await ctx.tick() 174 | 175 | @games.command(name="remove") 176 | async def games_remove(self, ctx: commands.Context, *, game: str): 177 | """Remove a game from the game whitelist.""" 178 | async with self.conf.guild(ctx.guild).game_whitelist() as whitelist: 179 | try: 180 | whitelist.remove(game) 181 | except ValueError: 182 | await ctx.send("That game is not in the whitelist.") 183 | return 184 | await self._update_guild(ctx.guild) 185 | await ctx.tick() 186 | 187 | @checks.bot_has_permissions(embed_links=True) 188 | @games.command(name="show") 189 | async def games_show(self, ctx: commands.Context): 190 | """Show the game whitelist for this server.""" 191 | whitelist = await self.conf.guild(ctx.guild).game_whitelist() 192 | if not whitelist: 193 | await ctx.send("The game whitelist is empty.") 194 | return 195 | embed = discord.Embed( 196 | title="StreamRoles Game Whitelist", 197 | description="\n".join(whitelist), 198 | colour=await ctx.embed_colour(), 199 | ) 200 | await ctx.send(embed=embed) 201 | 202 | @games.command(name="clear") 203 | async def games_clear(self, ctx: commands.Context): 204 | """Clear the game whitelist for this server.""" 205 | msg = await ctx.send( 206 | "This will clear the game whitelist for this server. " 207 | "Are you sure you want to do this?" 208 | ) 209 | menus.start_adding_reactions(msg, predicates.ReactionPredicate.YES_OR_NO_EMOJIS) 210 | 211 | pred = predicates.ReactionPredicate.yes_or_no(msg) 212 | try: 213 | message = await ctx.bot.wait_for("reaction_add", check=pred) 214 | except asyncio.TimeoutError: 215 | message = None 216 | if message is not None and pred.result is True: 217 | await self.conf.guild(ctx.guild).game_whitelist.clear() 218 | await self._update_guild(ctx.guild) 219 | await ctx.send("Done. The game whitelist has been cleared.") 220 | else: 221 | await ctx.send("The action was cancelled.") 222 | 223 | @streamrole.group() 224 | async def alerts(self, ctx: commands.Context): 225 | """Manage streamalerts for those who receive the streamrole.""" 226 | 227 | @alerts.command(name="setenabled") 228 | async def alerts_setenabled(self, ctx: commands.Context, true_or_false: bool): 229 | """Enable or disable streamrole alerts.""" 230 | await self.conf.guild(ctx.guild).alerts.enabled.set(true_or_false) 231 | await ctx.tick() 232 | 233 | @alerts.command(name="setchannel") 234 | async def alerts_setchannel( 235 | self, ctx: commands.Context, channel: discord.TextChannel 236 | ): 237 | """Set the channel for streamrole alerts.""" 238 | await self.conf.guild(ctx.guild).alerts.channel.set(channel.id) 239 | await ctx.tick() 240 | 241 | @alerts.command(name="autodelete") 242 | async def alerts_autodelete(self, ctx: commands.Context, true_or_false: bool): 243 | """Enable or disable alert autodeletion. 244 | 245 | This is enabled by default. When enabled, alerts will be deleted 246 | once the streamer's role is removed. 247 | """ 248 | await self.conf.guild(ctx.guild).alerts.autodelete.set(true_or_false) 249 | await ctx.tick() 250 | 251 | async def _get_filter_list( 252 | self, guild: discord.Guild, mode: FilterList 253 | ) -> Tuple[List[discord.Member], List[discord.Role]]: 254 | all_member_data = await self.conf.all_members(guild) 255 | all_role_data = await self.conf.all_roles() 256 | mode = mode.as_participle() 257 | member_ids = (u for u, d in all_member_data.items() if d.get(mode)) 258 | role_ids = (u for u, d in all_role_data.items() if d.get(mode)) 259 | members = list(filter(None, map(guild.get_member, member_ids))) 260 | roles = list(filter(None, map(guild.get_role, role_ids))) 261 | return members, roles 262 | 263 | async def _update_filter_list_entry( 264 | self, 265 | member_or_role: Union[discord.Member, discord.Role], 266 | filter_list: FilterList, 267 | value: bool, 268 | ) -> None: 269 | if isinstance(member_or_role, discord.Member): 270 | await self.conf.member(member_or_role).set_raw( 271 | filter_list.as_participle(), value=value 272 | ) 273 | await self._update_member(member_or_role) 274 | else: 275 | await self.conf.role(member_or_role).set_raw( 276 | filter_list.as_participle(), value=value 277 | ) 278 | await self._update_members_with_role(member_or_role) 279 | 280 | @streamrole.command() 281 | async def setrole(self, ctx: commands.Context, *, role: discord.Role): 282 | """Set the role which is given to streamers.""" 283 | await self.conf.guild(ctx.guild).streamer_role.set(role.id) 284 | await ctx.send( 285 | "Done. Streamers will now be given the {} role when " 286 | "they go live.".format(role.name) 287 | ) 288 | 289 | @streamrole.command() 290 | async def forceupdate(self, ctx: commands.Context): 291 | """Force the bot to reassign streamroles to members in this server. 292 | 293 | This command forces the bot to inspect the streaming status of 294 | all current members of the server, and assign (or remove) the 295 | streamrole. 296 | """ 297 | if not await self.get_streamer_role(ctx.guild): 298 | await ctx.send( 299 | f"The streamrole has not been set in this server. Please use " 300 | f"`{ctx.clean_prefix}streamrole setrole` first." 301 | ) 302 | return 303 | 304 | await self._update_guild(ctx.guild) 305 | await ctx.tick() 306 | 307 | async def get_streamer_role(self, guild: discord.Guild) -> Optional[discord.Role]: 308 | """Get the streamrole for this guild. 309 | 310 | Arguments 311 | --------- 312 | guild : discord.Guild 313 | The guild to retrieve the streamer role for. 314 | 315 | Returns 316 | ------- 317 | Optional[discord.Role] 318 | The role given to streaming users in this guild. ``None`` 319 | if not set. 320 | """ 321 | role_id = await self.conf.guild(guild).streamer_role() 322 | if not role_id: 323 | return 324 | try: 325 | role = next(r for r in guild.roles if r.id == role_id) 326 | except StopIteration: 327 | return 328 | else: 329 | return role 330 | 331 | async def get_alerts_channel( 332 | self, guild: discord.Guild 333 | ) -> Optional[discord.TextChannel]: 334 | """Get the alerts channel for this guild. 335 | 336 | Arguments 337 | --------- 338 | guild : discord.Guild 339 | The guild to retrieve the alerts channel for. 340 | 341 | Returns 342 | ------- 343 | Optional[discord.TextChannel] 344 | The channel where alerts are posted in this guild. ``None`` 345 | if not set or enabled. 346 | """ 347 | alerts_data = await self.conf.guild(guild).alerts.all() 348 | if not alerts_data["enabled"]: 349 | return 350 | return guild.get_channel(alerts_data["channel"]) 351 | 352 | async def _update_member( 353 | self, 354 | member: discord.Member, 355 | role: Optional[discord.Role] = None, 356 | alerts_channel: Optional[discord.TextChannel] = _alerts_channel_sentinel, 357 | ) -> None: 358 | role = role or await self.get_streamer_role(member.guild) 359 | if role is None: 360 | return 361 | 362 | channel = ( 363 | alerts_channel 364 | if alerts_channel is not _alerts_channel_sentinel 365 | else await self.get_alerts_channel(member.guild) 366 | ) 367 | 368 | activity = next( 369 | (a for a in member.activities if isinstance(a, discord.Streaming)), 370 | None, 371 | ) 372 | if activity is not None and not activity.platform: 373 | activity = None 374 | 375 | has_role = role in member.roles 376 | if activity is not None and await self._is_allowed(member): 377 | game = activity.game 378 | games = await self.conf.guild(member.guild).game_whitelist() 379 | if not games or game in games: 380 | if not has_role: 381 | log.debug("Adding streamrole %s to member %s", role.id, member.id) 382 | await member.add_roles(role) 383 | if channel: 384 | await self._post_alert(member, activity, game, channel) 385 | return 386 | 387 | if has_role: 388 | log.debug("Removing streamrole %s from member %s", role.id, member.id) 389 | await member.remove_roles(role) 390 | if channel and await self.conf.guild(member.guild).alerts.autodelete(): 391 | await self._remove_alert(member, channel) 392 | 393 | async def _update_members_with_role(self, role: discord.Role) -> None: 394 | streamer_role = await self.get_streamer_role(role.guild) 395 | if streamer_role is None: 396 | return 397 | 398 | alerts_channel = await self.get_alerts_channel(role.guild) 399 | 400 | if await self.conf.guild(role.guild).mode() == FilterList.blacklist: 401 | for member in role.members: 402 | if streamer_role in member.roles: 403 | log.debug( 404 | "Removing streamrole %s from member %s after role %s was " 405 | "blacklisted", 406 | streamer_role.id, 407 | member.id, 408 | role.id, 409 | ) 410 | await member.remove_roles( 411 | streamer_role, 412 | reason=f"Removing streamrole after {role} role was blacklisted", 413 | ) 414 | else: 415 | for member in role.members: 416 | await self._update_member(member, streamer_role, alerts_channel) 417 | 418 | async def _update_guild(self, guild: discord.Guild) -> None: 419 | streamer_role = await self.get_streamer_role(guild) 420 | if streamer_role is None: 421 | return 422 | 423 | alerts_channel = await self.get_alerts_channel(guild) 424 | 425 | for member in guild.members: 426 | await self._update_member(member, streamer_role, alerts_channel) 427 | 428 | async def _post_alert( 429 | self, 430 | member: discord.Member, 431 | activity: discord.Streaming, 432 | game: Optional[str], 433 | channel: discord.TextChannel, 434 | ) -> discord.Message: 435 | content = ( 436 | f"{chatutils.bold(member.display_name)} is now live on {activity.platform}" 437 | ) 438 | if game is not None: 439 | content += f", playing {chatutils.italics(str(game))}" 440 | content += ( 441 | f"!\n\nTitle: {chatutils.italics(activity.name)}\nURL: {activity.url}" 442 | ) 443 | 444 | msg = await channel.send(content) 445 | await self.conf.member(member).alert_messages.set_raw( 446 | str(channel.id), value=msg.id 447 | ) 448 | return msg 449 | 450 | async def _remove_alert( 451 | self, member: discord.Member, channel: discord.TextChannel 452 | ) -> None: 453 | conf_group = self.conf.member(member).alert_messages 454 | msg_id = await conf_group.get_raw(str(channel.id), default=None) 455 | if msg_id is None: 456 | return 457 | await conf_group.clear_raw(str(channel.id)) 458 | 459 | msg: Optional[discord.Message] = discord.utils.get( 460 | getattr(self.bot, "cached_messages", ()), id=msg_id 461 | ) 462 | if msg is None: 463 | try: 464 | msg = await channel.fetch_message(msg_id) 465 | except discord.NotFound: 466 | return 467 | 468 | with contextlib.suppress(discord.NotFound): 469 | await msg.delete() 470 | 471 | @commands.Cog.listener() 472 | async def on_guild_join(self, guild: discord.Guild) -> None: 473 | """Update any members when the bot joins a new guild.""" 474 | await self._update_guild(guild) 475 | 476 | @commands.Cog.listener() 477 | async def on_presence_update( 478 | self, before: discord.Member, after: discord.Member 479 | ) -> None: 480 | """Apply or remove the streamrole when a user's activity changes.""" 481 | if before.activities != after.activities: 482 | await self._update_member(after) 483 | 484 | @commands.Cog.listener() 485 | async def on_member_join(self, member: discord.Member) -> None: 486 | """Update a new member who joins.""" 487 | await self._update_member(member) 488 | 489 | async def _is_allowed(self, member: discord.Member) -> bool: 490 | if await self.conf.guild(member.guild).mode() == FilterList.blacklist: 491 | return not await self._is_blacklisted(member) 492 | else: 493 | return await self._is_whitelisted(member) 494 | 495 | async def _is_whitelisted(self, member: discord.Member) -> bool: 496 | if await self.conf.member(member).whitelisted(): 497 | return True 498 | for role in member.roles: 499 | if await self.conf.role(role).whitelisted(): 500 | return True 501 | return False 502 | 503 | async def _is_blacklisted(self, member: discord.Member) -> bool: 504 | if await self.conf.member(member).blacklisted(): 505 | return True 506 | for role in member.roles: 507 | if await self.conf.role(role).blacklisted(): 508 | return True 509 | return False 510 | -------------------------------------------------------------------------------- /streamroles/types.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from redbot.core import commands 4 | 5 | 6 | class FilterList(str, enum.Enum): 7 | blacklist = "blacklist" 8 | whitelist = "whitelist" 9 | 10 | def __str__(self) -> str: 11 | return self.name 12 | 13 | def as_participle(self) -> str: 14 | return self.name + "ed" 15 | 16 | # noinspection PyUnusedLocal 17 | @classmethod 18 | async def convert(cls, ctx: commands.Context, argument: str) -> "FilterList": 19 | try: 20 | # noinspection PyArgumentList 21 | return cls(argument.lower()) 22 | except ValueError: 23 | raise commands.BadArgument("Mode must be `blacklist` or `whitelist`.") 24 | -------------------------------------------------------------------------------- /strikes/__init__.py: -------------------------------------------------------------------------------- 1 | """Strikes - Keep track of misbehaving users.""" 2 | import asyncio 3 | 4 | from .strikes import Strikes 5 | 6 | 7 | async def setup(bot): 8 | cog = Strikes(bot) 9 | await cog.initialize() 10 | 11 | if asyncio.iscoroutinefunction(bot.add_cog): 12 | await bot.add_cog(cog) 13 | else: 14 | bot.add_cog(cog) 15 | -------------------------------------------------------------------------------- /strikes/data/ddl.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS strikes ( 2 | id BIGINT PRIMARY KEY, 3 | user BIGINT NOT NULL, 4 | guild BIGINT NOT NULL, 5 | moderator BIGINT NOT NULL, 6 | reason TEXT 7 | ); 8 | -------------------------------------------------------------------------------- /strikes/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["Tobotimus"], 3 | "description": "Strikes is a cog for helping moderation teams warn and keep track of misbehaving users. Strikes are filed with a reason and timestamp, and the cog is helpful for knowing how many times the user has misbehaved *recently*. ModLog integration is also available.", 4 | "install_msg": "Thanks for installing `strikes`. See `[p]help Strikes` for details.", 5 | "short": "Strike misbehaving users.", 6 | "hidden": false, 7 | "disabled": false, 8 | "required_cogs": {}, 9 | "requirements": ["tabulate[widechars]"], 10 | "tags": ["warnings", "strikes", "reports", "mod"], 11 | "type": "COG" 12 | } 13 | -------------------------------------------------------------------------------- /strikes/strikes.py: -------------------------------------------------------------------------------- 1 | """Module for the Strikes cog.""" 2 | import contextlib 3 | import os 4 | import sqlite3 5 | from collections import defaultdict 6 | from datetime import datetime, timedelta 7 | from typing import Iterator, List, Tuple, Union 8 | 9 | import discord 10 | from redbot.core import Config, checks, commands, data_manager, modlog 11 | from redbot.core.bot import Red 12 | from redbot.core.errors import CogLoadError 13 | from redbot.core.i18n import Translator 14 | from redbot.core.utils.chat_formatting import box, pagify 15 | 16 | try: 17 | from tabulate import tabulate 18 | except ImportError: 19 | raise CogLoadError( 20 | "tabulate is not installed. Please install it with the following command, then " 21 | "try loading this cog again:\n```\n[p]pipinstall tabulate[widechars]\n```\n" 22 | "This command requires the `downloader` cog to be loaded." 23 | ) 24 | 25 | UNIQUE_ID = 0x134087DE 26 | 27 | _CASETYPE = { 28 | "name": "strike", 29 | "default_setting": True, 30 | "image": "\N{BOWLING}", 31 | "case_str": "Strike", 32 | } 33 | 34 | _ = Translator(":blobducklurk:", __file__) 35 | 36 | 37 | class Strikes(commands.Cog): 38 | """Strike users to keep track of misbehaviour.""" 39 | 40 | def __init__(self, bot: Red, db: Union[str, bytes, os.PathLike, None] = None): 41 | self.bot = bot 42 | self.db = db or data_manager.cog_data_path(self) / "strikes.db" 43 | super().__init__() 44 | 45 | async def initialize(self): 46 | # Case-type registration 47 | with contextlib.suppress(RuntimeError): 48 | await modlog.register_casetype(**_CASETYPE) 49 | 50 | # Data definition (table creation) 51 | ddl_path = data_manager.bundled_data_path(self) / "ddl.sql" 52 | with self._db_connect() as conn, ddl_path.open() as ddl_file: 53 | cursor = conn.cursor() 54 | cursor.execute(ddl_file.read()) 55 | 56 | # Data migration from Config to SQLite 57 | json_file = data_manager.cog_data_path(self) / "settings.json" 58 | if json_file.exists(): 59 | conf = Config.get_conf(self, UNIQUE_ID) 60 | all_members = await conf.all_members() 61 | 62 | def _gen_rows() -> Iterator[Tuple[int, int, int, int, str]]: 63 | for guild_id, guild_data in all_members.items(): 64 | for member_id, member_data in guild_data.items(): 65 | for strike in member_data.get("strikes", []): 66 | yield ( 67 | strike["id"], 68 | member_id, 69 | guild_id, 70 | strike["moderator"], 71 | strike["reason"], 72 | ) 73 | 74 | cursor.executemany( 75 | """ 76 | INSERT INTO strikes(id, user, guild, moderator, reason) 77 | VALUES (?, ?, ?, ?, ?) 78 | """, 79 | _gen_rows(), 80 | ) 81 | json_file.replace(json_file.parent / "settings.old.json") 82 | 83 | def _db_connect(self) -> sqlite3.Connection: 84 | conn = sqlite3.connect(str(self.db)) 85 | conn.row_factory = sqlite3.Row 86 | conn.create_function("is_member", 2, self._is_member) 87 | return conn 88 | 89 | def _is_member(self, user_id: int, guild_id: int) -> bool: 90 | # Function exported to SQLite as is_member 91 | guild = self.bot.get_guild(guild_id) 92 | if guild is None: 93 | return False 94 | return guild.get_member(user_id) is not None 95 | 96 | async def strike_user( 97 | self, member: discord.Member, reason: str, moderator: discord.Member 98 | ) -> List[int]: 99 | """Give a user a strike. 100 | 101 | Parameters 102 | ---------- 103 | member : discord.Member 104 | The member to strike. 105 | reason : str 106 | The reason for the strike. 107 | moderator : discord.Member 108 | The moderator who gave the strike. 109 | 110 | Returns 111 | ------- 112 | List[int] 113 | A list of IDs for all strikes this user has received. 114 | 115 | """ 116 | now = datetime.now() 117 | strike_id = discord.utils.time_snowflake(now) 118 | with self._db_connect() as conn: 119 | cursor = conn.cursor() 120 | cursor.execute( 121 | """ 122 | INSERT INTO strikes(id, user, guild, moderator, reason) 123 | VALUES (?, ?, ?, ?, ?) 124 | """, 125 | (strike_id, member.id, member.guild.id, moderator.id, reason), 126 | ) 127 | cursor.execute( 128 | "SELECT id FROM strikes WHERE user == ? AND guild == ?", 129 | (member.id, member.guild.id), 130 | ) 131 | result = cursor.fetchall() 132 | await self.create_case(member, now, reason, moderator) 133 | return [row["id"] for row in result] 134 | 135 | async def create_case( 136 | self, 137 | member: discord.Member, 138 | timestamp: datetime, 139 | reason: str, 140 | moderator: discord.Member, 141 | ): 142 | """Create a new strike case. 143 | 144 | Parameters 145 | ---------- 146 | member : discord.Member 147 | The member who has received a strike. 148 | timestamp : datetime.datetime 149 | The timestamp for the strike. 150 | reason : str 151 | The reason for the strike. 152 | moderator : discord.Member 153 | The moderator's ID. 154 | 155 | Returns 156 | ------- 157 | redbot.core.modlog.Case 158 | New case object. 159 | 160 | """ 161 | try: 162 | await modlog.create_case( 163 | bot=self.bot, 164 | guild=member.guild, 165 | created_at=timestamp, 166 | action_type="strike", 167 | user=member, 168 | moderator=moderator, 169 | reason=reason, 170 | ) 171 | except RuntimeError: 172 | pass 173 | 174 | @checks.mod_or_permissions(kick_members=True) 175 | @commands.guild_only() 176 | @commands.command() 177 | async def strike( 178 | self, ctx: commands.Context, member: discord.Member, *, reason: str 179 | ): 180 | """Strike a user.""" 181 | strikes = await self.strike_user(member, reason, ctx.author) 182 | month_ago = discord.utils.time_snowflake((datetime.now() - timedelta(days=30))) 183 | last_month = [id_ for id_ in strikes if id_ > month_ago] 184 | await ctx.send( 185 | _( 186 | "Done. {user.display_name} now has {num} strikes ({recent_num} in the" 187 | " past 30 days)." 188 | ).format(user=member, num=len(strikes), recent_num=len(last_month)) 189 | ) 190 | 191 | @checks.mod_or_permissions(kick_members=True) 192 | @commands.guild_only() 193 | @commands.command() 194 | async def delstrike(self, ctx: commands.Context, strike_id: int): 195 | """Remove a single strike by its ID.""" 196 | with self._db_connect() as conn: 197 | conn.execute("DELETE FROM strikes WHERE id == ?", (strike_id,)) 198 | await ctx.tick() 199 | 200 | @checks.mod_or_permissions(kick_members=True) 201 | @commands.guild_only() 202 | @commands.command() 203 | async def delstrikes(self, ctx: commands.Context, *, member: discord.Member): 204 | """Remove all strikes from a member.""" 205 | with self._db_connect() as conn: 206 | conn.execute( 207 | "DELETE FROM strikes WHERE user == ? AND guild == ?", 208 | (member.id, member.guild.id), 209 | ) 210 | await ctx.tick() 211 | 212 | @checks.mod_or_permissions(kick_members=True) 213 | @commands.guild_only() 214 | @commands.command() 215 | async def strikes(self, ctx: commands.Context, *, member: discord.Member): 216 | """Show all previous strikes for a user.""" 217 | with self._db_connect() as conn: 218 | cursor = conn.execute( 219 | """ 220 | SELECT id, moderator, reason FROM strikes 221 | WHERE user == ? AND guild == ? 222 | ORDER BY id DESC 223 | """, 224 | (member.id, member.guild.id), 225 | ) 226 | table = self._create_table(cursor, member.guild) 227 | if table: 228 | pages = pagify(table, shorten_by=25) 229 | await ctx.send(_("Strikes for {user.display_name}:\n").format(user=member)) 230 | for page in pages: 231 | await ctx.send(box(page)) 232 | else: 233 | await ctx.send( 234 | _("{user.display_name} has never received any strikes.").format( 235 | user=member 236 | ) 237 | ) 238 | 239 | @checks.mod_or_permissions(kick_members=True) 240 | @commands.guild_only() 241 | @commands.command() 242 | async def allstrikes(self, ctx: commands.Context, num_days: int = 30): 243 | """Show all recent individual strikes. 244 | 245 | `[num_days]` is the number of past days of strikes to display. 246 | Defaults to 30. When 0, all strikes from the beginning of time 247 | will be counted shown. 248 | 249 | """ 250 | if num_days < 0: 251 | await ctx.send( 252 | _( 253 | "You must specify a number of days of at least 0 to retrieve " 254 | "strikes from." 255 | ) 256 | ) 257 | return 258 | start_id = ( 259 | discord.utils.time_snowflake(datetime.now() - timedelta(days=num_days)) 260 | if num_days 261 | else 0 262 | ) 263 | with self._db_connect() as conn: 264 | cursor = conn.execute( 265 | """ 266 | SELECT id, user, moderator, reason FROM strikes 267 | WHERE 268 | guild == ? 269 | AND id > ? 270 | AND is_member(user, guild) 271 | ORDER BY id DESC 272 | """, 273 | (ctx.guild.id, start_id), 274 | ) 275 | table = self._create_table(cursor, ctx.guild, show_id=False) 276 | 277 | if table: 278 | pages = pagify(table, shorten_by=25) 279 | if num_days: 280 | await ctx.send( 281 | _("All strikes received by users in the past {num} days:\n").format( 282 | num=num_days 283 | ) 284 | ) 285 | else: 286 | await ctx.send(_("All strikes received by users in this server:\n")) 287 | for page in pages: 288 | await ctx.send(box(page)) 289 | else: 290 | if num_days: 291 | await ctx.send( 292 | _( 293 | "No users in this server have received strikes in the past " 294 | "{num} days!" 295 | ).format(num=num_days) 296 | ) 297 | else: 298 | await ctx.send(_("No users in this server have ever received strikes!")) 299 | 300 | @checks.mod_or_permissions(kick_members=True) 301 | @commands.guild_only() 302 | @commands.command() 303 | async def strikecounts( 304 | self, 305 | ctx: commands.Context, 306 | num_days: int = 0, 307 | limit: int = 100, 308 | sort_by: str = "count", 309 | sort_order: str = "desc", 310 | ): 311 | """Show the strike count for multiple users. 312 | 313 | `[num_days]` is the number of past days of strikes to count. 314 | Defaults to 0, which means all strikes from the beginning of 315 | time will be counted. 316 | 317 | `[limit]` is the maximum amount of members to show the 318 | strike count for. Defaults to 100. 319 | 320 | `[sort_by]` is the column to sort the table by. May be one of 321 | either *count* or *date*. Defaults to *count*. 322 | 323 | `[sort_order]` is the order to sort in. It may be one of either 324 | *desc* for descending or *asc* for ascending. Defaults to 325 | *desc*. 326 | """ 327 | if num_days < 0: 328 | await ctx.send( 329 | _( 330 | "You must specify a number of days of at least 0 to retrieve " 331 | "strikes from." 332 | ) 333 | ) 334 | return 335 | if limit < 1: 336 | await ctx.send( 337 | _( 338 | "You must specify a number of members of at least 1 to retrieve " 339 | "strikes for." 340 | ) 341 | ) 342 | sort_by = sort_by.lower() 343 | if sort_by not in ("count", "date"): 344 | await ctx.send( 345 | _("Sorry, I don't know how to sort by {column}").format(column=sort_by) 346 | ) 347 | return 348 | elif sort_by == "date": 349 | sort_by = "most_recent_id" 350 | sort_order = sort_order.upper() 351 | if sort_order not in ("ASC", "DESC"): 352 | await ctx.send( 353 | _("Sorry, {word} is not a valid sort order.").format(word=sort_order) 354 | ) 355 | return 356 | start_id = ( 357 | discord.utils.time_snowflake(datetime.now() - timedelta(days=num_days)) 358 | if num_days 359 | else 0 360 | ) 361 | with self._db_connect() as conn: 362 | cursor = conn.execute( 363 | f""" 364 | SELECT 365 | max(id) as most_recent_id, 366 | user, 367 | count(user) as count 368 | FROM 369 | strikes 370 | WHERE 371 | guild = ? 372 | AND id > ? 373 | AND is_member(user, guild) 374 | GROUP BY guild, user 375 | ORDER BY {sort_by} {sort_order} 376 | LIMIT ? 377 | """, 378 | (ctx.guild.id, start_id, limit), 379 | ) 380 | table = self._create_table(cursor, ctx.guild) 381 | 382 | if table: 383 | pages = pagify(table, shorten_by=25) 384 | if num_days: 385 | await ctx.send( 386 | _( 387 | "Number of strikes received by users in the past {num} days:\n" 388 | ).format(num=num_days) 389 | ) 390 | else: 391 | await ctx.send( 392 | _("Number of strikes received by users in this server:\n") 393 | ) 394 | for page in pages: 395 | await ctx.send(box(page)) 396 | else: 397 | if num_days: 398 | await ctx.send( 399 | _( 400 | "No users in this server have received strikes in the past " 401 | "{num} days!" 402 | ).format(num=num_days) 403 | ) 404 | else: 405 | await ctx.send(_("No users in this server have ever received strikes!")) 406 | 407 | @staticmethod 408 | def _create_table( 409 | cursor: sqlite3.Cursor, guild: discord.Guild, *, show_id: bool = True 410 | ) -> str: 411 | tabular_data = defaultdict(list) 412 | for strike in cursor: 413 | with contextlib.suppress(IndexError): 414 | user = guild.get_member(strike["user"]) 415 | tabular_data[_("User")].append(user) 416 | with contextlib.suppress(IndexError): 417 | mod_id = strike["moderator"] 418 | tabular_data[_("Moderator")].append(guild.get_member(mod_id) or mod_id) 419 | with contextlib.suppress(IndexError): 420 | strike_id = strike["id"] 421 | tabular_data[_("Time & Date (UTC)")].append( 422 | discord.utils.snowflake_time(strike_id).strftime("%Y-%m-%d %H:%M") 423 | ) 424 | if show_id is True: 425 | tabular_data[_("Strike ID")].append(strike_id) 426 | with contextlib.suppress(IndexError): 427 | strike_count = strike["count"] 428 | tabular_data[_("Strike Count")].append(strike_count) 429 | with contextlib.suppress(IndexError): 430 | recent_id = strike["most_recent_id"] 431 | tabular_data[_("Latest Strike Given (UTC)")].append( 432 | discord.utils.snowflake_time(recent_id).strftime("%Y-%m-%d %H:%M") 433 | ) 434 | with contextlib.suppress(IndexError): 435 | reason = strike["reason"] 436 | if reason: 437 | reason = "\n".join( 438 | pagify(reason, delims=[" "], page_length=25, shorten_by=0) 439 | ) 440 | tabular_data[_("Reason")].append(reason) 441 | 442 | if tabular_data: 443 | return tabulate( 444 | tabular_data, headers="keys", tablefmt="fancy_grid", numalign="left" 445 | ) 446 | else: 447 | return "" 448 | -------------------------------------------------------------------------------- /updatered/__init__.py: -------------------------------------------------------------------------------- 1 | """UpdateRed - Update the bot with a command.""" 2 | import asyncio 3 | import sys 4 | 5 | from .updatered import UpdateRed 6 | 7 | 8 | async def setup(bot): 9 | if sys.platform == "win32": 10 | # Executables previously renamed ".old" should be cleaned up 11 | UpdateRed.cleanup_old_executables() 12 | 13 | cog = UpdateRed() 14 | if asyncio.iscoroutinefunction(bot.add_cog): 15 | await bot.add_cog(cog) 16 | else: 17 | bot.add_cog(cog) 18 | -------------------------------------------------------------------------------- /updatered/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["Tobotimus"], 3 | "description": "UpdateRed is a cog for updating your bot from within discord. When combined with the ability to restart the bot, updating Red becomes a easier than ever!\n\nYou can choose from multiple automatic versions, or specify a particular version you'd like to install.", 4 | "install_msg": "Thanks for installing `updatered`. See `[p]help UpdateRed` and `[p]help update` for details.", 5 | "short": "A command to update Red!", 6 | "hidden": false, 7 | "disabled": false, 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["update"], 11 | "type": "COG" 12 | } 13 | -------------------------------------------------------------------------------- /updatered/updatered.py: -------------------------------------------------------------------------------- 1 | """Module for the UpdateRed cog.""" 2 | import asyncio 3 | import asyncio.subprocess 4 | import io 5 | import logging 6 | import pathlib 7 | import re 8 | import sys 9 | import tarfile 10 | import time 11 | from typing import ClassVar, Iterable, List, Optional, Pattern, Tuple 12 | 13 | import discord 14 | from redbot.core import checks, commands 15 | 16 | log = logging.getLogger("red.updatered") 17 | 18 | 19 | class UpdateRed(getattr(commands, "Cog", object)): 20 | """Update Red from Discord. 21 | 22 | To get the most out of this cog, run red with systemd or pm2 on 23 | Linux, or the launcher on Windows, then use the `[p]restart` 24 | command to restart the bot after updating. 25 | """ 26 | 27 | DEV_LINK: ClassVar[str] = ( 28 | "https://github.com/Cog-Creators/Red-DiscordBot/tarball/" 29 | "V3/develop#egg=Red-DiscordBot" 30 | ) 31 | IS_VENV: ClassVar[bool] = hasattr(sys, "real_prefix") or ( 32 | hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix 33 | ) 34 | PIP_INSTALL_ARGS: ClassVar[Tuple[str, ...]] = ( 35 | sys.executable, 36 | "-m", 37 | "pip", 38 | "install", 39 | "--upgrade", 40 | ) 41 | if not IS_VENV: 42 | PIP_INSTALL_ARGS += ("--user",) 43 | _BIN_PATH: ClassVar[pathlib.Path] = pathlib.Path(sys.executable).parent 44 | _WINDOWS_BINARIES: ClassVar[List[pathlib.Path]] = [ 45 | _BIN_PATH / "redbot.exe", 46 | _BIN_PATH / "redbot-launcher.exe", 47 | *pathlib.Path(discord.__file__).parent.glob("bin/*.dll"), 48 | ] 49 | _SAVED_PKG_RE: ClassVar[Pattern[str]] = re.compile(r"\s+Saved\s(?P.*)$") 50 | 51 | @checks.is_owner() 52 | @commands.command(aliases=["updatered"]) 53 | async def update( 54 | self, ctx: commands.Context, version: str = "stable", *extras: str 55 | ) -> None: 56 | """Update Red with pip. 57 | 58 | The optional `version` argument can be set to any one of the 59 | following: 60 | - `stable` (default) - Update to the latest release on PyPI. 61 | - `pre` - Update to the latest pre-release, if available. 62 | - `dev` - Update from source control, i.e. V3/develop on 63 | GitHub. 64 | - Any specific version, e.g. `3.0.0b19`. 65 | 66 | You may also specify any number of `extras`, which are extra 67 | requirements you wish to install with Red. For example, to 68 | update mongo requirements with Red, run the command with 69 | `[p]update mongo`. 70 | 71 | Please note that when specifying any invalid arguments, the cog 72 | will naively try to run the update command with those arguments, 73 | possibly resulting in a misleading error message. 74 | """ 75 | version = version.lower() 76 | pre = False 77 | dev = False 78 | if version == "stable": 79 | version_marker = "" 80 | elif version == "pre": 81 | pre = True 82 | version_marker = "" 83 | elif version == "dev": 84 | dev = True 85 | version_marker = "" 86 | else: 87 | version_marker = "==" + version 88 | 89 | await self._update_and_communicate( 90 | ctx, version_marker=version_marker, pre=pre, dev=dev, extras=extras 91 | ) 92 | 93 | @checks.is_owner() 94 | @commands.command() 95 | async def urlupdate(self, ctx: commands.Context, *, url: str) -> None: 96 | """Update Red directly from a pip-installable URL.""" 97 | try: 98 | await self._update_and_communicate(ctx, url=url) 99 | except tarfile.ReadError: 100 | await ctx.send("That link does not appear to point to a tarball.") 101 | 102 | async def _update_and_communicate( 103 | self, 104 | ctx: commands.Context, 105 | *, 106 | url: Optional[str] = None, 107 | version_marker: str = "", 108 | pre: bool = False, 109 | dev: bool = False, 110 | extras: Optional[Iterable[str]] = None, 111 | ) -> None: 112 | async with ctx.typing(): 113 | return_code, stdout = await self.update_red( 114 | url=url, version_marker=version_marker, pre=pre, dev=dev, extras=extras 115 | ) 116 | 117 | if return_code: 118 | msg = "Something went wrong whilst updating." 119 | else: 120 | msg = "Update successful. Restarting your bot is recommended." 121 | 122 | if stdout: 123 | prompt = await ctx.send( 124 | msg + " Would you like to see the console output? (y/n)" 125 | ) 126 | 127 | try: 128 | response: Optional[discord.Message] = await ctx.bot.wait_for( 129 | "message", 130 | check=lambda m: m.author == ctx.author and m.channel == ctx.channel, 131 | timeout=15.0, 132 | ) 133 | except asyncio.TimeoutError: 134 | response = None 135 | 136 | if response and response.content.lower() in ("y", "yes"): 137 | with io.BytesIO(stdout.encode()) as fp: 138 | cur_date = time.strftime("%Y-%m-%dT%H-%M-%S") 139 | await ctx.send( 140 | file=discord.File(fp, filename=f"updatered-{cur_date}.log") 141 | ) 142 | else: 143 | await prompt.edit(content=msg) 144 | else: 145 | await ctx.send(msg) 146 | 147 | async def update_red( 148 | self, 149 | *, 150 | url: Optional[str] = None, 151 | version_marker: str = "", 152 | pre: bool = False, 153 | dev: bool = False, 154 | extras: Optional[Iterable[str]] = None, 155 | ) -> Tuple[int, str]: 156 | """Update the bot. 157 | 158 | Returns 159 | ------- 160 | Tuple[int, str] 161 | A tuple in the form (return_code, stdout). 162 | 163 | """ 164 | if extras: 165 | extras_str = f"[{','.join(extras)}]" 166 | else: 167 | extras_str = "" 168 | 169 | if dev: 170 | package = self.DEV_LINK + extras_str 171 | elif url is not None: 172 | package = url 173 | else: 174 | package = "Red-DiscordBot" + extras_str + version_marker 175 | 176 | args = self.PIP_INSTALL_ARGS 177 | if pre: 178 | args += ("--pre",) 179 | 180 | args += (package,) 181 | 182 | if sys.platform == "win32": 183 | # If we try to update whilst running Red, Windows will throw a permission 184 | # error due to binaries being in use (apparently). 185 | self.rename_executables() 186 | 187 | log.debug("Installing Red package with command: %s", " ".join(args)) 188 | 189 | process: Optional[asyncio.subprocess.Process] = None 190 | stdout = "" 191 | try: 192 | process = await asyncio.create_subprocess_exec( 193 | *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, 194 | ) 195 | 196 | stdout_data = (await process.communicate())[0] 197 | if stdout_data is not None: 198 | stdout += "\n" + stdout_data.decode() 199 | finally: 200 | if sys.platform == "win32" and process and process.returncode: 201 | self.rename_executables(undo=True) 202 | 203 | return process.returncode, stdout 204 | 205 | @classmethod 206 | def rename_executables(cls, *, undo: bool = False) -> None: 207 | """This is a helper method for renaming Red's executables in Windows.""" 208 | for exe in cls._WINDOWS_BINARIES: 209 | exe_old = exe.with_suffix(".old") 210 | if undo: 211 | from_file, to_file = exe_old, exe 212 | else: 213 | from_file, to_file = exe, exe_old 214 | 215 | if not from_file.is_file(): 216 | continue 217 | log.debug("Renaming %s to %s...", from_file, to_file) 218 | try: 219 | from_file.rename(to_file) 220 | except OSError: 221 | log.error("Failed to rename %s to %s!", from_file, to_file) 222 | 223 | @classmethod 224 | def cleanup_old_executables(cls) -> None: 225 | for exe in cls._WINDOWS_BINARIES: 226 | old_exe = exe.with_suffix(".old") 227 | if not old_exe.is_file(): 228 | continue 229 | log.debug("Deleting old file %s...", old_exe) 230 | try: 231 | old_exe.unlink() 232 | except OSError: 233 | log.debug("Failed to delete old file %s!", old_exe) 234 | -------------------------------------------------------------------------------- /welcomecount/__init__.py: -------------------------------------------------------------------------------- 1 | """WelcomeCount - Welcomes users and keeps track of daily joins.""" 2 | import asyncio 3 | 4 | from .welcomecount import WelcomeCount 5 | 6 | 7 | async def setup(bot): 8 | """Load welcomecount.""" 9 | cog = WelcomeCount() 10 | 11 | if asyncio.iscoroutinefunction(bot.add_cog): 12 | await bot.add_cog(cog) 13 | else: 14 | bot.add_cog(cog) 15 | -------------------------------------------------------------------------------- /welcomecount/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["Tobotimus"], 3 | "description": "WelcomeCount is a cog for sending customisable and context-based welcome messages to new users. It's named after it's main feature - it counts how many members have joined each day, and this can be included in the welcome message.", 4 | "install_msg": "Thanks for installing `welcomecount`. See `[p]help wcount` for details.", 5 | "short": "Customisable welcome messages.", 6 | "hidden": false, 7 | "disabled": false, 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["welcome", "joinmessage"], 11 | "type": "COG" 12 | } 13 | -------------------------------------------------------------------------------- /welcomecount/welcomecount.py: -------------------------------------------------------------------------------- 1 | """Module for the WelcomeCount Cog.""" 2 | import datetime 3 | from typing import List, Union 4 | 5 | import discord 6 | from redbot.core import Config, checks, commands 7 | from redbot.core.utils.chat_formatting import box 8 | 9 | __all__ = ["UNIQUE_ID", "WelcomeCount"] 10 | 11 | UNIQUE_ID = 0x6F7951A4 12 | _DEFAULT_WELCOME = ( 13 | "Welcome, {mention}, to {server}!\n\n{count} user{plural} joined today!" 14 | ) 15 | 16 | 17 | class WelcomeCount(commands.Cog): 18 | """A special welcome cog which keeps a daily count of new users. 19 | 20 | Idea came from Twentysix's version of Red on the official Red-DiscordBot 21 | server. 22 | """ 23 | 24 | def __init__(self): 25 | super().__init__() 26 | self.conf: Config = Config.get_conf( 27 | self, identifier=UNIQUE_ID, force_registration=True 28 | ) 29 | self.conf.register_channel( 30 | enabled=False, 31 | last_message=None, 32 | delete_last_message=True, 33 | welcome_msg=_DEFAULT_WELCOME, 34 | ) 35 | self.conf.register_channel( 36 | enabled=False, last_message=None, welcome_msg=_DEFAULT_WELCOME 37 | ) 38 | self.conf.register_guild(count=0, day=None, join_role=None) 39 | 40 | @checks.admin_or_permissions(manage_guild=True) 41 | @commands.guild_only() 42 | @commands.group(invoke_without_command=True, aliases=["wcount"]) 43 | async def welcomecount(self, ctx: commands.Context): 44 | """Manage settings for WelcomeCount.""" 45 | if not ctx.invoked_subcommand: 46 | await ctx.send_help() 47 | channel: discord.TextChannel = ctx.channel 48 | settings = self.conf.channel(channel) 49 | if await settings.enabled(): 50 | msg: str = await settings.welcome_msg() 51 | delete_last: bool = await settings.delete_last_message() 52 | await ctx.send( 53 | box( 54 | "Enabled in this channel.\n" 55 | "Deletion of previous welcome message enabled: {0}\n" 56 | "Welcome message: {1}" 57 | "".format(delete_last, msg) 58 | ) 59 | ) 60 | else: 61 | await ctx.send(box("Disabled in this channel.")) 62 | 63 | @welcomecount.command(name="toggle") 64 | async def welcomecount_toggle(self, ctx: commands.Context): 65 | """Toggle welcome messages in this channel.""" 66 | channel: discord.TextChannel = ctx.channel 67 | settings = self.conf.channel(channel) 68 | now_enabled: bool = not await settings.enabled() 69 | await settings.enabled.set(now_enabled) 70 | await ctx.send( 71 | "Welcome messages are now {0} in this channel." 72 | "".format("enabled" if now_enabled else "disabled") 73 | ) 74 | 75 | @welcomecount.command(name="message") 76 | async def welcomecount_message(self, ctx: commands.Context, *, message: str): 77 | """Set the bot's welcome message. 78 | 79 | This message can be formatted using these parameters: 80 | mention - Mention the user who joined 81 | username - The user's display name 82 | server - The name of the server 83 | count - The number of users who joined today. 84 | plural - Empty if `count` is 1. 's' otherwise. 85 | total - The total number of users in the server. 86 | To format the welcome message with the above parameters, include them 87 | in your message surrounded by curly braces {}. 88 | """ 89 | channel: discord.TextChannel = ctx.channel 90 | settings = self.conf.channel(channel) 91 | await settings.welcome_msg.set(message) 92 | member: discord.Member = ctx.author 93 | count: int = await self.conf.guild(ctx.guild).count() 94 | params = { 95 | "mention": member.mention, 96 | "username": member.display_name, 97 | "server": ctx.guild.name, 98 | "count": count, 99 | "plural": "" if count == 1 else "s", 100 | "total": ctx.guild.member_count, 101 | } 102 | try: 103 | to_send = message.format(**params) 104 | except KeyError as exc: 105 | await ctx.send( 106 | f"The welcome message cannot be formatted, because it contains an " 107 | f"invalid placeholder `{{{exc.args[0]}}}`. See `{ctx.clean_prefix}help " 108 | f"welcomecount message` for a list of valid placeholders." 109 | ) 110 | else: 111 | await ctx.send( 112 | "Welcome message set, here's what it'll look like:\n\n" + to_send 113 | ) 114 | 115 | @welcomecount.command(name="deletelast") 116 | async def welcomecount_deletelast(self, ctx: commands.Context): 117 | """Toggle deleting the previous welcome message in this channel. 118 | 119 | When enabled, the last message is deleted *only* if it was sent on 120 | the same day as the new welcome message. 121 | """ 122 | channel: discord.TextChannel = ctx.channel 123 | settings = self.conf.channel(channel) 124 | now_deleting: bool = not await settings.delete_last_message() 125 | await settings.delete_last_message.set(now_deleting) 126 | await ctx.send( 127 | "Deleting welcome messages are now {0} in this channel." 128 | "".format("enabled" if now_deleting else "disabled") 129 | ) 130 | 131 | @welcomecount.command(name="joinrole") 132 | async def welcomecount_joinrole( 133 | self, ctx: commands.Context, *, role: Union[discord.Role, str] 134 | ): 135 | """Set a role which a user must receive before they're welcomed. 136 | 137 | This means that, instead of the welcome message being sent when 138 | the user joins the server, the welcome message will be sent when 139 | they receive a particular role. 140 | 141 | Use `[p]welcomecount joinrole disable` to revert to the default 142 | behaviour. 143 | """ 144 | if isinstance(role, discord.Role): 145 | await self.conf.guild(ctx.guild).join_role.set(role.id) 146 | await ctx.tick() 147 | elif role.lower() == "disable": 148 | await self.conf.guild(ctx.guild).join_role.clear() 149 | await ctx.tick() 150 | else: 151 | await ctx.send(f'Role "{role}" not found.') 152 | 153 | async def send_welcome_message(self, member: discord.Member) -> None: 154 | guild: discord.Guild = member.guild 155 | server_settings = self.conf.guild(guild) 156 | today: datetime.date = datetime.date.today() 157 | new_day: bool = False 158 | if await server_settings.day() == str(today): 159 | cur_count: int = await server_settings.count() 160 | await server_settings.count.set(cur_count + 1) 161 | else: 162 | new_day = True 163 | await server_settings.day.set(str(today)) 164 | await server_settings.count.set(1) 165 | 166 | welcome_channels: List[discord.TextChannel] = [] 167 | # noinspection PyUnusedLocal 168 | channel: discord.TextChannel 169 | for channel in guild.channels: 170 | if await self.conf.channel(channel).enabled(): 171 | welcome_channels.append(channel) 172 | 173 | for channel in welcome_channels: 174 | channel_settings = self.conf.channel(channel) 175 | 176 | delete_last: bool = await channel_settings.delete_last_message() 177 | if delete_last and not new_day: 178 | last_message: int = await channel_settings.last_message() 179 | try: 180 | last_message: discord.Message = await channel.fetch_message( 181 | last_message 182 | ) 183 | except discord.HTTPException: 184 | # Perhaps the message was deleted 185 | pass 186 | else: 187 | await last_message.delete() 188 | count: int = await server_settings.count() 189 | params = { 190 | "mention": member.mention, 191 | "username": member.display_name, 192 | "server": guild.name, 193 | "count": count, 194 | "plural": "" if count == 1 else "s", 195 | "total": guild.member_count, 196 | } 197 | welcome: str = await channel_settings.welcome_msg() 198 | msg: discord.Message = await channel.send(welcome.format(**params)) 199 | await channel_settings.last_message.set(msg.id) 200 | 201 | # Events 202 | 203 | @commands.Cog.listener() 204 | async def on_member_join(self, member: discord.Member): 205 | """Send the welcome message and update the last message.""" 206 | if await self.conf.guild(member.guild).join_role() is None: 207 | await self.send_welcome_message(member) 208 | 209 | @commands.Cog.listener() 210 | async def on_member_update(self, before: discord.Member, after: discord.Member): 211 | join_role_id = await self.conf.guild(before.guild).join_role() 212 | if join_role_id is None: 213 | return 214 | 215 | before_roles = frozenset(before.roles) 216 | after_roles = frozenset(after.roles) 217 | try: 218 | added_role = next(iter(after_roles - before_roles)) 219 | except StopIteration: 220 | # A role wasn't added 221 | return 222 | 223 | if added_role.id == join_role_id: 224 | await self.send_welcome_message(after) 225 | --------------------------------------------------------------------------------