├── .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 |
--------------------------------------------------------------------------------