diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 00000000..540e4de6
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @sloth-ontabasco @Utkarsh-Patel-13 @arcinfini @KvantOrav
\ No newline at end of file
diff --git a/.github/images/birdbot.png b/.github/images/birdbot.png
new file mode 100644
index 00000000..0c9f0188
Binary files /dev/null and b/.github/images/birdbot.png differ
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..e299f82f
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,14 @@
+name: Deploy Birdbot
+
+on:
+ push:
+ branches:
+ - 'master'
+ - 'main'
+ - 'public-refactor'
+ - 'staging'
+ - 'staging-deploy'
+
+jobs:
+ deploy:
+ uses: kurzgesagt-in-a-nutshell/.github/.github/workflows/deploy-birdbot.yml@self-hosted-runner
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 00000000..3bc1ab25
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,16 @@
+name: Lint Python and check license headers
+
+on:
+ pull_request:
+ branches:
+ - '**'
+ push:
+ branches:
+ - 'master'
+ - 'main'
+
+jobs:
+ lint:
+ uses: kurzgesagt-in-a-nutshell/.github/.github/workflows/python-lint.yml@main
+ license-checker:
+ uses: kurzgesagt-in-a-nutshell/.github/.github/workflows/license-check.yml@main
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 00000000..b069a04d
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,26 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.5.0
+ hooks:
+ - id: check-merge-conflict
+ - id: check-yaml
+ - repo: local
+ hooks:
+ - id: black-format
+ name: black-format
+ entry: black
+ language: system
+ types: [python]
+ - id: isort-format
+ name: isort-format
+ entry: isort
+ language: system
+ types: [python]
+ stages: [commit, push, manual]
+ - id: pyright
+ name: pyright
+ entry: pyright .
+ language: system
+ types: [python]
+ pass_filenames: false
+ stages: [commit, push, manual]
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 80ab69ca..00000000
--- a/Dockerfile
+++ /dev/null
@@ -1,13 +0,0 @@
-FROM python:3.8-slim
-
-ENV VIRTUAL_ENV=/opt/venv
-RUN python3 -m venv $VIRTUAL_ENV
-ENV PATH="$VIRTUAL_ENV/bin:$PATH"
-
-#install dependencies
-COPY requirements.txt /bot/
-WORKDIR /bot/
-RUN pip install -r requirements.txt
-
-COPY . /bot/
-CMD ["python3","kurzgesagt.py"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..f288702d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
index c8283764..78fa77d3 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,65 @@
-# Kurzgesagtbot
+
+
+
+
+ Bird Bot
+
+
-A multi-purpose discord bot for [Kurzgesagt Discord](https://discord.gg/kurzgesagt).
+The almighty and powerful Bird Bot helps run the Kurzgesagt discord server like a well oiled machine
+
+# ➡️ Getting Started
+
+## ⚙️ Prerequisites
+
+- [Python 3.11 or higher](https://www.python.org/)
+- [MongoDB](https://www.mongodb.com/)
+
+## 📦 Installation
+
+Follow these steps to get the bot up and running in your system
+- Run the following commands
+
+```
+# clone the repository
+git clone https://github.com/kurzgesagt-in-a-nutshell/kurzgesagtbot
+
+# install virtualenv if you haven't already (Or use another Virtual Environment manager)
+pip install virtualenv
+
+# Setup a venv
+python3.11 -m venv birdbot
+source birdbot/bin/activate
+
+# install the dependencies
+pip install -r requirements.txt
+```
+
+- Navigate to /app/utils/config.py and change the values of the variables according to your requirements
+
+- Create a file named `.env` and paste the following lines in it. Change the values of the variables according to your requirements
+```
+MAIN_BOT_TOKEN='INSERT_MAIN_BOT_TOKEN'
+BETA_BOT_TOKEN='INSERT_BETA_BOT_TOKEN'
+ALPHA_BOT_TOKEN='INSERT_ALPHA_BOT_TOKEN'
+DB_KEY='INSERT_MONGODB_DATABASE_CONNECTION_URL'
+```
+
+- Run the bot with, use the `-a` or `-b` option to run testing versions of the bot
+```
+python3 startbot.py [-b] [-a]
+```
+
+If you need additional help you may join our [Discord Server](https://discord.gg/kurzgesagt)
+
+# 🤲 Contributing
+
+Please read our contributor guidelines [here](https://github.com/kurzgesagt-in-a-nutshell/.github/blob/main/CONTRIBUTING.md) before contributing
+
+Before submitting a pull request please ensure you conform to our [PyRight](https://github.com/microsoft/pyright) standards and be sure to use [ISort](https://pycqa.github.io/isort/#using-isort) import sorter and the [Black](https://github.com/psf/black) code formatter.
+Run these commands (preferably in the given order) and make sure they do not throw any errors:
+```
+pyright .
+isort .
+black .
+```
diff --git a/antiraid.json b/antiraid.json
deleted file mode 100644
index 08fa5173..00000000
--- a/antiraid.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "raidmode": {
- "joins": 100,
- "during": 1,
- "active": false
- }
-}
\ No newline at end of file
diff --git a/app/birdbot.py b/app/birdbot.py
new file mode 100644
index 00000000..9264e1de
--- /dev/null
+++ b/app/birdbot.py
@@ -0,0 +1,336 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This module contains the implementation of the BirdBot class. Along with the setup function
+"""
+
+import argparse
+import asyncio
+import io
+import logging
+import os
+import traceback
+from contextlib import contextmanager, suppress
+from logging.handlers import TimedRotatingFileHandler
+from pathlib import Path
+
+import certifi
+import discord
+import dotenv
+from discord import Interaction, TextChannel, app_commands
+from discord.abc import GuildChannel
+from discord.ext import commands
+from rich.logging import RichHandler
+
+from .utils import errors
+from .utils.config import Reference
+
+logger = logging.getLogger("BirdBot")
+
+
+@contextmanager
+def setup():
+ """
+ Setup the logger.
+ """
+ logger = logging.getLogger()
+ try:
+ dotenv.load_dotenv()
+ logging.getLogger("discord").setLevel(logging.INFO)
+ logging.getLogger("discord.http").setLevel(logging.INFO)
+
+ logger.setLevel(logging.DEBUG)
+ dtfmt = "%Y-%m-%d %H:%M:%S"
+ if not os.path.isdir("logs/"):
+ os.mkdir("logs/")
+ handlers = [
+ RichHandler(rich_tracebacks=True),
+ TimedRotatingFileHandler(filename="logs/birdbot.log", when="d", interval=5),
+ ]
+ fmt = logging.Formatter("[{asctime}] [{levelname:<7}] {name}: {message}", dtfmt, style="{")
+
+ for handler in handlers:
+ if isinstance(handler, TimedRotatingFileHandler):
+ handler.setFormatter(fmt)
+ logger.addHandler(handler)
+
+ yield
+ finally:
+ handlers = logger.handlers[:]
+ for handler in handlers:
+ handler.close()
+ logger.removeHandler(handler)
+
+
+class BirdTree(app_commands.CommandTree):
+ """
+ Subclass of app_commands.CommandTree to define the behavior for the birdbot tree.
+
+ Handles thrown errors within the tree and interactions between all commands.
+ """
+
+ @classmethod
+ async def maybe_responded(cls, interaction: Interaction, *args, **kwargs):
+ """
+ Either responds or sends a followup on an interaction response.
+ """
+ if interaction.response.is_done():
+ await interaction.followup.send(*args, **kwargs)
+
+ return
+
+ await interaction.response.send_message(*args, **kwargs)
+
+ async def alert(self, interaction: Interaction, error: app_commands.AppCommandError):
+ """
+ Attempts to alert the discord channel logs of an exception.
+ """
+
+ channel = await interaction.client.fetch_channel(Reference.Channels.Logging.dev)
+ assert isinstance(channel, TextChannel)
+
+ content = traceback.format_exc()
+
+ file = discord.File(io.BytesIO(bytes(content, encoding="UTF-8")), filename=f"{type(error)}.py")
+
+ embed = discord.Embed(
+ title="Unhandled Exception Alert",
+ description=f"```\nContext: \nguild:{repr(interaction.guild)}\n{repr(interaction.channel)}\n{repr(interaction.user)}\n```", # f"```py\n{content[2000:].strip()}\n```"
+ )
+
+ await channel.send(embed=embed, file=file)
+
+ async def on_error(self, interaction: Interaction, error: app_commands.AppCommandError):
+ """
+ Handles errors thrown within the command tree.
+
+ Informs the user of failure and logs code errors.
+ """
+ if isinstance(error, errors.InternalError):
+ # Inform user of failure ephemerally
+
+ embed = error.format_notif_embed(interaction)
+ await BirdTree.maybe_responded(interaction, embed=embed, ephemeral=True)
+
+ return
+ elif isinstance(error, app_commands.CheckFailure):
+ user_shown_error = errors.CheckFailure(content=str(error))
+
+ embed = user_shown_error.format_notif_embed(interaction)
+ await BirdTree.maybe_responded(interaction, embed=embed, ephemeral=True)
+
+ return
+
+ # most cases this will consist of errors thrown by the actual code
+
+ if isinstance(interaction.channel, GuildChannel):
+ is_in_public_channel = interaction.channel.category_id != Reference.Categories.moderation
+ else:
+ is_in_public_channel = False
+
+ user_shown_error = errors.InternalError()
+ await BirdTree.maybe_responded(
+ interaction, embed=user_shown_error.format_notif_embed(interaction), ephemeral=is_in_public_channel
+ )
+
+ try:
+ await self.alert(interaction, error)
+ except app_commands.AppCommandError as e:
+ await super().on_error(interaction, e)
+
+
+class BirdBot(commands.AutoShardedBot):
+ """
+ Main Bot, inherited from AutoShardedBot.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.get_database()
+ self.args = None
+
+ @classmethod
+ def from_parseargs(cls, args: argparse.Namespace):
+ """
+ Create and return an instance of a Bot from argparse Namespace instance.
+ """
+ logger.info(args)
+ allowed_mentions = discord.AllowedMentions(roles=False, everyone=False, users=True)
+ loop = asyncio.get_event_loop()
+ intents = discord.Intents(
+ guilds=True,
+ members=True,
+ bans=True,
+ emojis=True,
+ webhooks=True,
+ messages=True,
+ reactions=True,
+ message_content=True,
+ presences=True,
+ )
+ max_messages = 1000
+ if args.beta:
+ prefix = "b!"
+ owner_ids = Reference.botdevlist
+ activity = discord.Activity(type=discord.ActivityType.watching, name="for bugs")
+ elif args.alpha:
+ prefix = "a!"
+ owner_ids = Reference.botdevlist
+ activity = discord.Activity(type=discord.ActivityType.playing, name="imagine being a beta")
+ else:
+ prefix = "!"
+ owner_ids = Reference.botownerlist
+ max_messages = 10000
+ activity = discord.Activity(type=discord.ActivityType.listening, name="Steve's voice")
+ x = cls(
+ loop=loop,
+ max_messages=max_messages,
+ command_prefix=commands.when_mentioned_or(prefix),
+ owner_ids=owner_ids,
+ activity=activity,
+ case_insensitive=True,
+ allowed_mentions=allowed_mentions,
+ intents=intents,
+ tree_cls=BirdTree,
+ )
+
+ x.get_database()
+ x.args = args
+ return x
+
+ @classmethod
+ def get_database(cls):
+ """
+ Return MongoClient instance to self.db.
+ """
+ from pymongo import MongoClient
+
+ db_key = os.environ.get("DB_KEY")
+ if db_key is None:
+ logger.critical("NO DB KEY FOUND, USING LOCAL DB INSTEAD")
+ client = MongoClient(db_key, tlsCAFile=certifi.where())
+ db = client.KurzBot
+ logger.info("Connected to mongoDB")
+ cls.db = db
+
+ async def setup_hook(self):
+ """
+ Async setup for after the bot logs in.
+ """
+ if self.args:
+ await self.load_extensions("app/cogs", self.args)
+
+ async def load_extensions(self, folder: Path | str, args: argparse.Namespace) -> None:
+ """
+ Iterates over the extension folder and attempts to load all python files found.
+ """
+
+ if folder is None:
+ return
+ extdir = Path(folder)
+
+ if not extdir.is_dir():
+ return
+
+ for item in extdir.iterdir():
+ # Ignore some cogs for the test bots.
+ if item.stem in ("antiraid", "automod", "giveaway") and (args.beta or args.alpha):
+ logger.debug("Skipping: %s", item.name)
+ continue
+
+ if item.name.startswith("_"):
+ continue
+ if item.is_dir():
+ await self.load_extensions(item, args)
+ continue
+
+ if item.suffix == ".py":
+ await self.try_load(item)
+
+ async def try_load(self, path: Path) -> bool:
+ """
+ Attempts to load the given path and returns a boolean indicating successful status.
+ """
+
+ extension = ".".join(path.with_suffix("").parts)
+
+ try:
+ await self.load_extension(extension)
+ return True
+ except Exception as e:
+ logger.error("an error occurred while loading extension", exc_info=e)
+ return False
+
+ async def close(self):
+ """
+ Close the Discord connection and the aiohttp sessions if any (future perhaps?).
+ """
+ for ext in list(self.extensions):
+ with suppress(Exception):
+ await self.unload_extension(ext)
+
+ for cog in list(self.cogs):
+ with suppress(Exception):
+ await self.remove_cog(cog)
+
+ await super().close()
+
+ async def on_ready(self):
+ assert self.user is not None
+ logger.info("Logged in as")
+ logger.info(f"\tUser: {self.user.name}")
+ logger.info(f"\tID : {self.user.id}")
+ logger.info("------")
+
+ """"
+ From here on it's custom functions we can use in cogs.
+ """
+
+ def _user(self) -> discord.ClientUser:
+ """
+ Get self.bot.user.
+
+ This can only be used after login, as user can't be none.
+ """
+ user = self.user
+ if user == None:
+ raise errors.InvalidFunctionUsage()
+ return user
+
+ def ismainbot(self) -> bool:
+ """
+ Checks if self.bot is mainbot.
+
+ Only works after login.
+ """
+ if self._user().id == Reference.mainbot:
+ return True
+ return False
+
+ def _get_channel(self, id: int) -> discord.TextChannel:
+ """
+ Used to get Reference channels, only works with TextChannel.
+ """
+ channel = self.get_channel(id)
+ if not isinstance(channel, discord.TextChannel):
+ raise errors.InvalidFunctionUsage()
+ return channel
+
+ def get_mainguild(self) -> discord.Guild:
+ """
+ Returns Reference guild.
+ """
+ guild = self.get_guild(Reference.guild)
+ if guild == None:
+ raise errors.InvalidFunctionUsage()
+ return guild
diff --git a/cogs/automod.py b/app/cogs/automod.py
similarity index 82%
rename from cogs/automod.py
rename to app/cogs/automod.py
index 3d6823ff..d63d359b 100644
--- a/cogs/automod.py
+++ b/app/cogs/automod.py
@@ -1,48 +1,54 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+Magic written by austin. Handles text and gif filtering.
+
+Proceed at your own risk. Comments within the document may not be entirely
+accurate.
+"""
+
+import asyncio
import copy
-import json
-import typing
import datetime
+import io
import logging
import re
-import asyncio
-import io
-import demoji
+import typing
+import demoji
import discord
from discord import app_commands
from discord.ext import commands
-from utils import app_checks
-from utils.helper import (
- create_automod_embed,
- is_internal_command,
- is_external_command,
-)
+from app.birdbot import BirdBot
+from app.utils import checks
+from app.utils.config import Reference
+from app.utils.helper import create_automod_embed, is_external_command, is_internal_command
class Filter(commands.Cog):
- def __init__(self, bot):
+ def __init__(self, bot: BirdBot):
self.logger = logging.getLogger("Automod")
self.bot = bot
- config_file = open("config.json", "r")
- self.config_json = json.loads(config_file.read())
- config_file.close()
-
- self.logging_channel_id = self.config_json["logging"]["automod_logging_channel"]
+ self.logging_channel_id = Reference.Channels.Logging.automod_actions
self.logging_channel = None
self.message_history_list = {}
self.message_history_lock = asyncio.Lock()
- self.humanities_list = self.bot.db.filterlist.find_one({"name": "humanities"})[
- "filter"
- ]
- self.general_list = self.bot.db.filterlist.find_one({"name": "general"})[
- "filter"
- ]
- self.white_list = self.bot.db.filterlist.find_one({"name": "whitelist"})[
- "filter"
- ]
+ self.humanities_list: typing.List[str] = self.bot.db.filterlist.find_one({"name": "humanities"})["filter"]
+ self.general_list: typing.List[str] = self.bot.db.filterlist.find_one({"name": "general"})["filter"]
+ self.white_list: typing.List[str] = self.bot.db.filterlist.find_one({"name": "whitelist"})["filter"]
self.general_list_regex = self.generate_regex(self.general_list)
self.humanities_list_regex = self.generate_regex(self.humanities_list)
@@ -56,12 +62,12 @@ async def on_ready(self):
filter_commands = app_commands.Group(
name="filter",
description="Automod filter commands",
- guild_ids=[414027124836532234],
+ guild_ids=[Reference.guild],
default_permissions=discord.permissions.Permissions(manage_messages=True),
)
# return the required list
- def return_list(self, listtype):
+ def return_list(self, listtype) -> typing.List[str] | None:
if listtype == "whitelist":
return self.white_list
elif listtype == "general":
@@ -80,30 +86,25 @@ def return_regex(self, listtype):
# Updates filter list from Mongo based on listtype
async def updatelist(self, listtype):
if listtype == "whitelist":
- self.white_list = self.bot.db.filterlist.find_one({"name": "whitelist"})[
- "filter"
- ]
+ self.white_list = self.bot.db.filterlist.find_one({"name": "whitelist"})["filter"]
elif listtype == "general":
- self.general_list = self.bot.db.filterlist.find_one({"name": "general"})[
- "filter"
- ]
+ self.general_list = self.bot.db.filterlist.find_one({"name": "general"})["filter"]
self.general_list_regex = self.generate_regex(self.general_list)
elif listtype == "humanities":
- self.humanities_list = self.bot.db.filterlist.find_one(
- {"name": "humanities"}
- )["filter"]
+ self.humanities_list = self.bot.db.filterlist.find_one({"name": "humanities"})["filter"]
self.humanities_list_regex = self.generate_regex(self.humanities_list)
@filter_commands.command()
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def show(
self,
interaction: discord.Interaction,
list_type: typing.Literal["whitelist", "general", "humanities"],
):
- """Show words in selected filter list
+ """
+ Show words in selected filter list.
Parameters
----------
@@ -113,20 +114,19 @@ async def show(
filelist = self.return_list(list_type)
await interaction.response.send_message(
f"These are the words which are in the {list_type}{'blacklist' if list_type != 'whitelist' else ''}",
- file=discord.File(
- io.BytesIO("\n".join(filelist).encode("UTF-8")), f"{list_type}.txt"
- ),
+ file=discord.File(io.BytesIO("\n".join(filelist).encode("UTF-8")), f"{list_type}.txt"),
)
@filter_commands.command()
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def add(
self,
interaction: discord.Interaction,
list_type: typing.Literal["whitelist", "general", "humanities"],
word: str,
):
- """Add a word in selected filter list
+ """
+ Add a word in selected filter list.
Parameters
----------
@@ -146,21 +146,20 @@ async def add(
f"`{word}` added to the {list_type}{' list' if list_type != 'whitelist' else ''}."
)
- self.bot.db.filterlist.update_one(
- {"name": list_type}, {"$push": {"filter": word}}
- )
+ self.bot.db.filterlist.update_one({"name": list_type}, {"$push": {"filter": word}})
await self.updatelist(list_type)
@filter_commands.command()
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def remove(
self,
interaction: discord.Interaction,
list_type: typing.Literal["whitelist", "general", "humanities"],
word: str,
):
- """Remove a word in selected filter list
+ """
+ Remove a word in selected filter list.
Parameters
----------
@@ -180,13 +179,11 @@ async def remove(
f"`{word}` removed from the {list_type}{' list' if list_type != 'whitelist' else ''}."
)
- self.bot.db.filterlist.update_one(
- {"name": list_type}, {"$pull": {"filter": word}}
- )
+ self.bot.db.filterlist.update_one({"name": list_type}, {"$pull": {"filter": word}})
await self.updatelist(list_type)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
@filter_commands.command()
async def check(
self,
@@ -194,7 +191,8 @@ async def check(
list_type: typing.Literal["general", "humanities"],
text: str,
):
- """Check if a word/phrase contains profanity
+ """
+ Check if a word/phrase contains profanity.
Parameters
----------
@@ -217,23 +215,23 @@ async def check(
await interaction.response.send_message("No profanity.")
@commands.Cog.listener()
- async def on_member_update(self, before, after):
+ async def on_member_update(self, before: discord.Member, after: discord.Member):
if before.nick == after.nick:
return
await self.check_member(after)
@commands.Cog.listener()
- async def on_message(self, message):
+ async def on_message(self, message: discord.Message):
if isinstance(message.channel, discord.DMChannel):
return
if (
- message.channel.category.id == 414095379156434945 # mod category
- and message.channel.id != 414179142020366336 # bot testing
+ message.channel.category.id == Reference.Categories.moderation
+ and message.channel.id != Reference.Channels.bot_tests
):
return
- if message.channel.category.id ==974333356688965672: # language testing
- return
+ if message.channel.category.id == Reference.Channels.language_tests: # language testing
+ return
if self.is_member_excluded(message.author):
return
@@ -256,22 +254,22 @@ async def on_message(self, message):
if is_external_command(message):
return
- self.logging_channel = await self.bot.fetch_channel(self.logging_channel_id)
+ self.logging_channel = self.bot.get_channel(self.logging_channel_id)
if not isinstance(message.channel, discord.DMChannel):
await self.check_message(message)
await self.check_member(message.author)
@commands.Cog.listener()
- async def on_message_edit(self, before, after):
- if after.channel.id == 414452106129571842:
+ async def on_message_edit(self, before: discord.Message, after: discord.Message):
+ if after.channel.id == Reference.Channels.bot_commands:
return
if self.is_member_excluded(after.author):
return
if (
- before.channel.category.id == 414095379156434945 # mod category
- and before.channel.id != 414179142020366336 # bot testing
+ before.channel.category.id == Reference.Categories.moderation # mod category
+ and before.channel.id != Reference.Channels.bot_tests # bot testing
):
return
if before.content == after.content:
@@ -301,22 +299,18 @@ async def check_member(self, member):
await member.edit(nick="Kurzgesagt Fan")
if member.nick is None:
- if not re.search(
- r"[a-zA-Z0-9~!@#$%^&*()_+`;':\",./<>?]{3,}", member.name, re.IGNORECASE
- ):
+ if not re.search(r"[a-zA-Z0-9~!@#$%^&*()_+`;':\",./<>?]{3,}", member.name, re.IGNORECASE):
await member.edit(nick="Unpingable Username")
if any(s in member.name for s in ("nazi", "hitler", "führer", "fuhrer")):
await member.edit(nick="Parrot")
else:
- if not re.search(
- r"[a-zA-Z0-9~!@#$%^&*()_+`;':\",./<>?]{3,}", member.nick, re.IGNORECASE
- ):
+ if not re.search(r"[a-zA-Z0-9~!@#$%^&*()_+`;':\",./<>?]{3,}", member.nick, re.IGNORECASE):
await member.edit(nick="Unpingable Nickname")
if any(s in member.nick for s in ("nazi", "hitler", "führer", "fuhrer")):
await member.edit(nick=None)
- async def execute_action_on_message(self, message, actions):
+ async def execute_action_on_message(self, message: discord.Message, actions):
# TODO: make embeds more consistent once mod policy is set
if "ping" in actions:
if "delete_after" in actions:
@@ -334,12 +328,10 @@ async def execute_action_on_message(self, message, actions):
if isinstance(actions["delete_message"], int):
async with self.message_history_lock:
for i in range(4):
- await self.message_history_list[actions["delete_message"]][
- i
- ].delete()
- self.message_history_list[
+ await self.message_history_list[actions["delete_message"]][i].delete()
+ self.message_history_list[actions["delete_message"]] = self.message_history_list[
actions["delete_message"]
- ] = self.message_history_list[actions["delete_message"]][4:]
+ ][4:]
else:
await message.delete()
@@ -348,9 +340,7 @@ async def execute_action_on_message(self, message, actions):
await message.author.timeout(time, reason="spam")
try:
- await message.author.send(
- f"You have been muted for 30 minutes.\nGiven reason: Spam\n"
- )
+ await message.author.send(f"You have been muted for 30 minutes.\nGiven reason: Spam\n")
except discord.Forbidden:
pass
@@ -362,18 +352,16 @@ async def execute_action_on_message(self, message, actions):
# logic for messaging the user
if "log" in actions:
- embed = create_automod_embed(
- message=message, automod_type=actions.get("log")
- )
+ embed = create_automod_embed(message=message, automod_type=actions.get("log"))
await self.logging_channel.send(embed=embed)
def is_member_excluded(self, author):
rolelist = [
- 414092550031278091, # mod
- 414029841101225985, # admin
- 414954904382210049, # offical
- 414155501518061578, # robobird
- 240254129333731328, # stealth
+ Reference.Roles.moderator, # mod
+ Reference.Roles.administrator, # admin
+ Reference.Roles.kgsofficial, # offical
+ Reference.Roles.robobird, # robobird
+ Reference.Roles.stealthbot, # stealth
]
if author.bot:
return True
@@ -409,13 +397,7 @@ def check_profanity(self, ref_word_list, regex_list, message_clean):
indexes = [x.start() for x in re.finditer(r"\?", message_clean)]
# get rid of all other non ascii characters
message_clean = demoji.replace(message_clean, "*")
- message_clean = (
- str(message_clean)
- .encode("ascii", "replace")
- .decode()
- .lower()
- .replace("?", "*")
- )
+ message_clean = str(message_clean).encode("ascii", "replace").decode().lower().replace("?", "*")
# put back question marks
message_clean = list(message_clean)
for i in indexes:
@@ -458,7 +440,7 @@ def exception_list_check(self, offending_list):
# check for emoji spam
def check_emoji_spam(self, message):
- if message.channel.id == 526882555174191125: # new-members
+ if message.channel.id == Reference.Channels.new_members: # new-members
return False
if (
@@ -480,25 +462,19 @@ def check_emoji_spam(self, message):
# check for text spam
def check_text_spam(self, message):
- if message.channel.id == 526882555174191125: # new-members
+ if message.channel.id == Reference.Channels.new_members: # new-members
return False
# if the user has past messages
if message.author.id in self.message_history_list:
count = len(self.message_history_list[message.author.id])
if count > 3:
- if all(
- m.content == message.content
- for m in self.message_history_list[message.author.id][0:4]
- ):
+ if all(m.content == message.content for m in self.message_history_list[message.author.id][0:4]):
return message.author.id
if message.channel.id in self.message_history_list:
if len(self.message_history_list[message.channel.id]) > 3:
- if all(
- m.content == message.content
- for m in self.message_history_list[message.channel.id][0:4]
- ):
+ if all(m.content == message.content for m in self.message_history_list[message.channel.id][0:4]):
return message.channel.id
# check for mass ping
@@ -508,13 +484,13 @@ def check_ping_spam(self, message):
# check for gif bypass
def check_gif_bypass(self, message):
- filetypes = ["mp4", "gif", "webm", "gifv"]
+ filetypes = ["mp4", "gif", "webm", "gifv", "mov"]
- # general, bot-testing and humanities
if message.channel.id not in (
- 414027124836532236,
- 414179142020366336,
- 546315063745839115,
+ Reference.Channels.the_perch,
+ Reference.Channels.general,
+ Reference.Channels.bot_tests,
+ Reference.Channels.humanities,
):
return
# This is too aggressive and shouldn't be necessary. Leaving it commented for now though.
@@ -564,9 +540,7 @@ async def check_message(self, message):
)
embed = create_automod_embed(message=message, automod_type="profanity")
- embed.add_field(
- name="Blacklisted Word", value=is_profanity[:1024], inline=False
- )
+ embed.add_field(name="Blacklisted Word", value=is_profanity[:1024], inline=False)
file = discord.File(io.BytesIO(message.content.encode("UTF-8")), f"log.txt")
await self.logging_channel.send(embed=embed, file=file)
return
@@ -598,7 +572,6 @@ async def check_message(self, message):
# this one goes last due to lock
async with self.message_history_lock:
-
# if getting past this point we write to message history and pop if to many messages
if message.author.id in self.message_history_list:
@@ -772,5 +745,5 @@ def generate_regex(self, words):
return regexlist
-async def setup(bot):
+async def setup(bot: BirdBot):
await bot.add_cog(Filter(bot))
diff --git a/app/cogs/banner.py b/app/cogs/banner.py
new file mode 100644
index 00000000..4d3f1d11
--- /dev/null
+++ b/app/cogs/banner.py
@@ -0,0 +1,449 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+banner.py
+
+The banners that are stored within the mongo database are the image urls that
+are sent in the banners_and_topics channel. If the reference to these are
+removed then the urls are lost.
+
+The banner suggest design goes as following:
+User runs command to suggest photo. In this process an embed is created to
+be displayed to the qualified users to accept or deny the suggested photo. A
+view is attached to the embed's message to enable the user input of accepting
+or denying the photo. This view is a global view and therefore can be used
+multiple times with different messages and listens to all active references of
+it.
+
+Once the qualified user selects the action, the view is removed, the embed is
+updated to display the chocie made and the banner is added or not.
+"""
+
+import io
+import logging
+import re
+import typing
+
+import aiohttp
+import discord
+from discord import Interaction, app_commands
+from discord import ui as dui
+from discord.ext import commands, tasks
+from discord.interactions import Interaction
+from pymongo.errors import CollectionInvalid
+
+from app.birdbot import BirdBot
+from app.utils import checks, errors
+from app.utils.config import Reference
+from app.utils.helper import BannerCycle, calc_time, get_time_string
+
+if typing.TYPE_CHECKING:
+ from pymongo.collection import Collection
+
+logger = logging.getLogger(__name__)
+
+
+class BannerView(dui.View):
+ """
+ The static view that is used for handling the controls of accepting or denying a banner suggestion.
+ """
+
+ def __init__(self, banner_db, banners: list, accept_id: str, deny_id: str):
+ super().__init__(timeout=None)
+
+ self.banner_db: Collection = banner_db
+ self.banners = banners
+
+ self._accept.custom_id = accept_id
+ self._deny.custom_id = deny_id
+
+ def filename_from_url(self, url: str | None):
+ """
+ Get the image filename from the discord link.
+
+ Only works for cdn.discordapp.com links.
+ """
+ if url:
+ filename = url.split("/")[6].split("?")[0]
+ else:
+ filename = "banner.png"
+ return filename
+
+ async def interaction_check(self, interaction: Interaction) -> bool:
+ """
+ Checks that the interactor is a moderator+ for the defined guild.
+ """
+
+ guild = discord.utils.get(interaction.client.guilds, id=Reference.guild)
+
+ assert guild
+ assert interaction.guild
+ assert isinstance(interaction.user, discord.Member)
+
+ mod_role = guild.get_role(Reference.Roles.moderator)
+
+ return interaction.guild.id == guild.id and interaction.user.top_role >= mod_role
+
+ @dui.button(
+ label="Accept",
+ style=discord.ButtonStyle.blurple,
+ emoji=discord.PartialEmoji.from_str(Reference.Emoji.PartialString.kgsYes),
+ )
+ async def _accept(self, interaction: Interaction, button: dui.Button):
+ """
+ Accepts the banner and removes the view from the message.
+
+ Changes the embed to indicate it was accepted and by who.
+ """
+ message = interaction.message
+ assert message
+
+ embed = message.embeds[0]
+ url = embed.image.url
+
+ embed.title = f"Accepted by {interaction.user.name}"
+ embed.colour = discord.Colour.green()
+
+ # This is needed for discord to understand we are not trying to display
+ # the file itself and the image in the embed. (duplicate images)
+
+ filename = self.filename_from_url(url)
+ embed.set_image(url=f"attachment://{filename}")
+
+ self.banners.append(message.id)
+ self.banner_db.update_one({"name": "banners_id"}, {"$set": {"banners": self.banners}})
+ BannerCycle().queue_last(message.id)
+
+ await interaction.response.edit_message(embed=embed, view=None)
+ assert embed.author.name
+ try:
+ match = re.match(r".*\(([0-9]+)\)$", embed.author.name)
+ if match:
+ userid = match.group(1)
+ suggester = await interaction.client.fetch_user(int(userid))
+ await suggester.send(f"Your banner suggestion was accepted {url}")
+
+ except discord.Forbidden:
+ pass
+
+ @dui.button(
+ label="Deny",
+ style=discord.ButtonStyle.danger,
+ emoji=discord.PartialEmoji.from_str(Reference.Emoji.PartialString.kgsNo),
+ )
+ async def _deny(self, interaction: Interaction, button: dui.Button):
+ """
+ Denies the banner and removes the view from the message.
+
+ Changes the embed to indicate it was denied and by who.
+ """
+ message = interaction.message
+ assert message
+ embed = message.embeds[0]
+
+ embed.title = f"Denied by {interaction.user.name}"
+ embed.colour = discord.Colour.red()
+
+ # This is needed for discord to understand we are not trying to display
+ # the file itself and the image in the embed. (duplicate images)
+
+ filename = self.filename_from_url(embed.image.url)
+ embed.set_image(url=f"attachment://{filename}")
+
+ await interaction.response.edit_message(embed=embed, view=None)
+
+
+class Banner(commands.Cog):
+ def __init__(self, bot: BirdBot):
+ self.logger = logging.getLogger("Banners")
+ self.bot = bot
+
+ self.index = 0
+ self.banner_db: Collection = self.bot.db.Banners
+
+ async def cog_load(self) -> None:
+ banners_find = self.banner_db.find_one({"name": "banners_id"})
+ if banners_find == None:
+ raise CollectionInvalid
+ self.banners: typing.List = banners_find["banners"]
+ self.banner_cycle = BannerCycle(self.banners)
+
+ self.BANNER_ACCEPT = f"BANNER-ACCEPT-{self.bot._user().id}"
+ self.BANNER_DENY = f"BANNER-DENY-{self.bot._user().id}"
+
+ self.BANNER_VIEW = BannerView(
+ banner_db=self.banner_db, banners=self.banners, accept_id=self.BANNER_ACCEPT, deny_id=self.BANNER_DENY
+ )
+
+ self.bot.add_view(self.BANNER_VIEW)
+
+ async def cog_unload(self):
+ self.timed_banner_rotation.cancel()
+ self.BANNER_VIEW.stop()
+
+ @commands.Cog.listener()
+ async def on_ready(self):
+ self.logger.info("loaded Banners")
+
+ banner_commands = app_commands.Group(
+ name="banner",
+ description="Guild banner commands",
+ guild_ids=[Reference.guild],
+ default_permissions=discord.permissions.Permissions(manage_messages=True),
+ )
+
+ async def verify_url(self, url: str, byte: bool = False):
+ """
+ Returns url or bytes after verifyng size and content_type.
+
+ Returns bytes object if byte is set to True.
+ """
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url) as response:
+ if response.content_type.startswith("image"):
+ banner = await response.content.read()
+
+ if len(banner) / 1024 < 10240:
+ if byte:
+ return banner, response.content_type.split("/")[1]
+ return url, response.content_type.split("/")[1]
+ raise errors.InvalidParameterError(
+ content=f"Image must be less than 10240kb, yours is {int(len(banner)/1024)}kb."
+ )
+
+ raise errors.InvalidParameterError(
+ content=f"Link must be for an image file not {response.content_type}."
+ )
+
+ except aiohttp.InvalidURL:
+ raise errors.InvalidParameterError(content="The link provided is not valid")
+
+ @banner_commands.command()
+ @checks.mod_and_above()
+ async def add(
+ self,
+ interaction: discord.Interaction,
+ image: typing.Optional[discord.Attachment] = None,
+ url: typing.Optional[str] = None,
+ ):
+ """
+ Add or upload a banner.
+
+ Parameters
+ ----------
+ image: discord.Attachment
+ An image file
+ url: str
+ URL or Link of an image
+ """
+
+ await interaction.response.defer(ephemeral=True)
+
+ automated_channel = self.bot._get_channel(Reference.Channels.banners_and_topics)
+
+ url_: bytes | str
+ if url:
+ url_, img_type = await self.verify_url(url=url, byte=True)
+ elif image:
+ url_, img_type = await self.verify_url(url=image.url, byte=True)
+ else:
+ raise errors.InvalidParameterError(content="An image file or url is required")
+
+ file = discord.File(io.BytesIO(url_), filename=f"banner.{img_type}") # type: ignore
+
+ embed = discord.Embed(title="Banner Added", color=discord.Color.green())
+ embed.set_author(
+ name=interaction.user.name + "#" + interaction.user.discriminator,
+ icon_url=interaction.user.display_avatar.url,
+ )
+ embed.set_image(url=f"attachment://banner.{img_type}")
+ embed.set_footer(text="banner")
+
+ # Uploads the information to the banners channel
+ # The message ID is then extracted from this embed to keep a static reference
+ # The message ID is later used to fetch the image
+ message = await automated_channel.send(embed=embed, file=file)
+
+ self.banners.append(message.id)
+ self.banner_db.update_one({"name": "banners_id"}, {"$set": {"banners": self.banners}})
+ BannerCycle().queue_last(message.id)
+
+ await interaction.edit_original_response(content="Banner added.")
+
+ @banner_commands.command()
+ @checks.mod_and_above()
+ async def rotate(
+ self,
+ interaction: discord.Interaction,
+ duration: typing.Optional[str] = None,
+ stop: typing.Optional[bool] = False,
+ ):
+ """
+ Change server banner rotation duration or stop the rotation.
+
+ Parameters
+ ----------
+ duration: str
+ Time (example: 3hr or 1d)
+ stop: bool
+ Weather to stop banner rotation
+ """
+
+ if not stop and not duration:
+ return await interaction.response.send_message(
+ "Please provide value for atleast one argument.", ephemeral=True
+ )
+
+ if stop:
+ self.timed_banner_rotation.cancel()
+ return await interaction.response.send_message("Banner rotation stopped.", ephemeral=True)
+
+ assert duration
+ time, extra = calc_time([duration, ""])
+ if time == 0:
+ return await interaction.response.send_message("Wrong time syntax.", ephemeral=True)
+
+ if not self.timed_banner_rotation.is_running():
+ self.timed_banner_rotation.start()
+
+ assert time
+ self.timed_banner_rotation.change_interval(seconds=time)
+
+ await interaction.response.send_message(f"Banners are rotating every {get_time_string(time)}.", ephemeral=True)
+
+ @app_commands.command()
+ @app_commands.default_permissions(send_messages=True)
+ @app_commands.guilds(Reference.guild)
+ @app_commands.checks.cooldown(1, 60, key=lambda i: (i.guild_id, i.user.id))
+ async def banner_suggest(
+ self,
+ interaction: discord.Interaction,
+ image: typing.Optional[discord.Attachment] = None,
+ url: typing.Optional[str] = None,
+ ):
+ """
+ Suggest an image from kurzgesagt for server banner.
+
+ Parameters
+ ----------
+ image: discord.Attachment
+ An image file
+ url: str
+ URL or Link of an image
+ """
+ await interaction.response.defer(ephemeral=True)
+
+ automated_channel = self.bot._get_channel(Reference.Channels.banners_and_topics)
+
+ url_: bytes | str
+ if url:
+ if not url.startswith("https://cdn.discordapp.com"):
+ raise errors.InvalidParameterError(content="Only discord cdn links are supported")
+ url_, img_type = await self.verify_url(url=url, byte=True)
+
+ elif image:
+ url_, img_type = await self.verify_url(url=image.url, byte=True)
+
+ else:
+ raise errors.InvalidParameterError(content="An image file or url is required")
+
+ file = discord.File(io.BytesIO(url_), filename=f"banner.{img_type}") # type: ignore
+
+ embed = discord.Embed(color=0xC8A2C8)
+ embed.set_author(
+ name=f"{interaction.user.name} ({interaction.user.id})",
+ icon_url=interaction.user.display_avatar.url,
+ )
+ embed.set_image(url=f"attachment://banner.{img_type}")
+ embed.set_footer(text="banner")
+ await automated_channel.send(embed=embed, file=file, view=self.BANNER_VIEW)
+
+ await interaction.edit_original_response(content="Banner suggested.")
+
+ @banner_commands.command()
+ @checks.mod_and_above()
+ @app_commands.checks.cooldown(1, 30, key=lambda i: (i.guild_id, i.user.id))
+ async def change(
+ self,
+ interaction: discord.Interaction,
+ image: typing.Optional[discord.Attachment] = None,
+ url: typing.Optional[str] = None,
+ queue: typing.Optional[bool] = False,
+ ):
+ """
+ Change the current server banner.
+
+ Parameters
+ ----------
+ image: discord.Attachment
+ An image file
+ url: str
+ URL or Link of an image
+ queue: bool
+ Queue this banner next in rotation
+ """
+ url_: str | bytes
+ if url:
+ url_, img_type = await self.verify_url(url=url, byte=not queue)
+
+ elif image:
+ url_, img_type = await self.verify_url(url=image.url, byte=not queue)
+
+ else:
+ raise errors.InvalidParameterError(content="An image file or url is required")
+
+ if not queue:
+ assert isinstance(url_, bytes)
+ self.logger.info(f"Changed Banner to {url_}")
+ await self.bot.get_mainguild().edit(banner=url_)
+ await interaction.response.send_message("Server banner changed!", ephemeral=True)
+ else:
+ logger.info("Added banner to be queued next")
+ BannerCycle().queue_next(url_)
+ await interaction.response.send_message("Banner queued next", ephemeral=True)
+
+ @tasks.loop()
+ async def timed_banner_rotation(self):
+ """
+ Task that rotates the banners.
+ """
+ guild = self.bot.get_mainguild()
+ cur_banner_id = next(self.banner_cycle)
+ self.logger.info(f"{cur_banner_id}")
+ automated_channel = self.bot._get_channel(Reference.Channels.banners_and_topics)
+ try:
+ # check if banner is a message id (int) or url (str)
+ if type(cur_banner_id) is not str:
+ message = await automated_channel.fetch_message(cur_banner_id)
+ url = None
+ if message.embeds:
+ url = message.embeds[0].image.url
+ # this is necessary for legacy reasons
+ elif message.attachments:
+ url = message.attachments[0].url
+ if url is None:
+ raise commands.BadArgument()
+ else:
+ url = cur_banner_id
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url) as response:
+ banner = await response.content.read()
+ await guild.edit(banner=banner)
+ self.logger.info(f"Rotated Banner {url}")
+ except:
+ logger.exception("Failed rotating banner")
+
+
+async def setup(bot: BirdBot):
+ await bot.add_cog(Banner(bot))
diff --git a/app/cogs/color_select.py b/app/cogs/color_select.py
new file mode 100644
index 00000000..35679ace
--- /dev/null
+++ b/app/cogs/color_select.py
@@ -0,0 +1,166 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This cog defines ColorSelect, which is responsible for handling the removal and addition
+of exclusive colored roles. It also listens when a member no longer has the role that provides an
+exclusive color.
+"""
+import logging
+from typing import List, Literal
+
+import discord
+from discord import Interaction, app_commands
+from discord.app_commands.models import Choice
+from discord.ext import commands
+
+from app.birdbot import BirdBot
+from app.utils import errors
+from app.utils.config import ExclusiveColors, Reference
+
+logger = logging.getLogger(__name__)
+
+
+class ExclusiveColorTransformer(app_commands.Transformer):
+ """
+ A transformer that filters through member roles to determine what exclusive
+ colored roles can be added.
+ """
+
+ @staticmethod
+ def selectable_roles(member: discord.Member) -> List[discord.Role]:
+ """
+ Returns a list of selectable roles from the member's found roles.
+ """
+
+ # iterate through all of the exclusive colors
+ # if the member has an unlocker for an exclusive role, add it to the
+ # result list to be returned
+ result = []
+
+ for role in member.roles:
+
+ for _, value in ExclusiveColors.exclusive_colors.items():
+ if role.id in value["unlockers"]:
+ result.append(member.guild.get_role(value["id"]))
+
+ return result
+
+ async def transform(self, interaction: Interaction, value: str) -> discord.Role:
+ """
+ Transforms the string value into an exclusive colored role.
+ """
+
+ if not isinstance(interaction.user, discord.Member):
+ raise errors.InvalidInvocationError(content="This command must be ran in a server")
+
+ roles = ExclusiveColorTransformer.selectable_roles(interaction.user)
+
+ for role in roles:
+ if role.name == value:
+ return role
+
+ raise errors.InvalidParameterError(
+ content="The role to add could not be resolved or you do not have\
+ permission to apply it."
+ )
+
+ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]:
+ """
+ Returns a list of chocies (exclusive colored roles) for the member to pick from.
+
+ This method only returns roles that they have access to.
+ """
+
+ if not isinstance(interaction.user, discord.Member):
+ return []
+
+ roles = ExclusiveColorTransformer.selectable_roles(interaction.user)
+
+ return [Choice(name=r.name, value=r.name) for r in roles]
+
+
+class ColorSelect(commands.Cog):
+ """
+ Handles the removal of exclusive colored roles when a member no longer has the role that proves the color.
+
+ Allows users to add or remove a colored role based on their current roles.
+ """
+
+ def __init__(self, bot: BirdBot):
+ self.bot = bot
+
+ @commands.Cog.listener()
+ async def on_member_update(self, before: discord.Member, after: discord.Member):
+ """
+ Checks if the provided members roles are different.
+
+ If so, check if the exclusive colored roles applied, if any, are valid.
+ """
+
+ if before.roles == after.roles:
+ return
+
+ # check if selectable roles list is less than 'before'. if so then check
+ # for possible exclusive roles to remove.
+
+ roleids = [r.id for r in after.roles]
+
+ for name, value in ExclusiveColors.exclusive_colors.items():
+ has_unlocker_role = False
+
+ for roleid in roleids:
+ if roleid in value["unlockers"]:
+ has_unlocker_role = True
+ break
+
+ if value["id"] in roleids and not has_unlocker_role:
+ logger.info(
+ f"removing : {value['id']} from user who does not \
+ have access to it anymore"
+ )
+
+ await after.remove_roles(discord.Object(value["id"]), reason="auto remove color")
+
+ @app_commands.command(name="color", description="Provides exclusive name colors")
+ async def color(
+ self,
+ interaction: Interaction,
+ action: Literal["add", "remove"],
+ color: app_commands.Transform[discord.Role, ExclusiveColorTransformer],
+ ):
+ """
+ Allows the member to select a role to apply to themselves based on the colored configuration.
+
+ They can select to add or remove the role and only roles they have access to apply are provided in autocomplete.
+ """
+
+ if (
+ not isinstance(interaction.user, discord.Member)
+ or interaction.guild is None
+ or interaction.guild.id != Reference.guild
+ ):
+
+ raise errors.InvalidInvocationError(content="This command must be ran in the kurzgesagt guild")
+
+ if action == "add":
+ await interaction.user.add_roles(color, reason="color role update")
+ logger.debug("added role")
+ elif action == "remove":
+ await interaction.user.remove_roles(color, reason="color role update")
+ logger.debug("removed role")
+
+ await interaction.response.send_message(content=f"{action.title().strip('e')}ed {color.name}", ephemeral=True)
+
+
+async def setup(bot: BirdBot):
+ await bot.add_cog(ColorSelect(bot))
diff --git a/cogs/dev.py b/app/cogs/dev.py
similarity index 69%
rename from cogs/dev.py
rename to app/cogs/dev.py
index e44b3277..0517e70a 100644
--- a/cogs/dev.py
+++ b/app/cogs/dev.py
@@ -1,28 +1,49 @@
-import io
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This cog implements commands for bot developers
+Commands include:
+- `eval`: Evaluate a piece of python code
+- `activity`: Set bot activity status
+- `reload`: Reload a module
+- `kill`: Kill the bot
+- `restart`: Restart a sub process
+- `log`: View the bot's logs
+- `launch`: Spawn child process of alpha/beta bot instance on the VM
+- `sync_apps`: Sync slash commands
+- `clear_apps`: Clear slash commands
+"""
import asyncio
+import io
import logging
import math
import textwrap
import traceback
-import os
import typing
-
from contextlib import redirect_stdout
-from discord.ext.commands.errors import ExtensionNotFound
-
-from git import Repo, exc
-from git.cmd import Git
import discord
-from discord.ext import commands
from discord import app_commands
+from discord.ext import commands
+from discord.ext.commands.errors import ExtensionNotFound
-from utils import app_checks, helper
-from utils.helper import mod_and_above, devs_only, mainbot_only
+from app.birdbot import BirdBot
+from app.utils import checks, helper
+from app.utils.config import Reference
class Dev(commands.Cog):
- def __init__(self, bot):
+ def __init__(self, bot: BirdBot):
self.logger = logging.getLogger("Dev")
self.bot = bot
@@ -32,7 +53,7 @@ async def on_ready(self):
def cleanup_code(self, content: str):
"""
- Remove code-block from eval
+ Remove code-block from eval.
"""
if content.startswith("```") and content.endswith("```"):
return "\n".join(content.split("\n")[1:-1])
@@ -45,16 +66,17 @@ def get_syntax_error(self, e):
return f'```py\n{e.text}{"^":>{e.offset}}\n{e.__class__.__name__}: {e}```'
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def activity(
self,
interaction: discord.Interaction,
activity_type: typing.Literal["listening", "watching", "playing"],
message: str,
):
- """Set bot activity status
+ """
+ Set bot activity status.
Parameters
----------
@@ -70,16 +92,16 @@ async def activity(
"playing": discord.ActivityType.playing,
}
- await self.bot.change_presence(
- activity=discord.Activity(name=message, type=activities[activity_type])
- )
+ await self.bot.change_presence(activity=discord.Activity(name=message, type=activities[activity_type]))
await interaction.response.send_message("Activity changed.", ephemeral=True)
@commands.is_owner()
- @commands.command(pass_context=True, name="eval")
+ @commands.command()
async def eval(self, ctx: commands.Context, *, body: str):
- """Evaluates a code"""
+ """
+ Evaluates a code.
+ """
env = {
"bot": self.bot,
"ctx": ctx,
@@ -113,9 +135,9 @@ async def eval(self, ctx: commands.Context, *, body: str):
else:
value = stdout.getvalue()
try:
- await ctx.message.add_reaction("<:kgsYes:955703069516128307>")
+ await ctx.message.add_reaction(Reference.Emoji.PartialString.kgsYes)
except Exception as _:
- await ctx.message.add_reaction("<:kgsNo:955703108565098496>")
+ await ctx.message.add_reaction(Reference.Emoji.PartialString.kgsNo)
pass
if ret is None:
@@ -126,9 +148,7 @@ async def eval(self, ctx: commands.Context, *, body: str):
f"Returned over 2k chars, sending as file instead.\n"
f"(first 1.5k chars for quick reference)\n"
f"```py\n{value[0:1500]}\n```",
- file=discord.File(
- io.BytesIO(value.encode()), filename="output.txt"
- ),
+ file=discord.File(io.BytesIO(value.encode()), filename="output.txt"),
)
else:
await ctx.send(f"```py\n{value}\n```")
@@ -140,21 +160,21 @@ async def eval(self, ctx: commands.Context, *, body: str):
f"Returned over 2k chars, sending as file instead.\n"
f"(first 1.5k chars for quick reference)\n"
f'```py\n{f"{value}{ret}"[0:1500]}\n```',
- file=discord.File(
- io.BytesIO(f"{value}{ret}".encode()), filename="output.txt"
- ),
+ file=discord.File(io.BytesIO(f"{value}{ret}".encode()), filename="output.txt"),
)
else:
await ctx.send(f"```py\n{value}{ret}\n```")
- @devs_only()
+ @checks.devs_only()
@commands.command(name="reload", hidden=True)
async def reload(self, ctx: commands.Context, *, module_name: str):
- """Reload a module"""
+ """
+ Reload a module.
+ """
try:
try:
await self.bot.unload_extension(module_name)
- except discord.ext.commands.errors.ExtensionNotLoaded as enl:
+ except commands.errors.ExtensionNotLoaded as enl:
await ctx.send(f"Module not loaded. Trying to load it.", delete_after=6)
await self.bot.load_extension(module_name)
@@ -170,26 +190,33 @@ async def reload(self, ctx: commands.Context, *, module_name: str):
self.logger.error("{}: {}".format(type(e).__name__, e))
@commands.command(hidden=True)
- @mod_and_above()
+ @checks.mod_and_above()
async def kill(self, ctx: commands.Context):
- """Kill the bot"""
+ """
+ Kill the bot.
+ """
await ctx.send("Bravo 6 going dark.")
await self.bot.close()
@commands.command(hidden=True)
- @mod_and_above()
+ @checks.mod_and_above()
async def restart(self, ctx: commands.Context, instance: str):
- """restarts sub processes"""
+ """
+ Restarts a sub processes.
+ """
if instance not in ("songbirdalpha", "songbirdbeta", "twitterfeed", "youtubefeed"):
- raise commands.BadArgument("Instance argument must be songbirdalpha, songbirdbeta, twitterfeed, youtubefeed")
- process={
+ raise commands.BadArgument(
+ "Instance argument must be songbirdalpha, songbirdbeta, twitterfeed, youtubefeed"
+ )
+ process = {
"songbirdalpha": "songbirda.service",
"songbirdbeta": "songbirdb.service",
"twitterfeed": "twitter_feed.service",
- "youtubefeed": "youtube_feed.service"
+ "youtubefeed": "youtube_feed.service",
}
try:
- child = await asyncio.create_subprocess_shell("systemctl restart " + process[instance],
+ child = await asyncio.create_subprocess_shell(
+ "systemctl restart " + process[instance],
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@@ -199,7 +226,7 @@ async def restart(self, ctx: commands.Context, instance: str):
await ctx.send("could not restart process")
await ctx.send(e.__str__())
- @devs_only()
+ @checks.devs_only()
@commands.command(aliases=["logs"], hidden=True)
async def log(self, ctx: commands.Context, lines: int = 10):
"""View the bot's logs"""
@@ -220,9 +247,11 @@ async def log(self, ctx: commands.Context, lines: int = 10):
@commands.command()
@commands.is_owner()
- @mainbot_only()
+ @checks.mainbot_only()
async def launch(self, ctx: commands.Context, instance: str):
- """Spawn child process of alpha/beta bot instance on the VM, only works on main bot"""
+ """
+ Spawn child process of alpha/beta bot instance on the VM, only works on main bot.
+ """
if instance not in ("alpha", "beta"):
raise commands.BadArgument("Instance argument must be `alpha` or `beta`")
@@ -234,50 +263,39 @@ async def launch(self, ctx: commands.Context, instance: str):
stderr=asyncio.subprocess.STDOUT,
)
await ctx.send(
- f"Loaded {instance} instance. Don't forget to kill the instance once you're done to prevent memory leaks"
+ f"Loaded {instance} instance. Don't forget to kill the instance by running \
+ ```!eval\nawait self.bot.close()```once you're done to prevent memory leaks"
)
await child.wait()
finally:
try:
await ctx.send(f"{instance} instance has been terminated")
- await child.terminate()
+ await child.terminate() # type: ignore
except ProcessLookupError:
pass
- @devs_only()
- @commands.command(hidden=True)
- async def pull(self, ctx: commands.Context):
- self.logger.info("pulling repository")
- repo = Repo(os.getcwd()) # Get git repo object to check changes
- assert not repo.bare
- if repo.is_dirty():
- return await ctx.send(
- "There are untracked changes on the branch, please resolve them before pulling"
- )
-
- _g = Git(os.getcwd())
- _g.fetch()
- message = _g.pull("origin", "master")
- await ctx.send(f"```{message}```")
-
@app_commands.command()
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def send(
self,
interaction: discord.Interaction,
msg: str,
- channel: discord.TextChannel = None,
+ channel: typing.Optional[discord.TextChannel] = None,
):
+ """
+ Send a message in a channel.
+ """
+ assert isinstance(interaction.channel, discord.TextChannel)
+
if not channel:
channel = interaction.channel
await channel.send(msg)
await interaction.response.send_message("sent", ephemeral=True)
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=543884016282239006
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
+
embed = helper.create_embed(
author=interaction.user,
action="ran send command",
@@ -287,24 +305,28 @@ async def send(
await logging_channel.send(embed=embed)
@commands.command()
- @devs_only()
+ @checks.devs_only()
async def sync_apps(self, ctx: commands.Context):
-
+ """
+ Sync slash commands.
+ """
await ctx.bot.tree.sync()
- await ctx.bot.tree.sync(guild=discord.Object(414027124836532234))
+ await ctx.bot.tree.sync(guild=discord.Object(Reference.guild))
await ctx.reply("Synced local guild commands")
@commands.command()
- @devs_only()
+ @checks.devs_only()
async def clear_apps(self, ctx: commands.Context):
-
- ctx.bot.tree.clear_commands(guild=discord.Object(414027124836532234))
+ """
+ Clear slash commands.
+ """
+ ctx.bot.tree.clear_commands(guild=discord.Object(Reference.guild))
ctx.bot.tree.clear_commands(guild=None)
- await ctx.bot.tree.sync(guild=discord.Object(414027124836532234))
+ await ctx.bot.tree.sync(guild=discord.Object(Reference.guild))
await ctx.bot.tree.sync()
await ctx.send("cleared all commands")
-async def setup(bot):
+async def setup(bot: BirdBot):
await bot.add_cog(Dev(bot))
diff --git a/cogs/giveaway.py b/app/cogs/giveaway.py
similarity index 64%
rename from cogs/giveaway.py
rename to app/cogs/giveaway.py
index b9e1b974..ebe39c38 100644
--- a/cogs/giveaway.py
+++ b/app/cogs/giveaway.py
@@ -1,28 +1,38 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+Cog implementing the giveaway system for the server
+"""
+
import logging
-from datetime import datetime, timedelta, timezone
-import numpy as np
-import json
+import typing
+from datetime import timedelta, timezone
import discord
-from discord.ext import commands, tasks
+import numpy as np
from discord import app_commands
+from discord.ext import commands, tasks
-from utils import app_checks
-from utils.helper import (
- calc_time,
-)
-from utils.custom_converters import member_converter
-
-import typing
+from app.birdbot import BirdBot
+from app.utils import checks
+from app.utils.config import GiveawayBias, Reference
+from app.utils.helper import calc_time
class Giveaway(commands.Cog):
- def __init__(self, bot):
+ def __init__(self, bot: BirdBot):
self.logger = logging.getLogger("Giveaway")
self.bot = bot
- with open("config.json", "r") as config_file:
- config_json = json.loads(config_file.read())
- self.giveaway_bias = config_json["giveaway"]
self.active_giveaways = {}
self.giveaway_db = self.bot.db.Giveaways
@@ -30,10 +40,8 @@ def __init__(self, bot):
async def on_ready(self):
self.logger.info("loaded Giveaway")
- def cog_load(self) -> None:
- for giveaway in self.giveaway_db.find(
- {"giveaway_over": False, "giveaway_cancelled": False}
- ):
+ def cog_load(self):
+ for giveaway in self.giveaway_db.find({"giveaway_over": False, "giveaway_cancelled": False}):
giveaway["end_time"] = giveaway["end_time"].replace(tzinfo=timezone.utc)
self.active_giveaways[giveaway["message_id"]] = giveaway
@@ -46,58 +54,51 @@ def cog_unload(self):
name="giveaway",
description="Giveaway commands",
guild_only=True,
- guild_ids=[414027124836532234],
+ guild_ids=[Reference.guild],
default_permissions=discord.permissions.Permissions(manage_messages=True),
)
async def choose_winner(self, giveaway):
- """does the giveaway logic"""
- messagefound = False
+ """
+ Does the giveaway logic.
+
+ Chooses a winner, edits the giveaway embed and pings the winners.
+ """
+ message = False
try:
channel = await self.bot.fetch_channel(giveaway["channel_id"])
- message = await channel.fetch_message(giveaway["message_id"])
- messagefound = True
- except:
- messagefound = False
+ if isinstance(channel, discord.TextChannel):
+ message = await channel.fetch_message(giveaway["message_id"])
+ except discord.NotFound:
+ pass
- if messagefound:
+ if message:
if message.author != self.bot.user:
if giveaway["message_id"] in self.active_giveaways:
del self.active_giveaways[giveaway["message_id"]]
return
- embed = message.embeds[0].to_dict()
-
- embed["title"] = "Giveaway ended"
- embed["color"] = 15158332 # red
- embed["footer"]["text"] = "Giveaway Ended"
-
- users = []
+ members: typing.List[discord.Member] = []
self.logger.debug("Fetching reactions from users")
+ guild = message.guild
+ assert guild
for reaction in message.reactions:
if reaction.emoji == "🎉":
- userids = [
- user.id
- async for user in reaction.users()
- if user.id != self.bot.user.id
- ]
- users = []
+ userids = [user.id async for user in reaction.users() if not user.bot]
for userid in userids:
- try:
- member = await message.guild.fetch_member(userid)
- users.append(member)
- except discord.errors.NotFound:
- pass
+ member = guild.get_member(userid)
+ if member:
+ members.append(member)
self.logger.debug("Fetched users")
- if users != []:
+ if members != []:
self.logger.debug("Calculating weights")
weights = []
- for user in users:
- bias = self.giveaway_bias["default"]
- roles = [role.id for role in user.roles]
- for role in self.giveaway_bias["roles"]:
+ for member in members:
+ bias = GiveawayBias.default
+ roles = [role.id for role in member.roles]
+ for role in GiveawayBias.roles:
if role["id"] in roles:
bias = role["bias"]
break
@@ -111,18 +112,17 @@ async def choose_winner(self, giveaway):
prob = None
size = giveaway["winners_no"]
- if len(users) < size:
- size = len(users)
+ if len(members) < size:
+ size = len(members)
self.logger.debug("Choosing winner(s)")
+ users: list = members # why is type hinting
choice = np.random.choice(users, size=size, replace=False, p=prob)
winners = []
winnerids = ", ".join([str(i.id) for i in choice])
self.logger.debug(f"Fetched winner(s): {winnerids}")
for winner in choice:
- await message.reply(
- f"{winner.mention} won **{giveaway['prize']}**!"
- )
+ await message.reply(f"{winner.mention} won **{giveaway['prize']}**!")
winners.append(f"> {winner.mention}")
winners = "\n".join(winners)
@@ -131,24 +131,38 @@ async def choose_winner(self, giveaway):
winnerids = ""
self.logger.debug("Sending new embed")
- newdescription = embed["description"].splitlines()
- for i in range(len(newdescription)):
- if newdescription[i].startswith("> **Winners"):
- newdescription.insert(i + 1, winners)
- break
- embed["description"] = "\n".join(newdescription)
+ time = giveaway["end_time"]
+
+ embed = discord.Embed(
+ title="Giveaway ended",
+ timestamp=time, # type: ignore
+ colour=discord.Colour.red(),
+ )
+
+ embed.set_footer(text="Giveaway Ended")
+
+ riggedinfo = ""
+ if giveaway["rigged"]:
+ riggedinfo = (
+ "[rigged](https://discord.com/channels/414027124836532234/414452106129571842/714496884844134401) "
+ )
+
+ description = f"**{giveaway['prize']}**\nThe {riggedinfo}giveaway has ended\n"
+
+ description += f'> **Winners: {giveaway["winners_no"]}**\n'
+ description += winners + "\n"
+ description += f'> **{"Hosted" if giveaway["sponsor"] == giveaway["host"] else "Sponsored"} by**\n> <@{giveaway["sponsor"]}>'
+
+ embed.description = description
- embed = discord.Embed.from_dict(embed)
await message.edit(embed=embed)
else:
self.logger.debug("Message not found")
self.logger.debug("Deleting giveaway")
del self.active_giveaways[giveaway["message_id"]]
- self.giveaway_db.update_one(
- giveaway, {"$set": {"giveaway_cancelled": True}}
- )
+ self.giveaway_db.update_one(giveaway, {"$set": {"giveaway_cancelled": True}})
return
if giveaway["message_id"] in self.active_giveaways:
@@ -165,6 +179,11 @@ async def choose_winner(self, giveaway):
@tasks.loop()
async def giveaway_task(self):
+ """
+ Keep track of active giveaways.
+
+ Ends giveaways and queues up active ones.
+ """
templist = list(self.active_giveaways)
firstgiveaway = {}
@@ -190,7 +209,7 @@ async def giveaway_task(self):
self.giveaway_task.cancel()
@giveaway_commands.command()
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def start(
self,
interaction: discord.Interaction,
@@ -202,10 +221,11 @@ async def start(
1,
]
] = 1,
- sponsor: typing.Optional[discord.Member] = None,
+ sponsor: typing.Optional[discord.Member | discord.User] = None,
rigged: typing.Optional[bool] = True,
):
- """Starts a new giveaway
+ """
+ Starts a new giveaway.
Parameters
----------
@@ -223,14 +243,12 @@ async def start(
await interaction.response.defer(ephemeral=True)
- (time, _) = calc_time([time, ""])
+ (time, _) = calc_time([time, ""]) # type: ignore
if time == 0:
return await interaction.edit_original_response(content="Time can't be 0")
if time is None:
- return await interaction.edit_original_response(
- content="Invalid time syntax."
- )
+ return await interaction.edit_original_response(content="Invalid time syntax.")
if sponsor is None:
sponsor = interaction.user
@@ -240,18 +258,20 @@ async def start(
"sponsor": f"> **{'Hosted' if sponsor.id == interaction.user.id else 'Sponsored'} by**\n> {sponsor.mention}",
}
- time = discord.utils.utcnow() + timedelta(seconds=time)
+ time = discord.utils.utcnow() + timedelta(seconds=time) # type: ignore
embed = discord.Embed(
title="Giveaway started!",
- timestamp=time,
+ timestamp=time, # type: ignore
colour=discord.Colour.green(),
)
riggedinfo = ""
if rigged:
- riggedinfo = "[rigged](https://discord.com/channels/414027124836532234/414452106129571842/714496884844134401)"
+ riggedinfo = (
+ "[rigged](https://discord.com/channels/414027124836532234/414452106129571842/714496884844134401) "
+ )
- description = f"**{prize}**\nReact with 🎉 to join the {riggedinfo} giveaway\n"
+ description = f"**{prize}**\nReact with 🎉 to join the {riggedinfo}giveaway\n"
for field in fields:
description += "\n" + fields[field]
@@ -259,6 +279,8 @@ async def start(
embed.set_footer(text="Giveaway Ends")
+ assert isinstance(interaction.channel, discord.TextChannel)
+
message = await interaction.channel.send(embed=embed)
await message.add_reaction("🎉")
@@ -288,9 +310,10 @@ async def start(
await interaction.edit_original_response(content="Giveaway started.")
@giveaway_commands.command()
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def end(self, interaction: discord.Interaction, message_id: str):
- """Ends the giveaway preemptively
+ """
+ Ends the giveaway preemptively.
Parameters
----------
@@ -301,26 +324,23 @@ async def end(self, interaction: discord.Interaction, message_id: str):
await interaction.response.defer(ephemeral=True)
try:
- message_id = int(message_id)
+ message_id_ = int(message_id)
except ValueError as ve:
- return await interaction.edit_original_response(
- content="Invalid message id."
- )
+ return await interaction.edit_original_response(content="Invalid message id.")
- if message_id in self.active_giveaways:
- await self.choose_winner(self.active_giveaways[message_id])
+ if message_id_ in self.active_giveaways:
+ await self.choose_winner(self.active_giveaways[message_id_])
self.giveaway_task.restart()
- await interaction.edit_original_response(
- content="<:kgsYes:955703069516128307> Giveaway ended."
- )
+ await interaction.edit_original_response(content=f"{Reference.Emoji.PartialString.kgsYes} Giveaway ended.")
return
await interaction.edit_original_response(content="Giveaway not found!")
@giveaway_commands.command()
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def cancel(self, interaction: discord.Interaction, message_id: str):
- """Cancels the giveaway
+ """
+ Cancels a giveaway.
Parameters
----------
@@ -331,27 +351,23 @@ async def cancel(self, interaction: discord.Interaction, message_id: str):
await interaction.response.defer(ephemeral=True)
try:
- message_id = int(message_id)
+ message_id_ = int(message_id)
except ValueError as ve:
- return await interaction.edit_original_response(
- content="Invalid message id."
- )
+ return await interaction.edit_original_response(content="Invalid message id.")
- if message_id in self.active_giveaways:
- giveaway = self.active_giveaways[message_id]
+ if message_id_ in self.active_giveaways:
+ giveaway = self.active_giveaways[message_id_]
try:
- message = await interaction.guild.get_channel(
- giveaway["channel_id"]
- ).fetch_message(giveaway["message_id"])
+ message = await self.bot._get_channel(giveaway["channel_id"]).fetch_message( # type: ignore
+ giveaway["message_id"]
+ )
await message.delete()
- del self.active_giveaways[message_id]
+ del self.active_giveaways[message_id_]
self.giveaway_task.restart()
- self.giveaway_db.update_one(
- giveaway, {"$set": {"giveaway_cancelled": True}}
- )
+ self.giveaway_db.update_one(giveaway, {"$set": {"giveaway_cancelled": True}})
return await interaction.edit_original_response(
- content="<:kgsYes:955703069516128307> Giveaway cancelled!"
+ content=f"{Reference.Emoji.PartialString.kgsYes} Giveaway cancelled!"
)
except Exception as e:
raise e
@@ -359,7 +375,7 @@ async def cancel(self, interaction: discord.Interaction, message_id: str):
await interaction.edit_original_response(content="Giveaway not found!")
@giveaway_commands.command()
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def reroll(
self,
interaction: discord.Interaction,
@@ -372,7 +388,8 @@ async def reroll(
] = None,
rigged: typing.Optional[bool] = None,
):
- """Reroll the giveaway to select new winners.
+ """
+ Reroll the giveaway to select new winners.
Parameters
----------
@@ -387,14 +404,10 @@ async def reroll(
await interaction.response.defer(ephemeral=True)
try:
- message_id = int(message_id)
+ message_id_ = int(message_id)
except ValueError as ve:
- return await interaction.edit_original_response(
- content="Invalid message id."
- )
- doc = self.giveaway_db.find_one(
- {"giveaway_over": True, "message_id": message_id}
- )
+ return await interaction.edit_original_response(content="Invalid message id.")
+ doc = self.giveaway_db.find_one({"giveaway_over": True, "message_id": message_id_})
if doc:
if winner_count != None:
doc["winners_no"] = winner_count
@@ -402,16 +415,18 @@ async def reroll(
doc["rigged"] = rigged
await self.choose_winner(doc)
await interaction.edit_original_response(
- content="<:kgsYes:955703069516128307> Giveaway rerolled!"
+ content=f"{Reference.Emoji.PartialString.kgsYes} Giveaway rerolled!"
)
return
await interaction.edit_original_response(content="Giveaway not found!")
@giveaway_commands.command()
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def list(self, interaction: discord.Interaction):
- """List all active giveaways"""
+ """
+ List all active giveaways.
+ """
embed = discord.Embed(title="Active giveaways:")
for messageid in self.active_giveaways:
@@ -424,12 +439,10 @@ async def list(self, interaction: discord.Interaction):
)
except Exception as e:
self.logger.exception(e)
- return await interaction.response.send_message(
- "Error!!! Take a screenshot.", ephemeral=True
- )
+ return await interaction.response.send_message("Error!!! Take a screenshot.", ephemeral=True)
await interaction.response.send_message(embed=embed)
-async def setup(bot):
+async def setup(bot: BirdBot):
await bot.add_cog(Giveaway(bot))
diff --git a/cogs/help.py b/app/cogs/help.py
similarity index 73%
rename from cogs/help.py
rename to app/cogs/help.py
index fa4a55d7..0d9d289e 100644
--- a/cogs/help.py
+++ b/app/cogs/help.py
@@ -1,15 +1,32 @@
-import datetime
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This cog provides the help command and the ping command.
+"""
+
import logging
import discord
-from discord.ext import commands
from discord import app_commands
+from discord.ext import commands
-from utils import app_checks
+from app.birdbot import BirdBot
+from app.utils import checks
+from app.utils.config import Reference
class Help(commands.Cog):
- def __init__(self, bot):
+ def __init__(self, bot: BirdBot):
self.logger = logging.getLogger("Help")
self.bot = bot
self.bot.remove_command("help")
@@ -21,7 +38,7 @@ async def on_ready(self):
# TODO: Convert the output to embed or some UI
# TODO: Remove mod_and_above and default_permission check.
@app_commands.command()
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
@app_commands.default_permissions(manage_messages=True)
@app_commands.checks.cooldown(
1,
@@ -29,7 +46,7 @@ async def on_ready(self):
)
async def help(self, interaction: discord.Interaction):
"""
- Display help (Incomplete command)
+ Display help (Incomplete command).
"""
await interaction.response.defer(ephemeral=True)
@@ -39,6 +56,8 @@ async def help(self, interaction: discord.Interaction):
cmds = []
+ assert isinstance(interaction.user, discord.Member)
+ assert interaction.guild
for cmd in command_tree_global:
if isinstance(cmd, discord.app_commands.commands.Command):
cmds.append(cmd.name)
@@ -50,9 +69,7 @@ async def help(self, interaction: discord.Interaction):
for cmd in command_tree_guild:
if isinstance(cmd, discord.app_commands.commands.Command):
if cmd.default_permissions and cmd.default_permissions.manage_messages:
- if interaction.user.top_role >= interaction.guild.get_role(
- 414092550031278091
- ):
+ if interaction.user.top_role >= interaction.guild.get_role(Reference.Roles.moderator):
cmds.append(cmd.name)
else:
continue
@@ -63,9 +80,7 @@ async def help(self, interaction: discord.Interaction):
for c in cmd.commands:
if cmd.default_permissions:
if cmd.default_permissions.manage_messages:
- if interaction.user.top_role >= interaction.guild.get_role(
- 414092550031278091
- ):
+ if interaction.user.top_role >= interaction.guild.get_role(Reference.Roles.moderator):
cmds.append(f"{cmd.name} {c.name}")
else:
continue
@@ -74,9 +89,7 @@ async def help(self, interaction: discord.Interaction):
elif c.default_permissions:
if c.default_permissions.manage_messages:
- if interaction.user.top_role >= interaction.guild.get_role(
- 414092550031278091
- ):
+ if interaction.user.top_role >= interaction.guild.get_role(Reference.Roles.moderator):
cmds.append(f"{cmd.name} {c.name}")
else:
continue
@@ -93,10 +106,10 @@ async def help(self, interaction: discord.Interaction):
)
async def ping(self, interaction: discord.Interaction):
"""
- Ping Pong 🏓
+ Ping Pong 🏓.
"""
await interaction.response.send_message(f"{int(self.bot.latency * 1000)} ms")
-async def setup(bot):
+async def setup(bot: BirdBot):
await bot.add_cog(Help(bot))
diff --git a/app/cogs/listeners/error_events.py b/app/cogs/listeners/error_events.py
new file mode 100644
index 00000000..744e6ef2
--- /dev/null
+++ b/app/cogs/listeners/error_events.py
@@ -0,0 +1,100 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+import asyncio
+import io
+import logging
+from traceback import TracebackException
+
+import discord
+from discord.ext import commands
+from discord.ext.commands import errors
+
+from app.birdbot import BirdBot
+from app.utils.config import Reference
+from app.utils.errors import *
+from app.utils.helper import NoAuthorityError
+
+
+class Errors(commands.Cog):
+ """
+ Catches all exceptions coming in through commands.
+ """
+
+ def __init__(self, bot: BirdBot):
+ self.dev_logging_channel = Reference.Channels.Logging.dev
+
+ self.logger = logging.getLogger("Listeners")
+ self.bot = bot
+
+ @commands.Cog.listener()
+ async def on_ready(self):
+ self.logger.info("loaded Error listener")
+
+ @commands.Cog.listener()
+ async def on_command_error(self, ctx: commands.Context, err):
+ if isinstance(err, commands.CommandNotFound):
+ return
+
+ traceback_txt = "".join(TracebackException.from_exception(err).format())
+ channel = self.bot._get_channel(self.dev_logging_channel)
+
+ if isinstance(err, InternalError):
+ embed = err.format_notif_embed(ctx)
+
+ await ctx.send(embed=embed, delete_after=5)
+ await asyncio.sleep(5)
+ try:
+ await ctx.message.delete()
+ except discord.errors.NotFound:
+ pass
+
+ elif isinstance(
+ err,
+ (
+ errors.MissingPermissions,
+ NoAuthorityError,
+ errors.NotOwner,
+ errors.CheckAnyFailure,
+ errors.CheckFailure,
+ ),
+ ):
+ err = CheckFailure(content=str(err))
+
+ embed = err.format_notif_embed(ctx)
+
+ await ctx.send(embed=embed, delete_after=5)
+ await asyncio.sleep(5)
+ try:
+ await ctx.message.delete()
+ except discord.errors.NotFound:
+ pass
+
+ else:
+ self.logger.exception(traceback_txt)
+ await ctx.message.add_reaction(Reference.Emoji.PartialString.kgsStop)
+ if not self.bot.ismainbot():
+ return
+ await ctx.send(
+ "Uh oh, an unhandled exception occured, if this issue persists please contact any of bot devs (Sloth, FC, Austin, Orav)."
+ )
+ description = (
+ f"An [**unhandled exception**]({ctx.message.jump_url}) occured in <#{ctx.message.channel.id}> when "
+ f"running the **{ctx.command.name}** command.```\n{err}```" # type: ignore
+ )
+ embed = discord.Embed(title="Unhandled Exception", description=description, color=0xFF0000)
+ file = discord.File(io.BytesIO(traceback_txt.encode()), filename="traceback.txt")
+ await channel.send(embed=embed, file=file)
+
+
+async def setup(bot: BirdBot):
+ await bot.add_cog(Errors(bot))
diff --git a/app/cogs/listeners/member_events.py b/app/cogs/listeners/member_events.py
new file mode 100644
index 00000000..da3da7b4
--- /dev/null
+++ b/app/cogs/listeners/member_events.py
@@ -0,0 +1,159 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+import discord
+from discord.ext import commands
+
+from app.birdbot import BirdBot
+from app.utils.config import Reference
+
+
+class MemberEvents(commands.Cog):
+ def __init__(self, bot: BirdBot):
+ self.bot = bot
+
+ @commands.Cog.listener()
+ async def on_member_join(self, member: discord.Member):
+ if not self.bot.ismainbot():
+ return
+
+ await self.send_welcome(member)
+ await self.log_member_join(member)
+
+ @commands.Cog.listener()
+ async def on_member_remove(self, member: discord.Member):
+ if not self.bot.ismainbot():
+ return
+
+ await self.log_member_remove(member)
+
+ @commands.Cog.listener()
+ async def on_member_update(self, before: discord.Member, after: discord.Member):
+ """
+ Grant roles upon passing membership screening.
+ """
+
+ if not self.bot.ismainbot():
+ return
+
+ await self.check_member_screen(before, after)
+ await self.log_nickname_change(before, after)
+
+ async def send_welcome(self, member: discord.Member):
+ """
+ Send welcome message.
+ """
+ new_member_channel = self.bot._get_channel(Reference.Channels.new_members)
+ await new_member_channel.send(
+ content=f"Welcome hatchling {member.mention}!\n"
+ "Make sure to read the <#414268041787080708> and say hello to our <@&584461501109108738>s",
+ allowed_mentions=discord.AllowedMentions(users=True, roles=True),
+ )
+
+ async def log_member_join(self, member: discord.Member):
+ """
+ Logs member joins in the logging channel.
+ """
+ embed = discord.Embed(
+ title="Member joined",
+ description=f"{member.name}#{member.discriminator} ({member.id}) {member.mention}",
+ color=0x45E65A,
+ timestamp=discord.utils.utcnow(),
+ )
+ embed.set_author(name=member.name, icon_url=member.display_avatar.url)
+
+ embed.add_field(
+ name="Account Created",
+ value=f"",
+ inline=True,
+ )
+
+ embed.add_field(name="Search terms", value=f"```{member.id} joined```", inline=False)
+ embed.set_footer(text="Input the search terms in your discord search bar to easily sort through specific logs")
+
+ member_logging_channel = self.bot._get_channel(Reference.Channels.Logging.member_actions)
+ await member_logging_channel.send(embed=embed)
+
+ async def log_member_remove(self, member: discord.Member):
+ """
+ Logs member leaves in the logging channel.
+ """
+ embed = discord.Embed(
+ title="Member Left",
+ description=f"{member.name}#{member.discriminator} ({member.id})",
+ color=0xFF0004,
+ timestamp=discord.utils.utcnow(),
+ )
+ embed.set_author(name=member.name, icon_url=member.display_avatar.url)
+
+ embed.add_field(
+ name="Account Created",
+ value=f"",
+ inline=True,
+ )
+ embed.add_field(
+ name="Joined Server",
+ value=f"" if member.joined_at else "NONE",
+ inline=True,
+ )
+ embed.add_field(
+ name="Roles",
+ value=f"{' '.join([role.mention for role in member.roles])}",
+ inline=False,
+ )
+
+ embed.add_field(name="Search terms", value=f"```{member.id} left```", inline=False)
+ embed.set_footer(text="Input the search terms in your discord search bar to easily sort through specific logs")
+
+ member_logging_channel = self.bot._get_channel(Reference.Channels.Logging.member_actions)
+ await member_logging_channel.send(embed=embed)
+
+ async def check_member_screen(self, before: discord.Member, after: discord.Member):
+ if before.pending and (not after.pending):
+ guild = self.bot.get_mainguild()
+ english = guild.get_role(Reference.Roles.english)
+ assert english
+ await after.add_roles(
+ english,
+ reason="Membership screening passed",
+ )
+
+ async def log_nickname_change(self, before: discord.Member, after: discord.Member):
+ """
+ Logs member nickname change in the logging channel.
+ """
+ if before.nick == after.nick:
+ return
+
+ embed = discord.Embed(
+ title="Nickname changed",
+ description=f"{before.name}#{before.discriminator} ({before.id})",
+ color=0xFF6633,
+ timestamp=discord.utils.utcnow(),
+ )
+ embed.set_author(name=before.name, icon_url=before.display_avatar.url)
+ embed.add_field(name="Previous Nickname", value=f"{before.nick}", inline=True)
+ embed.add_field(name="Current Nickname", value=f"{after.nick}", inline=True)
+
+ embed.add_field(
+ name="Search terms",
+ value=f"```{before.id} changed nickname```",
+ inline=False,
+ )
+ embed.set_footer(text="Input the search terms in your discord search bar to easily sort through specific logs")
+
+ member_logging_channel = self.bot._get_channel(Reference.Channels.Logging.member_actions)
+ await member_logging_channel.send(embed=embed)
+
+
+async def setup(bot: BirdBot):
+ await bot.add_cog(MemberEvents(bot))
diff --git a/app/cogs/listeners/message_events.py b/app/cogs/listeners/message_events.py
new file mode 100644
index 00000000..27fc48a5
--- /dev/null
+++ b/app/cogs/listeners/message_events.py
@@ -0,0 +1,189 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+import discord
+import requests
+from discord.ext import commands
+from requests.models import PreparedRequest
+
+from app.birdbot import BirdBot
+from app.utils.config import Reference
+from app.utils.helper import is_internal_command
+
+
+class MessageEvents(commands.Cog):
+ def __init__(self, bot: BirdBot):
+ self.bot = bot
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message):
+ # Mainbot only, Kgs server only
+ # TextChannel and Thread only
+ if not self.bot.ismainbot() or message.guild != self.bot.get_mainguild():
+ return
+ if not isinstance(message.channel, discord.TextChannel | discord.Thread):
+ return
+ await self.translate_bannsystem(message)
+
+ @commands.Cog.listener()
+ async def on_message_edit(self, before: discord.Message, after: discord.Message):
+ # Mainbot only, Kgs server only, ignore bot edits
+ # TextChannel and Thread only
+ # Ignore moderation and log channels
+ if not self.bot.ismainbot() or before.guild != self.bot.get_mainguild() or before.author.bot:
+ return
+ if not isinstance(before.channel, discord.TextChannel | discord.Thread):
+ return
+ if before.channel.category and before.channel.category.id == (
+ Reference.Categories.moderation or Reference.Categories.server_logs
+ ):
+ return
+
+ await self.log_message_edit(before, after)
+
+ @commands.Cog.listener()
+ async def on_message_delete(self, message: discord.Message):
+ # Mainbot only, Kgs server only, ignore bot edits
+ # TextChannel and Thread only
+ # Ignore moderation and log channels
+ if not self.bot.ismainbot() or message.guild != self.bot.get_mainguild() or message.author.bot:
+ return
+ if not isinstance(message.channel, discord.TextChannel | discord.Thread):
+ return
+ if message.channel.category and message.channel.category.id == (
+ Reference.Categories.moderation or Reference.Categories.server_logs
+ ):
+ return
+
+ if is_internal_command(self.bot, message):
+ return
+
+ await self.log_message_delete(message)
+
+ async def log_message_delete(self, message: discord.Message):
+ """
+ Logs deleted message in the logging channel.
+ """
+ assert isinstance(message.channel, discord.TextChannel | discord.Thread)
+ assert message.guild
+
+ embed = discord.Embed(
+ title="Message Deleted",
+ description=f"Message deleted in {message.channel.mention}",
+ color=0xC9322C,
+ timestamp=discord.utils.utcnow(),
+ )
+ assert embed.description
+ embed.set_author(name=message.author.display_name, icon_url=message.author.display_avatar.url)
+ embed.add_field(name="Content", value=message.content)
+ search_terms = f"```Deleted in {message.channel.id}"
+
+ latest_logged_delete = [
+ log async for log in message.guild.audit_logs(limit=1, action=discord.AuditLogAction.message_delete)
+ ][0]
+
+ assert latest_logged_delete.user
+ self_deleted = False
+ if message.author == latest_logged_delete.target:
+ embed.description += f"\nDeleted by {latest_logged_delete.user.mention} {latest_logged_delete.user.name}"
+ search_terms += f"\nDeleted by {latest_logged_delete.user.id}"
+ else:
+ self_deleted = True
+ search_terms += f"\nDeleted by {message.author.id}"
+ embed.description += f"\nDeleted by {message.author.mention} {message.author.name}"
+
+ search_terms += f"\nSent by {message.author.id}"
+ search_terms += f"\nMessage from {message.author.id} deleted by {message.author.id if self_deleted else latest_logged_delete.user.id} in {message.channel.id}```"
+
+ embed.add_field(name="Search terms", value=search_terms, inline=False)
+ embed.set_footer(text="Input the search terms in your discord search bar to easily sort through specific logs")
+
+ message_logging_channel = self.bot._get_channel(Reference.Channels.Logging.message_actions)
+ await message_logging_channel.send(embed=embed)
+
+ async def log_message_edit(self, before: discord.Message, after: discord.Message):
+ """
+ Logs message edits outside of the moderator category.
+ """
+ assert isinstance(before.channel, discord.TextChannel | discord.Thread)
+
+ if before.content == after.content:
+ return
+
+ embed = discord.Embed(
+ title="Message Edited",
+ description=f"Message edited in {before.channel.mention}",
+ color=0xEE7600,
+ timestamp=discord.utils.utcnow(),
+ )
+ embed.set_author(name=before.author.display_name, icon_url=before.author.display_avatar.url)
+ embed.add_field(name="Before", value=before.content, inline=False)
+ embed.add_field(name="After", value=after.content, inline=False)
+ search_terms = f"""
+ ```Edited in {before.channel.id}\nEdited by {before.author.id}\nMessage edited in {before.channel.id} by {before.author.id}```
+ """
+
+ embed.add_field(name="Search terms", value=search_terms, inline=False)
+ embed.set_footer(text="Input the search terms in your discord search bar to easily sort through specific logs")
+
+ message_logging_channel = self.bot._get_channel(Reference.Channels.Logging.message_actions)
+ await message_logging_channel.send(embed=embed)
+
+ async def translate_bannsystem(self, message: discord.Message):
+ """
+ Translate incoming bannsystem reports.
+ """
+
+ if not (
+ message.channel.id == Reference.Channels.Logging.bannsystem and message.author.id == Reference.bannsystembot
+ ):
+ return
+
+ embed = message.embeds[0].to_dict()
+ assert "description" in embed and "fields" in embed
+ to_translate = sum(
+ [[embed["description"]], [field["value"] for field in embed["fields"]]], []
+ ) # flatten without numpy
+
+ embed["fields"][0]["name"] = "Reason"
+ embed["fields"][1]["name"] = "Proof"
+
+ url = "https://translate.birdbot.xyz/translate?"
+
+ # TODO add keys in the future
+ payload = {
+ "q": " ### ".join(to_translate),
+ "target": "en",
+ "source": "de",
+ "format": "text",
+ }
+
+ req = PreparedRequest()
+ req.prepare_url(url, payload)
+ assert req.url
+ response = requests.request("POST", req.url, verify=False).json()
+
+ replace_str = response["translatedText"].split(" ### ")
+ embed["description"] = replace_str[0]
+ embed["fields"][0]["value"] = replace_str[1]
+ embed["fields"][1]["value"] = replace_str[2]
+ to_send = discord.Embed.from_dict(embed)
+
+ translated_msg = await message.channel.send(embed=to_send)
+
+ await translated_msg.add_reaction(Reference.Emoji.PartialString.kgsYes)
+ await translated_msg.add_reaction(Reference.Emoji.PartialString.kgsNo)
+ await message.delete()
+
+
+async def setup(bot: BirdBot):
+ await bot.add_cog(MessageEvents(bot))
diff --git a/app/cogs/misc.py b/app/cogs/misc.py
new file mode 100644
index 00000000..7bdf0b75
--- /dev/null
+++ b/app/cogs/misc.py
@@ -0,0 +1,564 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+Miscellaneous commands and listeners that dont fit in with other cogs
+
+Included here is the staff intro commands and big_emote command
+"""
+import asyncio
+import logging
+import re
+import typing
+
+import demoji
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from app.birdbot import BirdBot
+from app.utils import checks
+from app.utils.config import Reference
+
+
+class Misc(commands.Cog):
+ def __init__(self, bot: BirdBot):
+ self.logger = logging.getLogger("Misc")
+ self.bot = bot
+ self.intro_db = self.bot.db.StaffIntros
+ self.kgs_guild: typing.Optional[discord.Guild] = None
+ self.role_precendence = (
+ 915629257470906369,
+ 414029841101225985,
+ 414092550031278091,
+ 1058243220817063936,
+ 681812574026727471,
+ )
+
+ @commands.Cog.listener()
+ async def on_ready(self):
+ self.logger.info("Loaded Misc Cog")
+
+ @commands.Cog.listener()
+ async def on_member_update(self, before: discord.Member, after: discord.Member):
+ if before.nick == after.nick:
+ return
+
+ self.kgs_guild = self.bot.get_mainguild()
+
+ subreddit_role = discord.utils.get(self.kgs_guild.roles, id=Reference.Roles.subreddit_mod)
+ if not after.top_role >= subreddit_role:
+ return
+
+ async with IntroLock.reorder_lock:
+ await self.edit_intro(after)
+
+ @commands.Cog.listener()
+ async def on_user_update(self, before: discord.User, after: discord.User):
+ self.kgs_guild = self.bot.get_mainguild()
+
+ member = self.kgs_guild.get_member(before.id)
+ if not member:
+ return
+ subreddit_role = discord.utils.get(self.kgs_guild.roles, id=Reference.Roles.subreddit_mod)
+ if not member.top_role >= subreddit_role:
+ return
+
+ async with IntroLock.reorder_lock:
+ await self.edit_intro(after)
+
+ async def edit_intro(self, member: discord.Member | discord.User):
+ """
+ Edits the intro with new name or avatar.
+ """
+ intro = self.intro_db.find_one({"_id": member.id})
+ if not intro:
+ return
+
+ intro_channel = self.bot._get_channel(Reference.Channels.intro_channel)
+
+ msg = await intro_channel.fetch_message(intro["message_id"])
+ embed = msg.embeds[0]
+ if embed.author.name != member.display_name or embed.author.icon_url != (
+ member.avatar.url if member.avatar else member.display_avatar.url
+ ):
+ embed.set_author(
+ name=member.display_name, icon_url=member.avatar.url if member.avatar else member.display_avatar.url
+ )
+ await msg.edit(embed=embed)
+
+ @app_commands.command()
+ @checks.role_and_above(Reference.Roles.subreddit_mod)
+ async def intro(self, interaction: discord.Interaction):
+ """
+ Staff intro command, create or edit an intro.
+ """
+ oldIntro = self.intro_db.find_one({"_id": interaction.user.id})
+ await interaction.response.send_modal(IntroModal(oldIntro=oldIntro, bot=self.bot)) # type: ignore
+
+ @app_commands.command()
+ @checks.admin_and_above()
+ async def intro_reorg(self, interaction: discord.Interaction):
+ """
+ Admin intro command, reorganizes all intros.
+
+ Delete demoted entries in mongo, purge the channel and send all up to date intro embeds.
+ """
+
+ await interaction.response.send_message("Will be done!", ephemeral=True)
+
+ def make_intro_embed(member: discord.Member, introDoc) -> discord.Embed:
+ description = f'**{introDoc["tz_text"]}**\n\n' + introDoc["bio"]
+ role = member.top_role
+ footer_name = "Kurzgesagt Official" if role.id == Reference.Roles.kgsmaintenance else role.name
+ if role.icon:
+ footer_icon = role.icon.url
+ else:
+ footer_icon = None
+
+ embed = discord.Embed(description=description, color=member.color)
+ embed.set_author(
+ name=member.display_name,
+ icon_url=member.avatar.url if member.avatar else member.display_avatar.url,
+ )
+ embed.set_footer(text=footer_name, icon_url=footer_icon)
+ embed.set_thumbnail(url=introDoc["image"])
+
+ return embed
+
+ kgs_guild = self.bot.get_mainguild()
+
+ lowest_role = kgs_guild.get_role(Reference.Roles.subreddit_mod)
+
+ embedList: typing.List[typing.Tuple[discord.Embed, discord.Member]] = []
+ for introDoc in self.intro_db.find():
+ member = kgs_guild.get_member(introDoc["_id"])
+
+ if not member or member.top_role < lowest_role:
+ self.intro_db.delete_one(introDoc)
+ continue
+
+ embed = make_intro_embed(member, introDoc)
+
+ embedList.append((embed, member))
+
+ def embed_sort(e: typing.Tuple[discord.Embed, discord.Member]) -> int:
+ return e[1].top_role.position
+
+ embedList.sort(key=embed_sort, reverse=True)
+
+ intro_channel = self.bot._get_channel(Reference.Channels.intro_channel)
+
+ def purge_check(msg: discord.Message) -> bool:
+ return bool(msg.author == self.bot.user and msg.embeds and msg.embeds[0].type == "rich")
+
+ # limit is currently 100 because getting the length of the collection is annoying
+ await intro_channel.purge(check=purge_check, limit=100)
+
+ for embed, member in embedList:
+ msg = await intro_channel.send(embed=embed)
+ self.intro_db.update_one({"_id": member.id}, {"$set": {"message_id": msg.id}})
+
+ @app_commands.command()
+ @app_commands.guilds(Reference.guild)
+ @app_commands.checks.cooldown(1, 10)
+ @checks.bot_commands_only()
+ async def big_emote(self, interaction: discord.Interaction, emoji: str):
+ """
+ Get image for server emote.
+
+ Parameters
+ ----------
+ emoji: str
+ Discord Emoji (only use in #bot-commands)
+ """
+ print(len(demoji.findall_list(emoji)))
+ if len(demoji.findall_list(emoji)) == 1:
+ code = (
+ str(emoji.encode("unicode-escape"))
+ .replace("U000", "-")
+ .replace("\\", "")
+ .replace("'", "")
+ .replace("u", "-")[2:]
+ )
+ print(code)
+ name = demoji.replace_with_desc(emoji).replace(" ", "-").replace(":", "").replace("_", "-")
+ await interaction.response.send_message(
+ "https://em-content.zobj.net/thumbs/160/twitter/322/" + name + "_" + code + ".png"
+ )
+ elif len(demoji.findall_list(emoji)) > 1:
+ await interaction.response.send_message("please only send one emoji")
+ else:
+ if re.match(r"", str(emoji)):
+ emoji = str(re.findall(r"", str(emoji))[0]) + ".gif"
+ await interaction.response.send_message("https://cdn.discordapp.com/emojis/" + str(emoji))
+ elif re.match(r"<:\w+:(\d{17,19})>", str(emoji)):
+ print("png")
+ emoji = str(re.findall(r"<:\w+:(\d{17,19})>", str(emoji))[0]) + ".png"
+ await interaction.response.send_message("https://cdn.discordapp.com/emojis/" + str(emoji))
+ else:
+ await interaction.response.send_message("Could not process this emoji")
+
+
+async def setup(bot: BirdBot):
+ await bot.add_cog(Misc(bot))
+
+
+class IntroModal(discord.ui.Modal):
+ """
+ The modal UI for intro commands.
+ """
+
+ def __init__(self, oldIntro: dict, bot: BirdBot):
+ super().__init__(title="Introduce yourself!")
+
+ self.logger = logging.getLogger("Misc")
+
+ self.oldIntro = oldIntro
+ self.intro_db = bot.db.StaffIntros
+
+ self.bot = bot
+
+ self.kgs_guild = bot.get_mainguild()
+
+ timezone_ph = bio_ph = image_ph = None
+ timezone_default = bio_default = image_default = None
+ if oldIntro:
+ timezone_default = oldIntro["tz_text"]
+ bio_default = oldIntro["bio"]
+ image_default = oldIntro["image"]
+ else:
+ timezone_ph = "The internet - UTC | GMT+0:00"
+ bio_ph = "Hello! I'm Birdbot and I help run this server."
+ image_ph = "https://cdn.discordapp.com/avatars/471705718957801483/cfcf7fbcdc9579d7f0606b014aa1ede8.png"
+
+ self.timezone = discord.ui.TextInput(
+ label="Enter your timezone.",
+ style=discord.TextStyle.short,
+ required=True,
+ placeholder=timezone_ph,
+ default=timezone_default,
+ max_length=90,
+ )
+
+ self.bio = discord.ui.TextInput(
+ label="Enter your bio.",
+ style=discord.TextStyle.paragraph,
+ required=True,
+ placeholder=bio_ph,
+ default=bio_default,
+ )
+
+ self.image = discord.ui.TextInput(
+ label="Enter the image link for your personal bird.",
+ style=discord.TextStyle.short,
+ required=True,
+ placeholder=image_ph,
+ default=image_default,
+ )
+
+ self.add_item(self.timezone)
+ self.add_item(self.bio)
+ self.add_item(self.image)
+
+ def get_footer(self, role: discord.Role) -> typing.Tuple[str, str | None]:
+ """
+ Get the role name and icon for the footer.
+ """
+ footer_name = "Kurzgesagt Official" if role.id == Reference.Roles.kgsmaintenance else role.name
+ if role.icon:
+ footer_icon = role.icon.url
+ else:
+ footer_icon = None
+ return footer_name, footer_icon
+
+ def create_embed(self) -> discord.Embed:
+ """
+ Make and return a new intro embed.
+ """
+ assert self.user
+ description = f"**{self.timezone_txt}**\n\n" + self.bio_txt
+
+ footer_name, footer_icon = self.get_footer(self.role)
+
+ embed = discord.Embed(description=description, color=self.user.color)
+ embed.set_author(
+ name=self.user.display_name,
+ icon_url=self.user.avatar.url if self.user.avatar else self.user.display_avatar.url,
+ )
+ embed.set_footer(text=footer_name, icon_url=footer_icon)
+ embed.set_thumbnail(url=self.image.value)
+
+ return embed
+
+ def add_emojis(self, text: str) -> str:
+ """
+ Add server emojis, because modals don't support them.
+ """
+ # make a simplified version of emojis
+ serverEmojis: dict = {}
+ for emoji in self.kgs_guild.emojis:
+ serverEmojis = {f":{emoji.name}:": emoji.id}
+
+ return re.sub(
+ r"(?)",
+ lambda emoji: (
+ f"<{emoji.group()}{serverEmojis[emoji.group()]}>" if emoji.group() in serverEmojis else emoji.group()
+ ),
+ text,
+ )
+
+ async def reorder_demotion(self, oldmessage: discord.Message):
+ """
+ Reorder the intros when user was demoted.
+ """
+ # make a list of messages that have to be edited (doc, msg)
+ # limit = self.intro_db.count_documents({})
+ assert self.user
+
+ limit = 100
+ embeds: typing.List[typing.Tuple[dict, discord.Message]] = []
+ newPos = 0
+ embeds.append((self.oldIntro, oldmessage))
+ snowflake = discord.Object(self.oldIntro["message_id"])
+ async for message in self.intro_channel.history(limit=limit, after=snowflake, oldest_first=True):
+ if not message.embeds:
+ continue
+ if not message.embeds[0].footer:
+ continue
+
+ doc = self.intro_db.find_one({"message_id": message.id})
+ if not doc:
+ continue
+
+ embeds.append((doc, message))
+
+ rolename = (
+ message.embeds[0].footer.text
+ if message.embeds[0].footer.text != "Kurzgesagt Official"
+ else "Kurzgesagt Maintenance"
+ )
+ msgrole = discord.utils.find(lambda role: role.name == rolename, self.kgs_guild.roles)
+
+ if self.role >= msgrole:
+ break
+
+ newPos += 1
+
+ self.logger.info(f"the new pos {newPos}")
+
+ for i in range(1, newPos + 1):
+ msg = embeds[i - 1][1]
+ await msg.edit(embed=embeds[i][1].embeds[0])
+
+ self.intro_db.update_one({"_id": embeds[i][0]["_id"]}, {"$set": {"message_id": msg.id}})
+
+ doc, msg = embeds[newPos]
+
+ embed = self.create_embed()
+ await msg.edit(embed=embed)
+ self.intro_db.update_one({"_id": self.user.id}, {"$set": {"message_id": msg.id}})
+
+ async def reorder_promotion(self, oldmessage: discord.Message):
+ """
+ Reorder intros when user was promoted.
+ """
+ # make a list of messages that have to be edited (doc, msg)
+ # limit = self.intro_db.count_documents({})
+ assert self.user
+
+ limit = 100
+ embeds: typing.List[typing.Tuple[dict, discord.Message]] = []
+ newPos = 0
+ embeds.append((self.oldIntro, oldmessage))
+ self.logger.info(f"Old message: {self.oldIntro['message_id']}")
+ snowflake = discord.Object(self.oldIntro["message_id"])
+ async for message in self.intro_channel.history(limit=limit, before=snowflake, oldest_first=False):
+ if not message.embeds:
+ continue
+ if not message.embeds[0].footer:
+ continue
+
+ doc = self.intro_db.find_one({"message_id": message.id})
+ if not doc:
+ continue
+
+ embeds.append((doc, message))
+
+ rolename = (
+ message.embeds[0].footer.text
+ if message.embeds[0].footer.text != "Kurzgesagt Official"
+ else "Kurzgesagt Maintenance"
+ )
+ msgrole = discord.utils.find(lambda role: role.name == rolename, self.kgs_guild.roles)
+
+ if self.role <= msgrole:
+ break
+
+ newPos += 1
+
+ for i in range(1, newPos + 1):
+ msg = embeds[i - 1][1]
+ await msg.edit(embed=embeds[i][1].embeds[0])
+
+ self.intro_db.update_one({"_id": embeds[i][0]["_id"]}, {"$set": {"message_id": msg.id}})
+
+ doc, msg = embeds[newPos]
+
+ embed = self.create_embed()
+ await msg.edit(embed=embed)
+ self.intro_db.update_one({"_id": self.user.id}, {"$set": {"message_id": msg.id}})
+
+ async def reorder_add(self):
+ """
+ Reorder intros when a new intro was added.
+ """
+ # make a list of messages that have to be edited (doc, msg)
+ # limit = self.intro_db.count_documents({})
+ assert self.user
+
+ limit = 100
+ embeds: typing.List[typing.Tuple[dict, discord.Message]] = []
+ newPos = 0
+ async for message in self.intro_channel.history(limit=limit, oldest_first=False):
+ if not message.embeds:
+ continue
+ if not message.embeds[0].footer:
+ continue
+
+ doc = self.intro_db.find_one({"message_id": message.id})
+ if not doc:
+ continue
+
+ rolename = (
+ message.embeds[0].footer.text
+ if message.embeds[0].footer.text != "Kurzgesagt Official"
+ else "Kurzgesagt Maintenance"
+ )
+ msgrole = discord.utils.find(lambda role: role.name == rolename, self.kgs_guild.roles)
+
+ embeds.append((doc, message))
+
+ if msgrole >= self.role:
+ break
+
+ newPos += 1
+
+ newembed = self.create_embed()
+
+ if newPos == 0:
+ msg = await self.intro_channel.send(embed=newembed)
+ self.intro_db.update_one({"_id": self.user.id}, {"$set": {"message_id": msg.id}})
+ return
+
+ for i in range(1, newPos):
+ msg = embeds[i - 1][1]
+ await msg.edit(embed=embeds[i][1].embeds[0])
+
+ self.intro_db.update_one({"_id": embeds[i][0]["_id"]}, {"$set": {"message_id": msg.id}})
+
+ doc, message = embeds[0]
+ msg = await self.intro_channel.send(embed=message.embeds[0])
+ self.intro_db.update_one({"_id": doc["_id"]}, {"$set": {"message_id": msg.id}})
+ doc, message = embeds[newPos - 1]
+ self.intro_db.update_one({"_id": self.user.id}, {"$set": {"message_id": message.id}})
+ await message.edit(embed=newembed)
+
+ async def on_submit(self, interaction: discord.Interaction):
+ """
+ Most of the intro command logic is here.
+ """
+ oldIntroMessage = None # if we're adding a new intro this will remain None
+
+ self.user = self.kgs_guild.get_member(interaction.user.id)
+ assert self.user
+ self.role = self.user.top_role
+ self.intro_channel = self.bot._get_channel(Reference.Channels.intro_channel)
+
+ self.timezone_txt = self.add_emojis(self.timezone.value)
+ self.bio_txt = self.add_emojis(self.bio.value)
+ self.image_txt = self.image.value
+
+ async def edit_intro(oldIntroMessage: discord.Message):
+ embed = oldIntroMessage.embeds[0]
+ await interaction.response.send_message("Your intro will be edited!", ephemeral=True)
+ # check if the user's top role has changed (promotion/demotion)
+ if (embed.footer.text == self.role.name) or (
+ embed.footer.text == "Kurzgesagt Official" and self.role.id == Reference.Roles.kgsofficial
+ ):
+ newembed = self.create_embed()
+ async with IntroLock.reorder_lock:
+ await oldIntroMessage.edit(embed=newembed)
+ else:
+ rolename = embed.footer.text
+ oldrole = discord.utils.find(lambda role: role.name == rolename, self.kgs_guild.roles)
+ if self.role > oldrole:
+ async with IntroLock.reorder_lock:
+ await self.reorder_promotion(oldIntroMessage)
+ elif self.role < oldrole:
+ async with IntroLock.reorder_lock:
+ await self.reorder_demotion(oldIntroMessage)
+
+ # update mongo
+ if self.oldIntro:
+ self.intro_db.update_one(
+ {"_id": self.user.id},
+ {
+ "$set": {
+ "tz_text": self.timezone_txt,
+ "bio": self.bio_txt,
+ "image": self.image.value,
+ }
+ },
+ )
+ else:
+ self.intro_db.insert_one(
+ {
+ "_id": self.user.id,
+ "tz_text": self.timezone_txt,
+ "bio": self.bio_txt,
+ "message_id": None, # we will edit it with message id after reordering
+ "image": self.image.value,
+ }
+ )
+
+ # check the validity of the image link, write a better regex?
+ if not re.match(r"^https*://.*\..*", self.image.value):
+ await interaction.response.send_message("Incorrect image link, please try again", ephemeral=True)
+ return
+
+ # check if the message was deleted for some reason
+ if self.oldIntro:
+ if not self.oldIntro["message_id"]:
+ await interaction.response.send_message(
+ "Something went wrong, can't find the intro message. Do the intros have to be reorganized?",
+ ephemeral=True,
+ )
+ return
+ try:
+ oldIntroMessage = await self.intro_channel.fetch_message(self.oldIntro["message_id"])
+ await edit_intro(oldIntroMessage)
+ return
+ except discord.NotFound:
+ await interaction.response.send_message(
+ "Something went wrong, can't find the intro message. Do the intros have to be reorganized?",
+ ephemeral=True,
+ )
+ return
+
+ await interaction.response.send_message("Your intro will be added!", ephemeral=True)
+ async with IntroLock.reorder_lock:
+ await self.reorder_add()
+
+
+class IntroLock:
+ reorder_lock = asyncio.Lock()
diff --git a/cogs/moderation.py b/app/cogs/moderation.py
similarity index 70%
rename from cogs/moderation.py
rename to app/cogs/moderation.py
index 152fa2a1..f83d7ade 100644
--- a/cogs/moderation.py
+++ b/app/cogs/moderation.py
@@ -1,32 +1,61 @@
-import json
-import io
-import typing
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This module contains the `Moderation` cog, which handles moderation-related commands and functionalities for the bot.
+
+Commands Defined:
+- `report`: Report issues to the moderation team, gives you an UI.
+- `clean` : Cleans/Purge messages from a channel.
+- `ban` : Ban or Force ban a user.
+- `unban` : Unban a user.
+- `kick` : Kick a user.
+- `selfmute` : Mute yourself.
+- `mute` : Mute a user.
+- `unmute` : Unmutes a user.
+- `role` : Add or Remove role to/from a user.
+- `warn` : Warns a user.
+- `delete_infr` : Allows for the deletion of an infraction.
+- `infraction` : Get infraction details of a user.
+- `detailed_inf` : Get detailed infraction details of a user.
+- `editinfr` : Edit an infraction.
+- `slowmode` : Set slowmode in a channel.
+- `nocmd` : blacklists a user from using commands.
+- `yescmd` : whitelists a user from using commands.
+"""
+
import datetime
+import io
import logging
-
-
-from utils import helper, app_checks, app_errors
-from utils.helper import (
- get_active_staff,
- blacklist_member,
- whitelist_member,
- is_public_channel,
-)
-from utils.infraction import InfractionList, InfractionKind
+from typing import Optional
import discord
-from discord.ext import commands
from discord import app_commands
+from discord.ext import commands
+
+from app.birdbot import BirdBot
+from app.utils import checks, errors, helper
+from app.utils.config import Reference
+from app.utils.helper import blacklist_member, get_active_staff, is_public_channel, whitelist_member
+from app.utils.infraction import InfractionKind, InfractionList
class FinalReconfirmation(discord.ui.View):
"""
- This view handles the interaction with moderators to confirm action while a
- user is on final warn. The moderator can choose to continue with the action
- or to cancel the action and follow through with a more appropriate action.
+ This view handles the interaction with moderators to confirm action while a user is on final warn.
- The moderator is also given the option to cancel while timing the user out
- for 10 minutes to provide decision making time.
+ The moderator can choose to continue with the action or to cancel the action and follow through with a more appropriate action.
+
+ The moderator is also given the option to cancel while timing the user out for 10 minutes to provide decision making time.
"""
@classmethod
@@ -38,17 +67,17 @@ async def handle(
moderator: discord.Member,
):
"""
- Manages the action of the reconfirmation and returns once the action is
- complete. Returns -1 if the action is to be canceled and 1 if the action
- is to proceed.
+ Manages the action of the reconfirmation and returns once the action is complete.
- # the state change must be made before the call to stop
+ Returns -1 if the action is to be canceled and 1 if the action is to proceed.
+ The state change must be made before the call to stop.
"""
reconfirmation = cls(user, moderator)
# BECAUSE OF THIS INTERACTION, COMMANDS THAT USE THIS VIEW MUST USE
# MAYBE RESPONDED LOGIC
+ assert isinstance(interaction.channel, discord.TextChannel)
await interaction.response.send_message(
f"{user.mention} is on final warning. Confirm action or cancel...",
ephemeral=is_public_channel(interaction.channel),
@@ -71,19 +100,15 @@ def __init__(self, user: discord.Member, moderator: discord.Member):
async def interaction_check(self, interaction: discord.Interaction):
"""
- Checks that the user interacting with this is the moderator that issued
- the warn.
+ Checks that the user interacting with this is the moderator that issued the warn.
"""
return interaction.user.id == self.moderator.id
@discord.ui.button(label="Continue", style=discord.ButtonStyle.danger)
- async def _continue(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
+ async def _continue(self, interaction: discord.Interaction, button: discord.ui.Button):
"""
- Removes the view and edits the message to inform that the action will
- continue
+ Removes the view and edits the message to inform that the action will continue.
"""
self.state = 1
@@ -96,12 +121,9 @@ async def _continue(
)
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey, row=2)
- async def _cancel(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
+ async def _cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
"""
- Removes the view to prevent more actions and edits the message to
- display the choice made
+ Removes the view to prevent more actions and edits the message to display the choice made.
"""
self.state = -1
@@ -114,11 +136,10 @@ async def _cancel(
)
@discord.ui.button(label="10m Timeout", style=discord.ButtonStyle.blurple)
- async def _timeout(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
+ async def _timeout(self, interaction: discord.Interaction, button: discord.ui.Button):
"""
Times out the user for 10 minutes to allow for further decision making.
+
This does not record an infraction but allows the moderator to take time
to think.
"""
@@ -126,9 +147,7 @@ async def _timeout(
self.state = -1
self.stop()
- await self.user.timeout(
- datetime.timedelta(minutes=10), reason="user timed out for decision making"
- )
+ await self.user.timeout(datetime.timedelta(minutes=10), reason="user timed out for decision making")
await interaction.response.edit_message(
content=f"{self.user.mention} was muted for 10 minutes to allow for decision making",
@@ -138,20 +157,16 @@ async def _timeout(
class Moderation(commands.Cog):
- def __init__(self, bot):
+ """
+ A cog that provides moderation-related commands and functionalities.
+ """
+
+ def __init__(self, bot: BirdBot):
self.logger = logging.getLogger("Moderation")
self.bot = bot
- config_file = open("config.json", "r")
- self.config_json = json.loads(config_file.read())
- config_file.close()
-
- self.logging_channel = self.config_json["logging"]["logging_channel"]
- self.message_logging_channel = self.config_json["logging"][
- "message_logging_channel"
- ]
- self.mod_role = self.config_json["roles"]["mod_role"]
- self.admin_role = self.config_json["roles"]["admin_role"]
+ self.mod_role = Reference.Roles.moderator
+ self.admin_role = Reference.Roles.administrator
@commands.Cog.listener()
async def on_ready(self):
@@ -162,21 +177,17 @@ async def on_ready(self):
async def report(
self,
interaction: discord.Interaction,
- member: typing.Optional[typing.Union[discord.Member, discord.User]] = None,
+ member: Optional[discord.Member | discord.User] = None,
):
- """Report issues to the moderation team, gives you an UI
+ """
+ Report issues to the moderation team, gives you an UI.
Parameters
----------
member: discord.Member
Mention or ID of member to report (is optional)
"""
-
- if interaction.guild:
- mod_channel = interaction.guild.get_channel(1092578562608988290)
- else:
- kgs_guild = self.bot.get_guild(414027124836532234)
- mod_channel = await kgs_guild.fetch_channel(1092578562608988290)
+ mod_channel = self.bot._get_channel(Reference.Channels.mod_chat)
class Modal(discord.ui.Modal):
def __init__(self, member):
@@ -195,20 +206,20 @@ def __init__(self, member):
)
async def on_submit(self, interaction: discord.Interaction):
- description = self.children[0].value
- message_link = self.children[1].value
+ description = self.children[0].value # type: ignore
+ message_link = self.children[1].value # type: ignore
channel = interaction.channel
if isinstance(channel, (discord.abc.GuildChannel, discord.Thread)):
channel_mention = channel.mention
-
+
# What this does is allow you to click the link to go to the
# messages around the time the report was made
- if message_link is None or message_link == '':
+ if message_link is None or message_link == "":
message_link = f"https://discord.com/channels/414027124836532234/{channel.id}/{interaction.id}"
else:
- channel_mention = 'Sent through DMs'
+ channel_mention = "Sent through DMs"
mod_embed = discord.Embed(
title="New Report",
@@ -226,29 +237,29 @@ async def on_submit(self, interaction: discord.Interaction):
)
await mod_channel.send(
- get_active_staff(interaction.client), embed=mod_embed,
- allowed_mentions=discord.AllowedMentions.all()
+ get_active_staff(interaction.client),
+ embed=mod_embed,
+ allowed_mentions=discord.AllowedMentions.all(),
)
- await interaction.response.send_message(
- "Report has been sent!", ephemeral=True
- )
+ await interaction.response.send_message("Report has been sent!", ephemeral=True)
await interaction.response.send_modal(Modal(member=member))
@app_commands.command()
- @app_commands.guilds(414027124836532234)
- @app_checks.mod_and_above()
+ @app_commands.guilds(Reference.guild)
+ @checks.mod_and_above()
@app_commands.default_permissions(manage_messages=True)
@app_commands.rename(_from="from")
async def clean(
self,
interaction: discord.Interaction,
count: app_commands.Range[int, 1, 200],
- _from: typing.Optional[discord.Member],
- channel: typing.Union[discord.TextChannel, discord.Thread, None] = None,
+ _from: Optional[discord.Member],
+ channel: discord.TextChannel | discord.Thread | None = None,
):
- """Cleans/Purge messages from a channel
+ """
+ Cleans/Purge messages from a channel.
Parameters
----------
@@ -260,6 +271,8 @@ async def clean(
Channel from which messages needs to be deleted (default: current channel)
"""
+ assert isinstance(interaction.channel, discord.TextChannel)
+ assert interaction.guild
if channel is None:
channel = interaction.channel
@@ -320,9 +333,7 @@ def check(message):
)
)
- message_logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.message_logging_channel
- )
+ message_logging_channel = self.bot._get_channel(Reference.Channels.Logging.message_actions)
await message_logging_channel.send(
f"{len(deleted_messages)} messages deleted in {channel.mention}",
@@ -333,9 +344,9 @@ def check(message):
)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def ban(
self,
interaction: discord.Interaction,
@@ -343,7 +354,8 @@ async def ban(
user: discord.User,
reason: str,
):
- """Ban or Force ban a user
+ """
+ Ban or Force ban a user.
Parameters
----------
@@ -355,17 +367,16 @@ async def ban(
Reason for the action
"""
+ assert interaction.guild
+ assert isinstance(interaction.user, discord.Member)
+ assert isinstance(interaction.channel, discord.TextChannel)
try:
# fetch_member throws not_found error if not found
member = await interaction.guild.fetch_member(user.id)
if member.top_role >= interaction.user.top_role:
- raise app_errors.InvalidAuthorizationError(
- "User could not be banned due to your clearance."
- )
+ raise errors.InvalidAuthorizationError(content="User could not be banned due to your clearance.")
- await member.send(
- f"You have been permanently removed from the server for following reason: \n{reason}"
- )
+ await member.send(f"You have been permanently removed from the server for following reason: \n{reason}")
except (discord.NotFound, discord.Forbidden):
pass
@@ -383,9 +394,7 @@ async def ban(
reason=reason,
)
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.logging_channel
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
embed = helper.create_embed(
author=interaction.user,
@@ -399,16 +408,17 @@ async def ban(
await logging_channel.send(embed=embed)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def unban(
self,
interaction: discord.Interaction,
user_id: discord.User,
- reason: typing.Optional[str] = None,
+ reason: Optional[str] = None,
):
- """Unban a user
+ """
+ Unban a user.
Parameters
----------
@@ -418,6 +428,8 @@ async def unban(
Reason for the action
"""
+ assert interaction.guild
+ assert isinstance(interaction.channel, discord.TextChannel)
try:
await interaction.guild.unban(user_id, reason=reason)
except discord.NotFound:
@@ -427,13 +439,9 @@ async def unban(
)
return
- await interaction.response.send_message(
- "user was unbanned", ephemeral=is_public_channel(interaction.channel)
- )
+ await interaction.response.send_message("user was unbanned", ephemeral=is_public_channel(interaction.channel))
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.logging_channel
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
embed = helper.create_embed(
author=interaction.user,
@@ -445,9 +453,9 @@ async def unban(
await logging_channel.send(embed=embed)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def kick(
self,
interaction: discord.Interaction,
@@ -455,7 +463,8 @@ async def kick(
member: discord.Member,
reason: str,
):
- """Kick a user
+ """
+ Kick a user.
Parameters
----------
@@ -467,17 +476,15 @@ async def kick(
Reason for the action
"""
+ assert interaction.guild
+ assert isinstance(interaction.user, discord.Member)
+ assert isinstance(interaction.channel, discord.TextChannel)
if member.top_role >= interaction.user.top_role:
-
- raise app_errors.InvalidAuthorizationError(
- "User could not be kicked due to your clearance."
- )
+ raise errors.InvalidAuthorizationError(content="User could not be kicked due to your clearance.")
infractions = InfractionList.from_user(member)
if infractions.on_final:
- result = await FinalReconfirmation.handle(
- interaction, infractions, member, interaction.user
- )
+ result = await FinalReconfirmation.handle(interaction, infractions, member, interaction.user)
if result < 0:
return
@@ -500,9 +507,7 @@ async def kick(
)
infractions.update()
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.logging_channel
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
embed = helper.create_embed(
author=interaction.user,
@@ -515,15 +520,16 @@ async def kick(
await logging_channel.send(embed=embed)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.checks.cooldown(1, 60)
async def selfmute(
self,
interaction: discord.Interaction,
time: str,
- reason: typing.Optional[str] = "Self Mute",
+ reason: Optional[str] = "Self Mute",
):
- """Mute yourself.
+ """
+ Mute yourself.
Parameters
----------
@@ -533,18 +539,17 @@ async def selfmute(
Reason for the action
"""
+ assert interaction.guild
+ assert isinstance(interaction.user, discord.Member)
+
tot_time, _ = helper.calc_time([time, ""])
if tot_time is None or tot_time <= 0:
- raise app_errors.InvalidInvocationError("Improper time provided")
+ raise errors.InvalidInvocationError(content="Improper time provided")
elif tot_time > 604801:
- raise app_errors.InvalidInvocationError(
- "Can't mute for longer than 7 days!"
- )
+ raise errors.InvalidInvocationError(content="Can't mute for longer than 7 days!")
elif tot_time < 300:
- raise app_errors.InvalidInvocationError(
- "Can't mute for shorter than 5 minutes!"
- )
+ raise errors.InvalidInvocationError(content="Can't mute for shorter than 5 minutes!")
duration = datetime.timedelta(seconds=tot_time)
finished = discord.utils.utcnow() + duration
@@ -562,9 +567,7 @@ async def selfmute(
ephemeral=True,
)
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=713107972737204236
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.misc_actions)
embed = helper.create_embed(
author=interaction.user,
@@ -579,9 +582,9 @@ async def selfmute(
await logging_channel.send(embed=embed)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def mute(
self,
interaction: discord.Interaction,
@@ -589,9 +592,10 @@ async def mute(
member: discord.Member,
time: str,
reason: str,
- final: typing.Optional[bool] = False,
+ final: Optional[bool] = False,
):
- """Mute a user
+ """
+ Mute a user.
Parameters
----------
@@ -603,29 +607,26 @@ async def mute(
Reason for the action
"""
- if member.top_role >= interaction.user.top_role:
+ assert interaction.guild
+ assert isinstance(interaction.user, discord.Member)
+ assert isinstance(interaction.channel, discord.TextChannel)
- raise app_errors.InvalidAuthorizationError(
- "user could not be muted due to your clearance"
- )
+ if member.top_role >= interaction.user.top_role:
+ raise errors.InvalidAuthorizationError(content="user could not be muted due to your clearance")
# time calculation
tot_time, _ = helper.calc_time([time, ""])
if tot_time is None:
- raise app_errors.InvalidInvocationError("no valid time provided")
+ raise errors.InvalidInvocationError(content="no valid time provided")
elif tot_time <= 0:
- raise app_errors.InvalidInvocationError("time can not be 0 or less")
+ raise errors.InvalidInvocationError(content="time can not be 0 or less")
elif tot_time > 2419200:
- raise app_errors.InvalidInvocationError(
- "time can not be longer than 28 days (2419200 seconds)"
- )
+ raise errors.InvalidInvocationError(content="time can not be longer than 28 days (2419200 seconds)")
infractions = InfractionList.from_user(member)
if infractions.on_final:
- result = await FinalReconfirmation.handle(
- interaction, infractions, member, interaction.user
- )
+ result = await FinalReconfirmation.handle(interaction, infractions, member, interaction.user)
if result < 0:
return
@@ -636,14 +637,16 @@ async def mute(
time_str = helper.get_time_string(tot_time)
default_msg = "(Note: Accumulation of warns may lead to permanent removal from the server)"
- final_msg = "**(This is your final warning, future infractions will lead to a non negotiable ban from the server)**"
+ final_msg = (
+ "**(This is your final warning, future infractions will lead to a non negotiable ban from the server)**"
+ )
if final:
default_msg = final_msg
+ else:
+ final = False
try:
- await member.send(
- f"You have been muted for {time_str}.\nGiven reason: {reason}\n{default_msg}"
- )
+ await member.send(f"You have been muted for {time_str}.\nGiven reason: {reason}\n{default_msg}")
except discord.Forbidden:
pass
@@ -667,9 +670,7 @@ async def mute(
)
infractions.update()
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.logging_channel
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
embed = helper.create_embed(
author=interaction.user,
@@ -683,16 +684,17 @@ async def mute(
await logging_channel.send(embed=embed)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def unmute(
self,
interaction: discord.Interaction,
member: discord.Member,
- reason: typing.Optional[str],
+ reason: Optional[str],
):
- """Unmutes a user
+ """
+ Unmutes a user.
Parameters
----------
@@ -702,9 +704,10 @@ async def unmute(
Reason for the action
"""
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.logging_channel
- )
+ assert interaction.guild
+ assert isinstance(interaction.channel, discord.TextChannel)
+
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
await member.timeout(None)
@@ -723,16 +726,17 @@ async def unmute(
)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def role(
self,
interaction: discord.Interaction,
member: discord.Member,
role: discord.Role,
):
- """Add or Remove role to/from a user
+ """
+ Add or Remove role to/from a user.
Parameters
----------
@@ -742,11 +746,12 @@ async def role(
Role to add or remove
"""
- if role >= interaction.user.top_role:
+ assert interaction.guild
+ assert isinstance(interaction.user, discord.Member)
+ assert isinstance(interaction.channel, discord.TextChannel)
- raise app_errors.InvalidAuthorizationError(
- "you do not have clearance to do that"
- )
+ if role >= interaction.user.top_role:
+ raise errors.InvalidAuthorizationError(content="You do not have clearance to do that")
# check if member has role
action, preposition = "", ""
@@ -767,9 +772,7 @@ async def role(
allowed_mentions=discord.AllowedMentions.none(),
)
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.logging_channel
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
embed = helper.create_embed(
author=interaction.user,
@@ -781,18 +784,19 @@ async def role(
await logging_channel.send(embed=embed)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def warn(
self,
interaction: discord.Interaction,
inf_level: app_commands.Range[int, 1, 5],
member: discord.Member,
reason: str,
- final: typing.Optional[bool] = False,
+ final: Optional[bool] = False,
):
- """Warns a user
+ """
+ Warns a user.
Parameters
----------
@@ -806,26 +810,29 @@ async def warn(
Mark warn as final
"""
- if member.top_role >= interaction.user.top_role:
+ assert interaction.guild
+ assert isinstance(interaction.user, discord.Member)
+ assert isinstance(interaction.channel, discord.TextChannel)
- raise app_errors.InvalidAuthorizationError(
- "user could not be warned due to your clearance"
- )
+ if member.top_role >= interaction.user.top_role:
+ raise errors.InvalidAuthorizationError(content="user could not be warned due to your clearance")
infractions = InfractionList.from_user(member)
if infractions.on_final:
- result = await FinalReconfirmation.handle(
- interaction, infractions, member, interaction.user
- )
+ result = await FinalReconfirmation.handle(interaction, infractions, member, interaction.user)
if result < 0:
return
# TODO make this more modular
default_msg = "(Note: Accumulation of warns may lead to permanent removal from the server)"
- final_msg = "**(This is your final warning, future infractions will lead to a non negotiable ban from the server)**"
+ final_msg = (
+ "**(This is your final warning, future infractions will lead to a non negotiable ban from the server)**"
+ )
if final:
default_msg = final_msg
+ else:
+ final = False
try:
await member.send(f"You have been warned for {reason} {default_msg}")
@@ -848,9 +855,7 @@ async def warn(
"user has been warned", ephemeral=is_public_channel(interaction.channel)
)
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.logging_channel
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
embed = helper.create_embed(
author=interaction.user,
@@ -863,9 +868,9 @@ async def warn(
await logging_channel.send(embed=embed)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def delete_infr(
self,
interaction: discord.Interaction,
@@ -873,15 +878,13 @@ async def delete_infr(
infr_type: InfractionKind,
):
"""
- Allows for the deletion of an infraction
- """
+ Allows for the deletion of an infraction.
- """
Since this is a lot of code, here is a breakdown...
IdxButton -> index button which is the button that records the input to
select an infraction. it should only be enabled when there is a valid
- infraction for it to represent (disabled value is handled by
+ infraction for it to represent (disabled value is handled by
DeleteInfractionView). Its only purpose is to call a method inside the
DeleteInfractionView passing the interaction and itself.
@@ -889,15 +892,15 @@ async def delete_infr(
instance is passed to the initialization along with the infraction kind
we are attempting to delete. The infractions of that kind are split into
chunks and each chunk is its own page of infractions show to the user of
- the command.
+ the command.
"""
class IdxButton(discord.ui.Button):
async def callback(self, interaction: discord.Interaction):
"""
- Calls select infraction on the DeleteInfractionView to proceed
- to the confirmation phase
+ Calls select infraction on the DeleteInfractionView to proceed to the confirmation phase.
"""
+ assert self.view
await self.view.select_infraction(interaction, self)
class DeleteInfractionView(discord.ui.View):
@@ -905,16 +908,14 @@ def __init__(self, user_infractions: InfractionList, kind: InfractionKind):
super().__init__(timeout=60)
"""
Splits the infractions into chunks of 5 or less and builds the
- buttons that represent the infractions posted on the page
+ buttons that represent the infractions posted on the page.
"""
self.user_infractions = user_infractions
# split the infractions into chunks 5 elements or less
infractions = user_infractions._kind_to_list(kind)
- self.chunks = [
- infractions[i : i + 5] for i in range(0, len(infractions), 5)
- ]
+ self.chunks = [infractions[i : i + 5] for i in range(0, len(infractions), 5)]
self.idx_buttons = []
self.current_chunk = 0
@@ -927,11 +928,9 @@ def __init__(self, user_infractions: InfractionList, kind: InfractionKind):
self.idx_buttons.append(button)
@discord.ui.button(label="<", style=discord.ButtonStyle.blurple, row=1)
- async def back(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
+ async def back(self, interaction: discord.Interaction, button: discord.ui.Button):
"""
- Goes backwards in the pagination. Supports cycling around
+ Goes backwards in the pagination. Supports cycling around.
"""
new_chunk = (self.current_chunk - 1) % len(self.chunks)
@@ -939,11 +938,9 @@ async def back(
await self.write_msg(interaction)
@discord.ui.button(label=">", style=discord.ButtonStyle.blurple, row=1)
- async def forward(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
+ async def forward(self, interaction: discord.Interaction, button: discord.ui.Button):
"""
- Goes forwards in the pagination. Supports cycling around
+ Goes forwards in the pagination. Supports cycling around.
"""
new_chunk = (self.current_chunk + 1) % len(self.chunks)
@@ -951,44 +948,42 @@ async def forward(
await self.write_msg(interaction)
@discord.ui.button(label="x", style=discord.ButtonStyle.red, row=1)
- async def exit(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
- await interaction.message.edit(
- content="Exited!!!", embed=None, view=None, delete_after=5
- )
+ async def exit(self, interaction: discord.Interaction, button: discord.ui.Button):
+ assert interaction.message
+ await interaction.message.edit(content="Exited!!!", embed=None, view=None, delete_after=5)
self.stop()
- @discord.ui.button(
- label="✓", style=discord.ButtonStyle.green, row=1, disabled=True
- )
- async def confirm(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
+ @discord.ui.button(label="✓", style=discord.ButtonStyle.green, row=1, disabled=True)
+ async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
if self.delete_infraction_idx == -1:
# This should not happen with correct logic but should
# provide insight on an issue if it does
- await interaction.response.send_message(
- "An infraction is not selected", ephemeral=True
- )
+ await interaction.response.send_message("An infraction is not selected", ephemeral=True)
return
- success = self.user_infractions.delete_infraction(
- infr_type, self.delete_infraction_idx
- )
+ # Log before deletion
+ logging_channel = await interaction.client.fetch_channel(Reference.Channels.Logging.mod_actions)
+ if isinstance(logging_channel, discord.abc.Messageable):
+ embed = helper.create_embed(
+ author=interaction.user,
+ action="Deleted Infraction",
+ users=[self.user_infractions._user],
+ extra=self.user_infractions.get_infraction_info_str(infr_type, self.delete_infraction_idx),
+ color=discord.Color.red(),
+ )
+
+ await logging_channel.send(embed=embed)
+
+ success = self.user_infractions.delete_infraction(infr_type, self.delete_infraction_idx)
if not success:
# This should not happen with correct logic but should
# provide insight on an issue if it does
- await interaction.response.send_message(
- "Infraction was not found", ephemeral=True
- )
+ await interaction.response.send_message("Infraction was not found", ephemeral=True)
return
self.user_infractions.update()
- # TODO, log this in mod-action-logs
-
await interaction.response.edit_message(
content="Infraction deleted successfully.", embed=None, view=None
)
@@ -996,7 +991,7 @@ async def confirm(
def build_embed(self) -> discord.Embed:
"""
- Builds the embed to display the current page of infractions
+ Builds the embed to display the current page of infractions.
"""
embed = discord.Embed(
@@ -1016,30 +1011,24 @@ def build_embed(self) -> discord.Embed:
return embed
- async def select_infraction(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
+ async def select_infraction(self, interaction: discord.Interaction, button: discord.ui.Button):
"""
- Selects the infraction that corresponds to the button passed
+ Selects the infraction that corresponds to the button passed.
This enables the phase of confirmation or declining the update.
All index buttons are disabled and the infraction is shown to
the user to confirm the choice.
"""
- self.delete_infraction_idx = int(button.label) + (
- 5 * self.current_chunk
- )
+ assert button.label
+ self.delete_infraction_idx = int(button.label) + (5 * self.current_chunk)
user_infractions = self.user_infractions
- infraction_info = user_infractions.get_infraction_info_str(
- infr_type, self.delete_infraction_idx
- )
+ infraction_info = user_infractions.get_infraction_info_str(infr_type, self.delete_infraction_idx)
embed = discord.Embed(
- title=f"Confirm deletion of {infr_type.name.lower()}:\n"
- + f"for {user.name} ({user.id})?",
+ title=f"Confirm deletion of {infr_type.name.lower()}:\n" + f"for {user.name} ({user.id})?",
color=discord.Color.magenta(),
timestamp=discord.utils.utcnow(),
description=infraction_info,
@@ -1053,21 +1042,21 @@ async def select_infraction(
async def write_msg(self, interaction: discord.Interaction):
"""
- Builds the embed, updates button activation and sends to discord
+ Builds the embed, updates button activation and edits the message.
"""
embed = self.build_embed()
# update disabled buttons
for button in self.idx_buttons:
- button.disabled = int(button.label) >= len(
- self.chunks[self.current_chunk]
- )
+ button.disabled = int(button.label) >= len(self.chunks[self.current_chunk])
await interaction.response.edit_message(embed=embed, view=self)
async def on_timeout(self):
- """Removes the view on timeout"""
+ """
+ Removes the view on timeout.
+ """
await interaction.edit_original_response(view=None)
@@ -1075,30 +1064,25 @@ async def interaction_check(self, new_interaction):
if new_interaction.user.id == interaction.user.id:
return True
else:
- await interaction.response.send_message(
- "You can't use that", ephemeral=True
- )
+ await interaction.response.send_message("You can't use that", ephemeral=True)
user_infractions = InfractionList.from_user(user)
if len(user_infractions._kind_to_list(infr_type)) == 0:
- await interaction.response.send_message(
- f"User has no {infr_type.name.lower()}s.", ephemeral=True
- )
+ await interaction.response.send_message(f"User has no {infr_type.name.lower()}s.", ephemeral=True)
return
div = DeleteInfractionView(user_infractions, kind=infr_type)
embed = div.build_embed()
- await interaction.response.send_message(
- embed=embed, view=div, ephemeral=is_public_channel(interaction.channel)
- )
+ assert isinstance(interaction.channel, discord.TextChannel)
+ await interaction.response.send_message(embed=embed, view=div, ephemeral=is_public_channel(interaction.channel))
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def infractions(self, interaction: discord.Interaction, user: discord.User):
"""Checks a users infractions.
@@ -1111,7 +1095,7 @@ async def infractions(self, interaction: discord.Interaction, user: discord.User
class InfButton(discord.ui.Button):
"""
- Represents a button to switch pages on an infraction embed view
+ Represents a button to switch pages on an infraction embed view.
"""
def __init__(
@@ -1127,21 +1111,18 @@ def __init__(
async def callback(self, interaction):
"""
- Switches the embed to display content for the corresponding
- infraction kind
+ Switches the embed to display content for the corresponding infraction kind.
"""
- infs_embed = self.user_infractions.get_infractions_of_kind(
- self.inf_type
- )
+ infs_embed = self.user_infractions.get_infractions_of_kind(self.inf_type)
await interaction.response.edit_message(embed=infs_embed)
class InfractionView(discord.ui.View):
"""
- Represents an infraction embed view. This hosts buttons of the
- different InfractionKinds to allow switching between the display of
- each infraction list.
+ Represents an infraction embed view.
+
+ This hosts buttons of the different InfractionKinds to allow switching between the display of each infraction list.
"""
def __init__(self, user_infractions: InfractionList):
@@ -1150,14 +1131,12 @@ def __init__(self, user_infractions: InfractionList):
self.user_infractions = user_infractions
for kind in InfractionKind:
- button = InfButton(
- user_infractions, inf_type=kind, label=kind.name.title() + "s"
- )
+ button = InfButton(user_infractions, inf_type=kind, label=kind.name.title() + "s")
self.add_item(button)
async def on_timeout(self):
"""
- Removes the view on timeout for visual aid
+ Removes the view on timeout for visual aid.
"""
await interaction.edit_original_response(view=None)
@@ -1165,6 +1144,7 @@ async def on_timeout(self):
user_infractions = InfractionList.from_user(user)
infs_embed = user_infractions.get_infractions_of_kind(InfractionKind.WARN)
+ assert isinstance(interaction.channel, discord.TextChannel)
await interaction.response.send_message(
embed=infs_embed,
view=InfractionView(user_infractions),
@@ -1172,9 +1152,9 @@ async def on_timeout(self):
)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def detailed_infr(
self,
interaction: discord.Interaction,
@@ -1182,7 +1162,8 @@ async def detailed_infr(
infr_type: InfractionKind,
infr_id: int,
):
- """Get detailed view of an infraction.
+ """
+ Get detailed view of an infraction.
Parameters
----------
@@ -1194,24 +1175,21 @@ async def detailed_infr(
ID as mentioned as last field of the infraction
"""
+ assert isinstance(interaction.channel, discord.TextChannel)
user_infractions = InfractionList.from_user(user)
embed = user_infractions.get_detailed_infraction(infr_type, infr_id)
if embed is None:
- await interaction.response.send_message(
- "Infraction with given id and type not found.", ephemeral=True
- )
+ await interaction.response.send_message("Infraction with given id and type not found.", ephemeral=True)
return
- await interaction.response.send_message(
- embed=embed, ephemeral=is_public_channel(interaction.channel)
- )
+ await interaction.response.send_message(embed=embed, ephemeral=is_public_channel(interaction.channel))
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def editinfr(
self,
interaction: discord.Interaction,
@@ -1221,7 +1199,8 @@ async def editinfr(
title: str,
description: str,
):
- """Add extra fields to an infractions
+ """
+ Add extra fields to an infractions.
Parameters
----------
@@ -1237,15 +1216,14 @@ async def editinfr(
Description for the field
"""
+ assert interaction.guild
+ assert isinstance(interaction.channel, discord.TextChannel)
+
user_infractions = InfractionList.from_user(user)
- success = user_infractions.detail_infraction(
- infr_type, infr_id, title, description
- )
+ success = user_infractions.detail_infraction(infr_type, infr_id, title, description)
if not success:
- await interaction.response.send_message(
- "Infraction with given id and type not found.", ephemeral=True
- )
+ await interaction.response.send_message("Infraction with given id and type not found.", ephemeral=True)
return
user_infractions.update()
@@ -1265,23 +1243,22 @@ async def editinfr(
color=discord.Color.red(),
)
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.logging_channel
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
await logging_channel.send(embed=embed)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def slowmode(
self,
interaction: discord.Interaction,
duration: app_commands.Range[int, 0, 360],
- channel: typing.Union[discord.TextChannel, discord.Thread, None],
- reason: typing.Optional[str],
+ channel: discord.TextChannel | discord.Thread | None,
+ reason: Optional[str],
):
- """Add or remove slowmode in a channel
+ """
+ Add or remove slowmode in a channel.
Parameters
-----------
@@ -1293,6 +1270,9 @@ async def slowmode(
Reason for the action
"""
+ assert interaction.guild
+ assert isinstance(interaction.channel, discord.TextChannel)
+
if channel is None:
channel = interaction.channel
@@ -1303,9 +1283,7 @@ async def slowmode(
ephemeral=True,
)
- logging_channel = discord.utils.get(
- interaction.guild.channels, id=self.logging_channel
- )
+ logging_channel = self.bot._get_channel(Reference.Channels.Logging.mod_actions)
embed = helper.create_embed(
author=interaction.user,
@@ -1318,9 +1296,9 @@ async def slowmode(
await logging_channel.send(embed=embed)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def nocmd(
self,
interaction: discord.Interaction,
@@ -1328,7 +1306,7 @@ async def nocmd(
command_name: str,
):
"""
- Blacklists a member from using a command
+ Blacklists a member from using a command.
Parameters
----------
@@ -1340,14 +1318,13 @@ async def nocmd(
command = discord.utils.get(self.bot.commands, name=command_name)
if command is None:
- return await interaction.response.send_message(
- f"{command_name} is not a valid command", ephemeral=True
- )
+ return await interaction.response.send_message(f"{command_name} is not a valid command", ephemeral=True)
+
+ assert isinstance(interaction.user, discord.Member)
+
if interaction.user.top_role > member.top_role:
blacklist_member(self.bot, member, command)
- await interaction.response.send_message(
- f"{member.name} can no longer use {command_name}", ephemeral=True
- )
+ await interaction.response.send_message(f"{member.name} can no longer use {command_name}", ephemeral=True)
else:
await interaction.response.send_message(
f"You cannot blacklist someone higher or equal to you smh.",
@@ -1355,9 +1332,9 @@ async def nocmd(
)
@app_commands.command()
- @app_commands.guilds(414027124836532234)
+ @app_commands.guilds(Reference.guild)
@app_commands.default_permissions(manage_messages=True)
- @app_checks.mod_and_above()
+ @checks.mod_and_above()
async def yescmd(
self,
interaction: discord.Interaction,
@@ -1365,7 +1342,7 @@ async def yescmd(
command_name: str,
):
"""
- Whitelist a member from using a command
+ Whitelist a member from using a command.
Parameters
----------
@@ -1376,18 +1353,14 @@ async def yescmd(
"""
command = discord.utils.get(self.bot.commands, name=command_name)
if command is None:
- return await interaction.response.send_message(
- f"{command_name} is not a valid command", ephemeral=True
- )
+ return await interaction.response.send_message(f"{command_name} is not a valid command", ephemeral=True)
if whitelist_member(member, command):
- await interaction.response.send_message(
- f"{member.name} can now use {command.name}", ephemeral=True
- )
+ await interaction.response.send_message(f"{member.name} can now use {command.name}", ephemeral=True)
else:
await interaction.response.send_message(
f"{member.name} is not blacklisted from {command.name}", ephemeral=True
)
-async def setup(bot):
+async def setup(bot: BirdBot):
await bot.add_cog(Moderation(bot))
diff --git a/app/cogs/patreon.py b/app/cogs/patreon.py
new file mode 100644
index 00000000..10d809da
--- /dev/null
+++ b/app/cogs/patreon.py
@@ -0,0 +1,127 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+To be removed
+"""
+import asyncio
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+import app.utils.errors as errors
+from app.birdbot import BirdBot
+from app.utils import checks
+from app.utils.config import Reference
+from app.utils.infraction import InfractionList
+
+
+class Patreon(commands.Cog):
+ def __init__(self, bot: BirdBot):
+ self.bot = bot
+
+ @commands.Cog.listener()
+ async def on_member_join(self, member: discord.Member):
+ """
+ Listen for new patrons and provide them the option to unenroll from autojoining.
+ Listen for new members and fire webhook for greeting.
+ """
+
+ diff_roles = [role.id for role in member.roles]
+ if any(x in diff_roles for x in Reference.Roles.patreon()):
+ guild = self.bot.get_mainguild()
+ english = guild.get_role(Reference.Roles.english)
+ assert english
+ await member.add_roles(
+ english,
+ reason="Patron auto join",
+ )
+
+ try:
+ embed = discord.Embed(
+ title="Hey there patron! Annoyed about auto-joining the server?",
+ description="Unfortunately Patreon doesn't natively support a way to disable this- "
+ "but you have the choice of getting volutarily banned from the server "
+ "therby preventing your account from rejoining. To do so simply type ```!unenrol```"
+ "If you change your mind in the future just fill out [this form!](https://forms.gle/m4KPj2Szk1FKGE6F8)",
+ color=0xFFFFFF,
+ )
+ embed.set_thumbnail(url="https://cdn.discordapp.com/emojis/824253681443536896.png?size=256")
+
+ await member.send(embed=embed)
+ except discord.Forbidden:
+ return
+
+ @app_commands.command()
+ @checks.patreon_only()
+ @app_commands.checks.cooldown(1, 300, key=lambda i: (i.user.id))
+ async def unenrol(self, interaction: discord.Interaction):
+ """
+ Unenrol from Patron auto join.
+ """
+
+ embed = discord.Embed(
+ title="We're sorry to see you go",
+ description="Are you sure you want to get banned from the server?"
+ "If you change your mind in the future you can simply fill out [this form.](https://forms.gle/m4KPj2Szk1FKGE6F8)",
+ color=0xFFCB00,
+ )
+ embed.set_thumbnail(url="https://cdn.discordapp.com/emojis/736621027093774467.png?size=96")
+
+ def check(reaction, user):
+ return user == interaction.user
+
+ fallback_embed = discord.Embed(
+ title="Action Cancelled",
+ description="Phew, That was close.",
+ color=0x00FFA9,
+ )
+ confirm_msg: discord.Message | None = None
+ try:
+ confirm_msg = await interaction.user.send(embed=embed)
+ await interaction.response.send_message("Please check your DMs.")
+ await confirm_msg.add_reaction(Reference.Emoji.PartialString.kgsYes)
+ await confirm_msg.add_reaction(Reference.Emoji.PartialString.kgsNo)
+ reaction, _ = await self.bot.wait_for("reaction_add", timeout=120, check=check)
+
+ if isinstance(reaction.emoji, str):
+ await confirm_msg.edit(embed=fallback_embed)
+ return
+ if reaction.emoji.id == Reference.Emoji.kgsYes:
+ member = self.bot.get_mainguild().get_member(interaction.user.id)
+ if member == None:
+ raise errors.InvalidFunctionUsage()
+ user_infractions = InfractionList.from_user(member)
+ user_infractions.banned_patreon = True
+ user_infractions.update()
+
+ await interaction.user.send("Success! You've been banned from the server.")
+ await member.ban(reason="Patron Voluntary Removal")
+ return
+ if reaction.emoji.id == Reference.Emoji.kgsNo:
+ await confirm_msg.edit(embed=fallback_embed)
+ return
+
+ except discord.Forbidden:
+ await interaction.response.send_message(
+ "I can't seem to DM you. please check your privacy settings and try again",
+ ephemeral=True,
+ )
+
+ except asyncio.TimeoutError:
+ if confirm_msg != None:
+ await confirm_msg.edit(embed=fallback_embed)
+
+
+async def setup(bot: BirdBot):
+ await bot.add_cog(Patreon(bot))
diff --git a/app/cogs/smfeed.py b/app/cogs/smfeed.py
new file mode 100644
index 00000000..11bbbd31
--- /dev/null
+++ b/app/cogs/smfeed.py
@@ -0,0 +1,73 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+Social Media Feed Approval queue implementation.
+Social media posts that are sent to the queue are approved by mods and above and sent to the feed channel.
+"""
+import logging
+
+import discord
+from discord.ext import commands
+
+from app.birdbot import BirdBot
+from app.utils.config import Reference
+
+
+class Smfeed(commands.Cog):
+ def __init__(self, bot: BirdBot):
+ self.logger = logging.getLogger("Smfeed")
+ self.bot = bot
+
+ @commands.Cog.listener()
+ async def on_ready(self):
+ self.logger.info("loaded Smfeed")
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message):
+ """React to the twitter webhooks"""
+
+ if not self.bot.ismainbot():
+ return
+ if message.channel.id == Reference.Channels.social_media_queue:
+ await message.add_reaction(Reference.Emoji.PartialString.kgsYes)
+
+ @commands.Cog.listener()
+ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
+ """If mod or above reacts to twitter webhook tweet, sends it to proper channel"""
+ if payload.member == None:
+ return
+
+ if (
+ payload.channel_id != Reference.Channels.social_media_queue
+ or payload.member.bot
+ or payload.emoji.id != Reference.Emoji.kgsYes
+ ):
+ return
+
+ guild = self.bot.get_mainguild()
+ trainee_mod_role = guild.get_role(Reference.Roles.trainee_mod)
+ if payload.member.top_role < trainee_mod_role:
+ return
+ smq_channel = self.bot._get_channel(Reference.Channels.social_media_queue)
+ smf_channel = self.bot._get_channel(Reference.Channels.social_media_feed)
+ message = await smq_channel.fetch_message(payload.message_id)
+ for reaction in message.reactions:
+ if isinstance(reaction.emoji, str):
+ continue
+ if reaction.emoji.id == Reference.Emoji.kgsYes and reaction.count <= 2:
+ await smf_channel.send(message.content)
+ break
+
+
+async def setup(bot: BirdBot):
+ await bot.add_cog(Smfeed(bot))
diff --git a/app/cogs/topic.py b/app/cogs/topic.py
new file mode 100644
index 00000000..5192cfe5
--- /dev/null
+++ b/app/cogs/topic.py
@@ -0,0 +1,445 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This module contains the `Topic` cog, which provides commands for managing and interacting with topics.
+
+Commands Defined:
+- `topic`: Fetches a random topic.
+- `search`: Search a topic.
+- `add`: Moderators can add a topic directly.
+- `remove`: Moderators can remove a topic.
+"""
+
+import asyncio
+import logging
+import re
+import typing
+from typing import TYPE_CHECKING
+
+import discord
+from discord import Interaction, app_commands
+from discord import ui as dui
+from discord.ext import commands
+from fuzzywuzzy import process
+from pymongo.errors import CollectionInvalid
+
+from app.birdbot import BirdBot
+from app.utils import checks, errors
+from app.utils.config import Reference
+from app.utils.helper import TopicCycle
+
+if TYPE_CHECKING:
+ from pymongo.collection import Collection
+
+
+class TopicEditorModal(dui.Modal):
+ """
+ A modal sent to the user attempting to change the topic.
+ """
+
+ topic = dui.TextInput(
+ label="Topic", placeholder="Edited topic goes here", style=discord.TextStyle.long, max_length=2000
+ )
+
+ def __init__(self, topic: str):
+ super().__init__(title="Topic Editor", timeout=60 * 2)
+ self.topic.default = topic
+
+ async def on_submit(self, interaction: Interaction) -> None:
+ """
+ Edits the message if the input value is different than the default.
+ """
+ if self.topic.value == self.topic.default:
+ await interaction.response.defer(thinking=False)
+ return
+
+ message = interaction.message
+ assert message
+ embed = message.embeds[0]
+
+ embed.description = self.topic.value
+
+ embed.add_field(name="Initial Submission", value=self.topic.default)
+
+ await interaction.response.edit_message(embed=embed)
+
+
+class TopicAcceptorView(dui.View):
+ """
+ A class that is meant to be instantiated once and used on all topic suggestions.
+ """
+
+ def __init__(self, accept_id: str, deny_id: str, edit_id: str, topics: list, topics_db):
+ super().__init__(timeout=None)
+
+ self._accept.custom_id = accept_id
+ self._deny.custom_id = deny_id
+ self._edit.custom_id = edit_id
+
+ self.topics = topics
+ self.topics_db: Collection = topics_db
+ self.editing = {}
+
+ async def interaction_check(self, interaction: Interaction) -> bool:
+ """
+ Checks if another user is currently editing this topic.
+ """
+ assert interaction.message
+ editing = self.editing.get(interaction.message.id, 0)
+ if editing != 0:
+ who = f"<@{editing}> is currently editing this topic"
+ avoid = (
+ ""
+ if editing != interaction.user.id
+ else "\nThis interaction will clear out in at least two minutes."
+ + " To avoid this issue, do not cancel out of the popup"
+ )
+
+ raise errors.InvalidAuthorizationError(content=f"{who}{avoid}")
+ return True
+
+ async def on_error(self, interaction: Interaction, error: Exception, item: dui.Item):
+ """
+ Raises the error to the command tree.
+ """
+ await interaction.client.tree.on_error(interaction, error) # type: ignore
+
+ @dui.button(
+ label="Accept",
+ style=discord.ButtonStyle.green,
+ emoji=discord.PartialEmoji.from_str(Reference.Emoji.PartialString.kgsYes),
+ )
+ async def _accept(self, interaction: Interaction, button: dui.Button):
+ """
+ Accepts the topic and removes the view from the message.
+
+ Changes the embed to indicate it was accepted and by who.
+ """
+ message = interaction.message
+ assert message
+ embed = message.embeds[0]
+
+ topic = embed.description
+ self.topics.append(topic)
+ self.topics_db.update_one({"name": "topics"}, {"$set": {"topics": self.topics}})
+
+ TopicCycle().queue_last(topic)
+
+ embed.color = discord.Color.green()
+ embed.title = f"Accepted by {interaction.user.name}"
+
+ await interaction.response.edit_message(embed=embed, view=None)
+
+ try:
+ assert embed.author.name
+ match = re.match(r".*\(([0-9]+)\)$", embed.author.name)
+ if match:
+ userid = match.group(1)
+ suggester = await interaction.client.fetch_user(int(userid))
+ await suggester.send(f"Your topic suggestion was accepted: **{topic}**")
+
+ except discord.Forbidden:
+ pass
+
+ @dui.button(
+ label="Deny",
+ style=discord.ButtonStyle.danger,
+ emoji=discord.PartialEmoji.from_str(Reference.Emoji.PartialString.kgsNo),
+ )
+ async def _deny(self, interaction: Interaction, button: dui.Button):
+ """
+ Denys the topic and removes the view from the message.
+
+ Changes the embed to indicate it was denied and by who.
+ """
+ message = interaction.message
+ assert message
+ embed = message.embeds[0]
+
+ embed.color = discord.Color.red()
+ embed.title = f"Denied by {interaction.user.name}"
+
+ await interaction.response.edit_message(embed=embed, view=None)
+
+ @dui.button(
+ label="Edit",
+ style=discord.ButtonStyle.blurple,
+ emoji=discord.PartialEmoji.from_str(Reference.Emoji.PartialString.kgsWhoAsked),
+ )
+ async def _edit(self, interaction: Interaction, button: dui.Button):
+ """
+ Sends a modal to interact with the provided topic text.
+ """
+ assert interaction.message
+ self.editing[interaction.message.id] = interaction.user.id
+
+ embed = interaction.message.embeds[0]
+ assert embed.description
+
+ topic_modal = TopicEditorModal(embed.description)
+ await interaction.response.send_modal(topic_modal)
+
+ await topic_modal.wait()
+ self.editing.pop(interaction.message.id)
+
+
+class Topic(commands.Cog):
+ def __init__(self, bot: BirdBot):
+ self.logger = logging.getLogger("Fun")
+ self.bot = bot
+
+ self.topics_db: Collection = self.bot.db.Topics
+ topics_find = self.topics_db.find_one({"name": "topics"})
+ if topics_find == None:
+ raise CollectionInvalid
+ self.topics: typing.List = topics_find["topics"] # Use this for DB interaction
+
+ @commands.Cog.listener()
+ async def on_ready(self):
+ self.logger.info("loaded Topic")
+
+ async def cog_load(self):
+ self.TOPIC_ACCEPT = f"TOPIC-ACCEPT-{self.bot._user().id}"
+ self.TOPIC_DENY = f"TOPIC-DENY-{self.bot._user().id}"
+ self.TOPIC_EDIT = f"TOPIC-EDIT-{self.bot._user().id}"
+
+ self.TOPIC_VIEW = TopicAcceptorView(
+ accept_id=self.TOPIC_ACCEPT,
+ deny_id=self.TOPIC_DENY,
+ edit_id=self.TOPIC_EDIT,
+ topics=self.topics,
+ topics_db=self.topics_db,
+ )
+
+ self.bot.add_view(self.TOPIC_VIEW)
+
+ self.topics_cycle = TopicCycle(self.topics)
+
+ async def cog_unload(self) -> None:
+ self.TOPIC_VIEW.stop()
+
+ topics_command = app_commands.Group(
+ name="topics",
+ description="Topic commands",
+ guild_ids=[Reference.guild],
+ default_permissions=discord.permissions.Permissions(manage_messages=True),
+ )
+
+ @app_commands.command()
+ @app_commands.default_permissions(send_messages=True)
+ @app_commands.guilds(Reference.guild)
+ @checks.general_only()
+ @checks.topic_perm_check()
+ @app_commands.checks.cooldown(1, 300, key=lambda i: (i.guild_id, i.user.id))
+ async def topic(self, interaction: discord.Interaction):
+ """
+ Fetches a random topic.
+ """
+ topic = next(self.topics_cycle)
+ await interaction.response.send_message(f"{topic}")
+
+ @topics_command.command()
+ @checks.mod_and_above()
+ async def search(self, interaction: discord.Interaction, text: str):
+ """
+ Search a topic.
+
+ Parameters
+ ----------
+ text: str
+ Search string
+ """
+
+ await interaction.response.defer(ephemeral=True)
+
+ search_result = process.extractBests(text, self.topics, limit=9)
+
+ t = [topic[0] for topic in search_result if topic[1] > 75]
+
+ if t == []:
+ return await interaction.edit_original_response(content="No match found.")
+
+ embed_desc = "".join(f"{self.topics.index(tp) + 1}. {tp}\n" for _, tp in enumerate(t))
+
+ embed = discord.Embed(
+ title="Best matches for search: ",
+ description=embed_desc,
+ )
+
+ await interaction.edit_original_response(embed=embed)
+
+ @topics_command.command()
+ @checks.mod_and_above()
+ async def add(self, interaction: discord.Interaction, text: str):
+ """
+ Moderators can add a topic directly.
+
+ Parameters
+ ----------
+ text: str
+ New topic
+ """
+
+ self.topics.append(text)
+
+ self.topics_db.update_one({"name": "topics"}, {"$set": {"topics": self.topics}})
+
+ TopicCycle().queue_last(text)
+
+ await interaction.response.send_message(f"Topic added.")
+
+ @topics_command.command()
+ @checks.mod_and_above()
+ async def remove(
+ self,
+ interaction: discord.Interaction,
+ index: typing.Optional[int] = None,
+ search_text: typing.Optional[str] = None,
+ ):
+ """
+ Moderators can remove a topic.
+
+ Parameters
+ ----------
+ index: int
+ Index of topic
+ search_text: str
+ Search string
+ """
+ topics_find = self.topics_db.find_one({"name": "topics"})
+ if topics_find == None:
+ raise CollectionInvalid
+ self.topics = topics_find["topics"]
+
+ if index is None and search_text is None:
+ return await interaction.response.send_message(
+ "Please provide value for one of the arguments.", ephemeral=True
+ )
+
+ await interaction.response.defer()
+
+ if index is not None:
+ if index < 1 or index > len(self.topics):
+ return await interaction.edit_original_response(
+ content=f"Invalid index. Min value: 1, Max value: {len(self.topics)}"
+ )
+
+ index = index - 1
+ topic = self.topics[index]
+ del self.topics[index]
+
+ self.topics_db.update_one({"name": "topics"}, {"$set": {"topics": self.topics}})
+
+ TopicCycle().queue_remove(topic)
+
+ emb = discord.Embed(
+ title="Success",
+ description=f"**{topic}** removed.",
+ colour=discord.Colour.green(),
+ )
+ await interaction.edit_original_response(embed=emb)
+
+ else:
+ if search_text is None:
+ return await interaction.edit_original_response(
+ content="Invalid arguments. Please specify either index or search string."
+ )
+
+ search_result = process.extractBests(search_text, self.topics, limit=9)
+
+ t = [topic[0] for topic in search_result if topic[1] > 75]
+
+ if t == []:
+ return await interaction.edit_original_response(content="No match found.")
+
+ embed_desc = "".join(f"{index + 1}. {tp}\n" for index, tp in enumerate(t))
+
+ embed = discord.Embed(
+ title="React on corresponding number to delete topic.",
+ description=embed_desc,
+ )
+
+ msg = await interaction.edit_original_response(embed=embed)
+
+ emote_list = [
+ "\u0031\uFE0F\u20E3",
+ "\u0032\uFE0F\u20E3",
+ "\u0033\uFE0F\u20E3",
+ "\u0034\uFE0F\u20E3",
+ "\u0035\uFE0F\u20E3",
+ "\u0036\uFE0F\u20E3",
+ "\u0037\uFE0F\u20E3",
+ "\u0038\uFE0F\u20E3",
+ "\u0039\uFE0F\u20E3",
+ ]
+
+ for emote in emote_list[: len(t)]:
+ await msg.add_reaction(emote)
+
+ def check(reaction, user):
+ return user == interaction.user and str(reaction.emoji) in emote_list
+
+ try:
+ reaction, user = await self.bot.wait_for("reaction_add", timeout=30.0, check=check)
+
+ i = emote_list.index(str(reaction.emoji))
+
+ emb = discord.Embed(
+ title="Success!",
+ description=f"**{search_result[i][0]}**\nremoved",
+ colour=discord.Colour.green(),
+ )
+
+ self.topics.remove(search_result[i][0])
+
+ self.topics_db.update_one({"name": "topics"}, {"$set": {"topics": self.topics}})
+
+ TopicCycle().queue_remove(search_result[i][0])
+
+ await msg.edit(embed=emb)
+ await msg.clear_reactions()
+
+ except asyncio.TimeoutError:
+ await msg.delete()
+ return
+
+ @app_commands.command()
+ @app_commands.default_permissions(send_messages=True)
+ @app_commands.guilds(Reference.guild)
+ @app_commands.checks.cooldown(1, 60, key=lambda i: (i.guild_id, i.user.id))
+ async def topic_suggest(self, interaction: discord.Interaction, topic: str):
+ """
+ Users can suggest a new topic.
+
+ Parameters
+ ----------
+ topic: str
+ Topic to suggest
+ """
+ await interaction.response.defer(ephemeral=True)
+ automated_channel = self.bot._get_channel(Reference.Channels.banners_and_topics)
+ embed = discord.Embed(description=topic, color=0xC8A2C8)
+ embed.set_author(
+ name=f"{interaction.user.name} ({interaction.user.id})",
+ icon_url=interaction.user.display_avatar.url,
+ )
+ embed.set_footer(text="topic")
+ await automated_channel.send(embed=embed, view=self.TOPIC_VIEW)
+
+ await interaction.edit_original_response(
+ content="Topic suggested.",
+ )
+
+
+async def setup(bot: BirdBot):
+ await bot.add_cog(Topic(bot))
diff --git a/app/utils/checks.py b/app/utils/checks.py
new file mode 100644
index 00000000..943823e0
--- /dev/null
+++ b/app/utils/checks.py
@@ -0,0 +1,210 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+Checks for the bot commands.
+These checks are used to ensure that the command invoker has the necessary permissions to run the command.
+"""
+import discord
+from discord import Interaction, app_commands
+from discord.ext import commands
+
+from .config import Reference
+from .errors import InvalidAuthorizationError, InvalidInvocationError
+
+
+def check(predicate):
+ """
+ This is a custom check decorator that works for both app_commands and
+ regular text commands.
+ """
+
+ def true_decorator(decked_func):
+ if isinstance(decked_func, app_commands.Command):
+ app_commands.check(predicate)(decked_func)
+
+ elif isinstance(decked_func, commands.Command):
+ commands.check(predicate)(decked_func)
+
+ else:
+ app_commands.check(predicate)(decked_func)
+ commands.check(predicate)(decked_func)
+
+ return decked_func
+
+ return true_decorator
+
+
+def mod_and_above():
+ """
+ Checks if the command invoker has a mod role or above.
+ """
+
+ async def predicate(info: Interaction | commands.Context):
+ user = info.user if isinstance(info, Interaction) else info.author
+ assert isinstance(user, discord.Member)
+
+ user_role_ids = [x.id for x in user.roles]
+ check_role_ids = Reference.Roles.moderator_and_above()
+ if not any(x in user_role_ids for x in check_role_ids):
+ raise InvalidAuthorizationError
+ return True
+
+ return check(predicate)
+
+
+def admin_and_above():
+ """
+ Checks if the author of the context is an administrator or kgs official.
+ """
+
+ async def predicate(info: Interaction | commands.Context):
+ user = info.user if isinstance(info, Interaction) else info.author
+ assert isinstance(user, discord.Member)
+
+ user_role_ids = [x.id for x in user.roles]
+ check_role_ids = Reference.Roles.admin_and_above()
+ if not any(x in user_role_ids for x in check_role_ids):
+ raise InvalidAuthorizationError
+ return True
+
+ return check(predicate)
+
+
+def role_and_above(id: int):
+ """
+ Checks if the user has role above or equal to the passed role.
+ """
+
+ async def predicate(info: Interaction | commands.Context):
+ user = info.user if isinstance(info, Interaction) else info.author
+ guild = info.guild
+
+ assert isinstance(user, discord.Member)
+ if guild is None:
+ raise InvalidInvocationError
+
+ check_role = guild.get_role(id) # Role could not exist if command is used in incorrect guild
+ if not user.top_role >= check_role:
+ raise InvalidAuthorizationError
+ return True
+
+ return check(predicate)
+
+
+def mainbot_only():
+ """
+ Checks if the bot running the context is the main bot.
+ """
+
+ async def predicate(info: Interaction | commands.Context):
+ me = info.client.user if isinstance(info, Interaction) else info.me
+ assert me
+ if not me.id == Reference.mainbot:
+ raise InvalidInvocationError
+ return True
+
+ return check(predicate)
+
+
+def devs_only():
+ """
+ Checks if the command invoker is in the dev list.
+ """
+
+ async def predicate(info: Interaction | commands.Context):
+ user = info.user if isinstance(info, Interaction) else info.author
+
+ if not user.id in Reference.botdevlist:
+ raise InvalidAuthorizationError
+ return True
+
+ return check(predicate)
+
+
+def general_only():
+ """
+ Checks if the command is invoked in general chat or the moderation category.
+ """
+
+ async def predicate(info: Interaction | commands.Context):
+ channel = info.channel
+ assert isinstance(channel, discord.TextChannel)
+
+ if channel.category_id != Reference.Categories.moderation and channel.id != Reference.Channels.general:
+ raise InvalidInvocationError(content=f"This command can only be ran in <#{Reference.Channels.general}>")
+ return True
+
+ return check(predicate)
+
+
+def bot_commands_only():
+ """
+ Checks if the command is invoked in bot_commands or the moderation category.
+ """
+
+ async def predicate(info: Interaction | commands.Context):
+ channel = info.channel
+
+ assert isinstance(channel, discord.TextChannel)
+ if channel.category_id != Reference.Categories.moderation and channel.id != Reference.Channels.bot_commands:
+ raise InvalidInvocationError(
+ content=f"This command can only be ran in <#{Reference.Channels.bot_commands}>"
+ )
+ return True
+
+ return check(predicate)
+
+
+def topic_perm_check():
+ """
+ Checks if the command invoker has the duck role+ or a patreon role.
+ """
+
+ async def predicate(info: Interaction | commands.Context):
+ user = info.user if isinstance(info, Interaction) else info.author
+ guild = info.guild
+
+ assert isinstance(user, discord.Member)
+
+ if guild is None:
+ raise InvalidInvocationError
+
+ check_role = guild.get_role(Reference.Roles.duck)
+
+ user_role_ids = [x.id for x in user.roles]
+ check_role_ids = Reference.Roles.patreon()
+ if user.top_role >= check_role or any(x in user_role_ids for x in check_role_ids):
+ return True
+ raise InvalidAuthorizationError(content="This can only be ran by ducks+ and patreon members")
+
+ return check(predicate)
+
+
+def patreon_only():
+ """
+ Checks if the command invoker has the duck role+ or a patreon role.
+ """
+
+ async def predicate(info: Interaction | commands.Context):
+ client = info.client if isinstance(info, Interaction) else info.bot
+ user = info.user if isinstance(info, Interaction) else info.author
+
+ member = client.get_guild(Reference.guild).get_member(user.id) # type: ignore
+ assert member
+ member_role_ids = [x.id for x in member.roles]
+ check_role_ids = Reference.Roles.patreon()
+ if not any(x in member_role_ids for x in check_role_ids):
+ raise InvalidAuthorizationError
+ return True
+
+ return check(predicate)
diff --git a/app/utils/config.py b/app/utils/config.py
new file mode 100644
index 00000000..da6cadaa
--- /dev/null
+++ b/app/utils/config.py
@@ -0,0 +1,174 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This module contains the configuration settings for guild level items such as roles, channels and emojis.
+"""
+
+import discord
+
+
+class Reference:
+ botownerlist = [
+ 183092910495891467, # Sloth
+ 248790213386567680, # Austin
+ ]
+ botdevlist = [
+ 389718094270038018, # FC
+ 424843380342784011, # Oeav
+ 183092910495891467, # Sloth
+ 248790213386567680, # Austin
+ 229779964898181120, # source
+ ]
+ guild = 414027124836532234
+ mainbot = 471705718957801483
+ bannsystembot = 697374082509045800
+
+ class Roles:
+
+ moderator = 414092550031278091
+ administrator = 414029841101225985
+ kgsofficial = 414954904382210049
+ trainee_mod = 905510680763969536
+ robobird = 414155501518061578
+ stealthbot = 691931822023770132
+ subreddit_mod = 681812574026727471
+ kgsmaintenance = 915629257470906369
+
+ patreon_3 = 753258289185161248
+ patreon_2 = 415154206970740737
+ patreon_1 = 753268671107039274
+
+ nitro_bird = 598031301622104095
+ contributor = 476852559311798280
+
+ galacduck = 698479120878665729 # GalacDuck
+ legendary_duck = 662937489220173884 # LegendDuck
+ super_duck = 637114917178048543 # SuperDuck
+ duck = 637114897544511488 # Duck
+ smol_duck = 637114873268142081 # Smol Duck
+ duckling = 637114849570062347 # Duckling
+ duck_hatchling = 637114722675851302 # Duck Hatchling
+ duck_egg = 821961644425871390 # Duck Egg
+
+ english = 901136119863844864
+
+ @staticmethod
+ def admin_and_above():
+ return [Reference.Roles.administrator, Reference.Roles.kgsofficial]
+
+ @staticmethod
+ def moderator_and_above():
+ return [
+ Reference.Roles.trainee_mod,
+ Reference.Roles.moderator,
+ Reference.Roles.administrator,
+ Reference.Roles.kgsofficial,
+ ]
+
+ @staticmethod
+ def patreon():
+ return [Reference.Roles.patreon_1, Reference.Roles.patreon_2, Reference.Roles.patreon_3]
+
+ class Categories:
+ moderation = 414095379156434945
+ server_logs = 879399341561892905
+
+ class Channels:
+ general = 1162035011025911889
+ bot_commands = 414452106129571842
+ bot_tests = 414179142020366336
+ new_members = 526882555174191125
+ humanities = 1162034723758034964
+ server_moments = 960927545639972994
+ mod_chat = 1092578562608988290
+ social_media_queue = 580354435302031360
+ social_media_feed = 489450008643502080
+ banners_and_topics = 546689491486769163
+ intro_channel = 981620309163655218
+ language_tests = 974333356688965672
+ the_perch = 651461159995834378
+
+ class Logging:
+ mod_actions = 543884016282239006
+ automod_actions = 966769038879498301
+ message_actions = 879399217511161887
+ member_actions = 939570758903005296
+ dev = 865321589919055882
+ misc_actions = 713107972737204236
+ bannsystem = 1009138597221372044
+
+ class Emoji:
+ kgsYes = 955703069516128307
+ kgsNo = 955703108565098496
+
+ class PartialString:
+ kgsYes = "<:kgsYes:955703069516128307>"
+ kgsNo = "<:kgsNo:955703108565098496>"
+ kgsStop = "<:kgsStop:579824947959169024>"
+ kgsWhoAsked = "<:kgsWhoAsked:754871694467924070>"
+
+ @staticmethod
+ async def fetch(client: discord.Client, ref: int) -> discord.Emoji | None:
+ """
+ When given a client object and an emoji id, returns a discord.Emoji
+ """
+
+ if em := client.get_emoji(ref) is not None:
+ return em # type: ignore
+ return None
+
+
+class GiveawayBias:
+ roles = [
+ {
+ "id": Reference.Roles.galacduck,
+ "bias": 11,
+ },
+ {
+ "id": Reference.Roles.legendary_duck,
+ "bias": 7,
+ },
+ {
+ "id": Reference.Roles.super_duck,
+ "bias": 4,
+ },
+ {
+ "id": Reference.Roles.duck,
+ "bias": 3,
+ },
+ {
+ "id": Reference.Roles.smol_duck,
+ "bias": 2,
+ },
+ ]
+ default = 1
+
+
+class ExclusiveColors:
+ """
+ Contains a list of selectable colored roles that can be provided to a user if they have the role that unlocks the color.
+ """
+
+ exclusive_colors = {
+ "Patreon Orange": {
+ "id": 976158045639946300,
+ "unlockers": [Reference.Roles.patreon_1, Reference.Roles.patreon_2, Reference.Roles.patreon_3],
+ },
+ "Patreon Green": {
+ "id": 976158006616137748,
+ "unlockers": [Reference.Roles.patreon_2, Reference.Roles.patreon_3],
+ },
+ "Patreon Blue": {"id": 976157262718582784, "unlockers": [Reference.Roles.patreon_3]},
+ "Nitro Pink": {"id": 976157185971204157, "unlockers": [Reference.Roles.nitro_bird]},
+ "Contributor Gold": {"id": 976176253826654329, "unlockers": [Reference.Roles.contributor]},
+ }
diff --git a/app/utils/custom_converters.py b/app/utils/custom_converters.py
new file mode 100644
index 00000000..0b057c6e
--- /dev/null
+++ b/app/utils/custom_converters.py
@@ -0,0 +1,107 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This module provides custom converters for Discord bot commands.
+"""
+
+import logging
+import re
+
+from discord.ext import commands
+from discord.ext.commands.converter import _get_from_guilds, _utils_get
+
+logger = logging.getLogger("CustomConverters")
+
+
+def _get_id_match(argument):
+ """
+ Check if the string could be a user id.
+
+ Args:
+ argument (str): The string to check.
+
+ Returns:
+ re.Match: A match object if the string is a user id, None otherwise.
+ """
+ _id_regex = re.compile(r"([0-9]{15,21})$")
+ return _id_regex.match(argument)
+
+
+def member_converter(ctx: commands.Context, argument):
+ """
+ Find a guild member from an id or mention string.
+
+ Args:
+ ctx (commands.Context): The context of the command.
+ argument (str): The id or mention string.
+
+ Returns:
+ discord.Member: The guild member if found, None otherwise.
+ """
+ try:
+ bot = ctx.bot
+ guild = ctx.guild
+ match = _get_id_match(argument) or re.match(r"<@!?([0-9]+)>$", argument)
+ if match is None:
+ result = None
+ else:
+ user_id = int(match.group(1))
+ if guild:
+ result = guild.get_member(user_id) or _utils_get(ctx.message.mentions, id=user_id)
+ else:
+ result = _get_from_guilds(bot, "get_member", user_id)
+
+ return result
+ except Exception as e:
+ logging.error(str(e))
+ return None
+
+
+def get_members(ctx: commands.Context, *args):
+ """
+ Return a list of members found and leftover text.
+
+ Args:
+ ctx (commands.Context): The context of the command.
+ *args (str): Variable number of arguments representing id or mention strings.
+
+ Returns:
+ tuple: A tuple containing a list of members found and leftover text.
+ - list: The list of members found.
+ - list: The leftover text.
+ """
+ try:
+ members = []
+ extra = []
+ got_members = False
+ for a in args:
+ result = member_converter(ctx, a)
+ if not got_members:
+ if result:
+ members.append(result)
+ else:
+ got_members = True
+ extra.append(a)
+ else:
+ extra.append(a)
+
+ if not members:
+ members = None
+ if not extra:
+ extra = None
+
+ return members, extra
+
+ except Exception as e:
+ logging.error(e)
+ return None, None
diff --git a/app/utils/errors.py b/app/utils/errors.py
new file mode 100644
index 00000000..c9dff32f
--- /dev/null
+++ b/app/utils/errors.py
@@ -0,0 +1,88 @@
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+Contains all internal errors for the program.
+
+Behavior for errors are within the InternalError class and alternations are made in inherited classes.
+"""
+
+from discord import Embed, Interaction, app_commands
+from discord.ext import commands
+
+from .config import Reference
+
+
+class InternalError(Exception):
+ """
+ Base class for all internal errors.
+ """
+
+ title = "Internal Error"
+ content = "an unhandled internal error occurred. if this continues please inform an active bot dev"
+ color = 0xC6612A
+
+ def __init__(self, *, content: str | None = None):
+ if content is not None:
+ self.content = content
+
+ def format_notif_embed(self, info: commands.Context | Interaction):
+ # interaction = info if isinstance(info, Interaction) else None
+ # context = info if isinstance(info, commands.Context) else None
+
+ embed = Embed(title=self.title, color=self.color, description=self.content.format(info=info))
+
+ return embed
+
+
+class CheckFailure(InternalError, app_commands.CheckFailure, commands.CheckFailure):
+ """
+ InternalError and CheckFailure for both slash and message commands.
+ """
+
+ title = f"{Reference.Emoji.PartialString.kgsNo} You can not use this command"
+ content = "Default, Bot Devs need to provide better info here"
+
+
+class InvalidAuthorizationError(CheckFailure):
+ """
+ Raised when user does not have access to run a command.
+ """
+
+ title = f"{Reference.Emoji.PartialString.kgsNo} Invalid Authorization"
+ content = "```\nYou do not have access to run this command\n```"
+
+
+class InvalidInvocationError(CheckFailure):
+ """
+ Raised when user runs a command in the wrong place.
+ """
+
+ title = f"{Reference.Emoji.PartialString.kgsNo} Invalid Invocation"
+ content = "```\nThis command was ran in an invalid context\n```"
+
+
+class InvalidParameterError(CheckFailure):
+ """
+ Raised when the user provides bad parameters for the command.
+ """
+
+ title = f"{Reference.Emoji.PartialString.kgsNo} Invalid Parameters"
+ content = "The parameters you provided are not accepted in this context"
+
+
+class InvalidFunctionUsage(InternalError):
+ """
+ Usually raised when self.bot custom functions are used incorrectly.
+ """
+
+ pass
diff --git a/utils/helper.py b/app/utils/helper.py
similarity index 60%
rename from utils/helper.py
rename to app/utils/helper.py
index 2e9d1157..a7fac7ca 100644
--- a/utils/helper.py
+++ b/app/utils/helper.py
@@ -1,21 +1,37 @@
-import re
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+Miscallaneous helper functions and classes that are used throught the bot.
+"""
+
import datetime
-import json
import logging
-from typing import List, Tuple, Union
-import typing
+import random
+import re
+from collections import deque
+from typing import List, Tuple
import discord
from discord.ext import commands
-from birdbot import BirdBot
+from app.birdbot import BirdBot
+
+from .config import Reference
infraction_db = BirdBot.db.Infraction
timed_actions_db = BirdBot.db.TimedAction
cmd_blacklist_db = BirdBot.db.CommandBlacklist
-config_json = json.load(open("config.json"))
-config_roles = config_json["roles"]
logger = logging.getLogger("Helper")
@@ -104,134 +120,34 @@
# ----Exception classes begin------#
class NoAuthorityError(commands.CheckFailure):
- """Raised when user has no clearance to run a command"""
+ """
+ Raised when user has no clearance to run a command.
+ """
class WrongChannel(commands.CheckFailure):
- """Raised when trying to run a command in the wrong channel"""
+ """
+ Raised when trying to run a command in the wrong channel.
+ """
def __init__(self, id):
super().__init__(f"This command can only be run in <#{id}>")
class DevBotOnly(commands.CheckFailure):
- """Raised when trying to run commands meant for dev bots"""
+ """
+ Raised when trying to run commands meant for dev bots.
+ """
# ----Exception classes end------#
-# ------Custom checks begin-------#
-
-
-def general_only():
- async def predicate(ctx: commands.Context):
- if (
- ctx.channel.category_id != 414095379156434945 # Mod channel category
- and ctx.channel.id != 414027124836532236 # general id
- ):
- raise WrongChannel(414027124836532236)
- return True
-
- return commands.check(predicate)
-
-
-def bot_commands_only():
- async def predicate(ctx: commands.Context):
- if (
- ctx.channel.category_id != 414095379156434945 # Mod channel category
- and ctx.channel.id != 414452106129571842 # bot commands id
- ):
- raise WrongChannel(414452106129571842)
- return True
-
- return commands.check(predicate)
-
-
-def devs_only():
- async def predicate(ctx: commands.Context):
- if not ctx.author.id in [
- 389718094270038018, # FC
- 424843380342784011, # Oeav
- 183092910495891467, # Sloth
- 248790213386567680, # Austin
- 229779964898181120, # source
- ]:
- raise NoAuthorityError
- return True
-
- return commands.check(predicate)
-
-
-def mainbot_only():
- async def predicate(ctx: commands.Context):
- if not ctx.me.id == 471705718957801483:
- raise DevBotOnly
- return True
-
- return commands.check(predicate)
-
-
-def role_and_above(id: int):
- """Check if user has role above or equal to passed role"""
-
- async def predicate(ctx: commands.Context):
- check_role = ctx.guild.get_role(id)
- if not ctx.author.top_role >= check_role:
- raise NoAuthorityError
- return True
-
- return commands.check(predicate)
-
-
-def patreon_only():
- async def predicate(ctx: commands.Context):
-
- guild = discord.utils.get(ctx.bot.guilds, id=414027124836532234)
- user = guild.get_member(ctx.author.id)
- user_role_ids = [x.id for x in user.roles]
- check_role_ids = [
- config_roles["patreon_blue_role"],
- config_roles["patreon_green_role"],
- config_roles["patreon_orange_role"],
- ]
- if not any(x in user_role_ids for x in check_role_ids):
- raise NoAuthorityError
- return True
-
- return commands.check(predicate)
-
-
-def mod_and_above():
- async def predicate(ctx: commands.Context):
- user_role_ids = [x.id for x in ctx.author.roles]
- check_role_ids = [
- config_roles["mod_role"],
- config_roles["admin_role"],
- config_roles["kgsofficial_role"],
- config_roles["trainee_mod_role"],
- ]
- if not any(x in user_role_ids for x in check_role_ids):
- raise NoAuthorityError
- return True
-
- return commands.check(predicate)
-
-
-def admin_and_above():
- async def predicate(ctx: commands.Context):
- user_role_ids = [x.id for x in ctx.author.roles]
- check_role_ids = [config_roles["admin_role"], config_roles["kgsofficial_role"]]
- if not any(x in user_role_ids for x in check_role_ids):
- raise NoAuthorityError
- return True
-
- return commands.check(predicate)
-
def is_internal_command(bot: commands.AutoShardedBot, message: discord.Message):
"""
- check if message is a bird bot command
- returns bool
+ Check if message is a bird bot command.
+
+ Returns bool.
"""
for x in bot.commands:
if any(message.content.startswith(f"!{y}") for y in x.aliases):
@@ -243,8 +159,9 @@ def is_internal_command(bot: commands.AutoShardedBot, message: discord.Message):
def is_external_command(message: discord.Message):
"""
- check if message is a third party bot command
- returns bool
+ Check if message is a third party bot command.
+
+ Returns bool.
"""
for command in possible_commands:
if re.match(possible_prefixes + command, message.content, re.IGNORECASE):
@@ -252,13 +169,10 @@ def is_external_command(message: discord.Message):
return False
-# ------Custom checks end-------#
-
-
def create_embed(
- author: Union[discord.User, discord.Member],
+ author: discord.User | discord.Member,
action: str,
- users: List[Union[discord.User, discord.Member]] = None,
+ users: List[discord.User | discord.Member] | None = None,
reason=None,
extra=None,
color=discord.Color.blurple,
@@ -266,7 +180,7 @@ def create_embed(
inf_level=None,
) -> discord.Embed:
"""
- Creates an embed
+ Creates an embed.
Args:
author (discord.User or discord.Member): The author of the action (eg ctx.author)
@@ -309,13 +223,12 @@ def create_embed(
return embed
-def create_timed_action(
- users: List[Union[discord.User, discord.Member]], action: str, time: int
-):
+def create_timed_action(users: List[discord.User | discord.Member], action: str, time: int):
+ # TODO DELETE
"""Creates a database entry for timed action [not in use currently]
Args:
- users (List[Union[discord.User, discord.Member]]): List of affected users
+ users (List[discord.User | discord.Member]): List of affected users
action (str): Action ("mute")
time (int): Duration for which action will last
"""
@@ -328,14 +241,14 @@ def create_timed_action(
"action": action,
"action_start": datetime.datetime.utcnow(),
"duration": time,
- "action_end": datetime.datetime.utcnow()
- + datetime.timedelta(seconds=time),
+ "action_end": datetime.datetime.utcnow() + datetime.timedelta(seconds=time),
}
)
ids = timed_actions_db.insert_many(data)
def delete_timed_actions_uid(u_id: int):
+ # TODO DELETE
"""delete timed action by user_id [not in use currently]
Args:
@@ -344,8 +257,10 @@ def delete_timed_actions_uid(u_id: int):
timed_actions_db.remove({"user_id": u_id})
-def calc_time(args: List[str]) -> Tuple[int, str]:
- """Parses time from given list.
+def calc_time(args: List[str]) -> Tuple[int | None, str | None]:
+ """
+ Parses time from given list (string.split(" ")).
+
Example:
["1hr", "12m30s", "extra", "string"] => (4350, "extra string")
@@ -381,10 +296,8 @@ def calc_time(args: List[str]) -> Tuple[int, str]:
if a[:s] == "":
break
else:
-
t = 0
for i in a:
-
if i.isdigit():
t = t * 10 + int(i)
@@ -422,7 +335,8 @@ def calc_time(args: List[str]) -> Tuple[int, str]:
def get_time_string(t: int) -> str:
- """Convert provided time input (seconds) to Day-Hours-Mins-Second string
+ """
+ Convert provided time input (seconds) to Day-Hours-Mins-Second string.
Args:
t (int): Time in seconds
@@ -441,6 +355,7 @@ def get_time_string(t: int) -> str:
def get_timed_actions():
+ # TODO DELETE
"""Fetch all timed action from db [not in use currently]"""
return timed_actions_db.find().sort("action_end", 1)
@@ -449,7 +364,8 @@ def create_automod_embed(
message: discord.Message,
automod_type: str,
):
- """Create embed for automod
+ """
+ Create embed for automod.
Args:
message (discord.Message): The message object
@@ -458,6 +374,7 @@ def create_automod_embed(
Returns:
embed: A discord.Embed object.
"""
+ assert isinstance(message.channel, discord.TextChannel)
embed = discord.Embed(
title=f"Message deleted. ({automod_type})",
description=f"Message author: {message.author.mention}\nChannel: {message.channel.mention}",
@@ -466,77 +383,63 @@ def create_automod_embed(
)
embed.add_field(
name="Message Content",
- value=f"{message.content[:1024]}"
- if message.content
- else "it's an attachment/embed",
+ value=f"{message.content[:1024]}" if message.content else "it's an attachment/embed",
inline=False,
)
return embed
-def get_active_staff(bot: commands.AutoShardedBot) -> str:
+def get_active_staff(bot: discord.Client) -> str:
"""
- Gets string containing mentions of active staff (mods, trainee mods and admins)
+ Gets string containing mentions of active staff (mods, trainee mods and admins).
+
Mentions both mod roles if no mod is online
Returns: str
"""
- guild = discord.utils.get(bot.guilds, id=414027124836532234)
+ guild = discord.utils.get(bot.guilds, id=Reference.guild)
+ assert guild
active_staff = []
mods_active = False
- for role_id in [
- config_roles["mod_role"],
- config_roles["admin_role"],
- config_roles["trainee_mod_role"],
- ]:
- for member in discord.utils.get(guild.roles, id=role_id).members:
+ for role_id in [Reference.Roles.moderator, Reference.Roles.administrator, Reference.Roles.trainee_mod]:
+ role = discord.utils.get(guild.roles, id=role_id)
+ assert role
+ for member in role.members:
if member.bot:
continue
if member in active_staff:
continue
- if (
- member.status == discord.Status.online
- or member.status == discord.Status.idle
- ):
+ if member.status == discord.Status.online or member.status == discord.Status.idle:
active_staff.append(member)
if not mods_active:
- if member.top_role.id in [
- config_roles["mod_role"],
- config_roles["trainee_mod_role"],
- ]:
+ if member.top_role.id in [Reference.Roles.moderator, Reference.Roles.trainee_mod]:
# check for active mods
mods_active = True
mention_str = " ".join([staff.mention for staff in active_staff])
if not mods_active:
- mention_str += (
- f"<@&{config_roles['mod_role']}> <@&{config_roles['trainee_mod_role']}>"
- )
+ mention_str += f"<@&{Reference.Roles.moderator}> <@&{Reference.Roles.trainee_mod}>"
return mention_str
-def blacklist_member(
- bot: commands.AutoShardedBot, member: discord.Member, command: commands.Command
-):
+# This is useless due to slash migration
+def blacklist_member(bot: commands.AutoShardedBot, member: discord.Member, command: commands.Command):
"""
- Blacklists a member from a command
+ Blacklists a member from a command.
"""
cmd = cmd_blacklist_db.find_one({"command_name": command.name})
if cmd is None:
- cmd_blacklist_db.insert_one(
- {"command_name": command.name, "blacklisted_users": [member.id]}
- )
+ cmd_blacklist_db.insert_one({"command_name": command.name, "blacklisted_users": [member.id]})
return
- cmd_blacklist_db.update_one(
- {"command_name": command.name}, {"$push": {"blacklisted_users": member.id}}
- )
+ cmd_blacklist_db.update_one({"command_name": command.name}, {"$push": {"blacklisted_users": member.id}})
+# This is useless due to slash migration
def whitelist_member(member: discord.Member, command: commands.Command) -> bool:
"""
Whitelist a member from a command and return True
@@ -546,20 +449,91 @@ def whitelist_member(member: discord.Member, command: commands.Command) -> bool:
if cmd is None or member.id not in cmd["blacklisted_users"]:
return False
- cmd_blacklist_db.update_one(
- {"command_name": command.name}, {"$pull": {"blacklisted_users": member.id}}
- )
+ cmd_blacklist_db.update_one({"command_name": command.name}, {"$pull": {"blacklisted_users": member.id}})
return True
-def is_public_channel(
- channel: typing.Union[discord.TextChannel, discord.Thread]
-) -> bool:
+def is_public_channel(channel: discord.TextChannel | discord.Thread) -> bool:
"""
- Returns true for all channels except those under the moderation category
+ Returns true for all channels except those under the moderation category.
Currently used within the moderation cog to determine if an interaction
- should be ephemeral
+ should be ephemeral.
"""
- # check if channel is underneath the moderation category
- return channel.category_id != 414095379156434945 # mod category id
+
+ return channel.category_id != Reference.Categories.moderation
+
+
+class Cycle(object):
+ """
+ Singleton iterator class used to cycle through a list randomly infinitely.
+ """
+
+ __instance = None
+
+ queue = []
+ dequeue = deque([], 0)
+
+ def __new__(cls, *args, **kwargs):
+ if cls.__instance is None:
+ cls.__instance = super(Cycle, cls).__new__(cls)
+ return cls.__instance
+
+ def __init__(self, queue: list | None = None, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if queue:
+ self.queue = queue
+ self.dequeue = deque(random.sample(self.queue, len(self.queue)))
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ """
+ Returns the current item in the queue and makes a new random queue if empty.
+ """
+ if len(self.dequeue) == 1:
+ self.dequeue.extend(random.sample(self.queue, len(self.queue)))
+
+ if not self.dequeue:
+ raise StopIteration
+
+ cur = self.dequeue.popleft()
+ return cur
+
+ def queue_last(self, entry):
+ """
+ Adds an item to the end of the queue.
+ """
+ self.dequeue.append(entry)
+
+ def queue_next(self, entry):
+ """
+ Adds an item to the beginning of the queue.
+ """
+ self.dequeue.extendleft([entry])
+
+ def queue_remove(self, entry):
+ """
+ Removes an item from the queue.
+ """
+ try:
+ self.dequeue.remove(entry)
+ except:
+ pass
+
+
+class BannerCycle(Cycle):
+ """
+ The iterator class for banners.
+ """
+
+ pass
+
+
+class TopicCycle(Cycle):
+ """
+ The iterator class for topics.
+ """
+
+ pass
diff --git a/utils/infraction.py b/app/utils/infraction.py
similarity index 67%
rename from utils/infraction.py
rename to app/utils/infraction.py
index bb2b034b..0e449ed2 100644
--- a/utils/infraction.py
+++ b/app/utils/infraction.py
@@ -1,7 +1,31 @@
-import enum, typing, logging
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This module provides classes for managing infractions in the kurzgesagt server.
+
+Classes:
+- InfractionKind: An enumeration representing the different types of infractions.
+- Infraction: Represents an individual infraction.
+- InfractionList: Represents a list of infractions for a user.
+"""
+
+import enum
+import logging
+from typing import Dict, List, Optional
+
import discord
-from birdbot import BirdBot
+from app.birdbot import BirdBot
INFRACTION_DB = BirdBot.db.Infraction
logger = logging.getLogger(__name__)
@@ -16,12 +40,12 @@ class InfractionKind(enum.Enum):
class Infraction:
"""
- Represents an infraction in the kurzgesagt server. Supports warns, mutes,
- kicks and bans
- """
+ Represents an infraction in the kurzgesagt server.
- def __init__(self, kind: InfractionKind, data: typing.Dict):
+ Supports warns, mutes, kicks and bans.
+ """
+ def __init__(self, kind: InfractionKind, data: Dict):
self._kind = kind
self._author_id = data.pop("author_id", None)
self._author_name = data.pop("author_name", None)
@@ -37,12 +61,24 @@ def __init__(self, kind: InfractionKind, data: typing.Dict):
def new(
cls,
kind: InfractionKind,
- author: discord.User,
+ author: discord.User | discord.Member,
level: int,
reason: str,
- duration: str = None,
+ duration: Optional[str] = None,
):
- """Creates a new infraction instance with the provided details"""
+ """
+ Creates a new infraction instance with the provided details.
+
+ Args:
+ kind (InfractionKind): The kind of infraction.
+ author (discord.User | discord.Member): The author of the infraction.
+ level (int): The level of the infraction.
+ reason (str): The reason for the infraction.
+ duration (Optional[str], optional): The duration of the infraction. Defaults to None.
+
+ Returns:
+ Infraction: The newly created infraction instance.
+ """
data = {
"author_id": author.id,
@@ -58,19 +94,18 @@ def new(
return cls(kind, data)
@property
- def level(self) -> typing.Union[int, str]:
+ def level(self) -> int | str:
"""
- A property that returns the integer level of the infraction or the
- string 'legacy' if there is no level.
+ A property that returns the integer level of the infraction or the string 'legacy' if there is no level.
"""
return self._level if self._level is not None else "legacy"
def info_str(self, id: int):
"""
- Returns basic information of the infraction as a string
+ Returns basic information of the infraction as a string.
- The infraction's index must be passed into the method to provide an id
+ The infraction's index must be passed into the method to provide an id.
"""
duration = "" if self._duration is None else f"Duration: {self._duration}\n"
@@ -83,9 +118,9 @@ def info_str(self, id: int):
+ f"{self._kind.name.title()} ID: {id}\n```"
)
- def detailed_info_embed(self, user: discord.User):
+ def detailed_info_embed(self, user: discord.User | discord.Member):
"""
- Returns detailed information on the infraction as an embed
+ Returns detailed information on the infraction as an embed.
"""
embed = discord.Embed(
@@ -115,19 +150,18 @@ def detailed_info_embed(self, user: discord.User):
def detail(self, title: str, description: str):
"""
- Appends extra details to the infraction. This also allows for editing of
- contents such as the reason though I dont really want to recommend this
- method.
+ Appends extra details to the infraction.
+
+ This also allows for editing of contents such as the reason though I dont really want to recommend this method.
"""
self._extra[title] = description
def to_dict(self):
"""
- Serialize this instance into a dict for storage
+ Serialize this instance into a dict for storage.
- Always serialize the default info: author_id, author_name, datetime,
- reason, and infraction_level.
+ Always serialize the default info: author_id, author_name, datetime, reason, and infraction_level.
Always append any extra info left over in the original data.
If a duration value is present, include it into the data.
@@ -151,12 +185,10 @@ def to_dict(self):
class InfractionList:
"""
- Represents a list of infractions for a user
+ Represents a list of infractions for a user.
"""
- def __init__(
- self, user: discord.User, data: typing.Optional[typing.Dict] = None
- ) -> None:
+ def __init__(self, user: discord.User | discord.Member, data: Optional[Dict] = None) -> None:
if data is None:
data = {}
@@ -168,28 +200,17 @@ def __init__(
self._banned_patreon = data.pop("banned_patreon", False)
self._final_warn = data.pop("final_warn", False)
- self._warns = [
- Infraction(InfractionKind.WARN, warn_data)
- for warn_data in data.pop("warn", [])
- ]
- self._mutes = [
- Infraction(InfractionKind.MUTE, mute_data)
- for mute_data in data.pop("mute", [])
- ]
- self._kicks = [
- Infraction(InfractionKind.KICK, kick_data)
- for kick_data in data.pop("kick", [])
- ]
- self._bans = [
- Infraction(InfractionKind.BAN, ban_data) for ban_data in data.pop("ban", [])
- ]
+ self._warns = [Infraction(InfractionKind.WARN, warn_data) for warn_data in data.pop("warn", [])]
+ self._mutes = [Infraction(InfractionKind.MUTE, mute_data) for mute_data in data.pop("mute", [])]
+ self._kicks = [Infraction(InfractionKind.KICK, kick_data) for kick_data in data.pop("kick", [])]
+ self._bans = [Infraction(InfractionKind.BAN, ban_data) for ban_data in data.pop("ban", [])]
@classmethod
- def from_user(cls, user: discord.User):
+ def from_user(cls, user: discord.User | discord.Member):
"""
- This searches the mongo db for an entry of a user. If no entry is found,
- none is returned and due to the behavior of the class, info is filled
- out accordingly.
+ This searches the mongo db for an entry of a user.
+
+ If no entry is found, none is returned and due to the behavior of the class, info is filled out accordingly.
The user is linked to this instance and it can be updated to the
database whenever.
@@ -202,17 +223,18 @@ def from_user(cls, user: discord.User):
@classmethod
def new_user_infraction(
cls,
- user: discord.User,
+ user: discord.User | discord.Member,
kind: InfractionKind,
level: int,
- author: discord.User,
+ author: discord.User | discord.Member,
reason: str,
duration=None,
final=False,
):
"""
- A shorthand for running InteractionList.from_user(user), adding a new
- infraction and calling update.
+ A shorthand for running InfractionList.from_user(user) and new_infraction().
+
+ Adds a new infraction and calls an update.
"""
user_infractions = cls.from_user(user)
@@ -231,14 +253,16 @@ def new_user_infraction(
@property
def on_final(self) -> bool:
- """A value indicating whether the user is on final warn or not"""
+ """
+ A value indicating whether the user is on final warn or not.
+ """
return self._final_warn
@property
def banned_patreon(self) -> bool:
"""
- A property detailing if the user is banned through unenrol
+ A property detailing if the user is banned through unenrol.
"""
return self._banned_patreon
@@ -246,12 +270,12 @@ def banned_patreon(self) -> bool:
@banned_patreon.setter
def banned_patreon(self, value: bool):
"""
- Updates the property detailing if the user is banned through unenrol
+ Updates the property detailing if the user is banned through unenrol.
"""
self._banned_patreon = value
- def _kind_to_list(self, kind: InfractionKind) -> typing.List[Infraction]:
+ def _kind_to_list(self, kind: InfractionKind) -> List[Infraction]:
"""
Returns the list of infractions corresponding to the given kind.
@@ -269,15 +293,12 @@ def _kind_to_list(self, kind: InfractionKind) -> typing.List[Infraction]:
def summary(self) -> str:
"""
- Returns a summary of the users infractions. This is the blurb of text
- shown on a user's infraction embed which contains the total infraction
- count, if the user is on final warning or not, and the quick summary of
- the layout of infraction levels.
+ Returns a summary of the users infractions.
+
+ This is the blurb of text shown on a user's infraction embed which contains the total infraction count, if the user is on final warning or not, and the quick summary of the layout of infraction levels.
"""
- final_warn = (
- "" if self._final_warn is not True else "USER IS ON FINAL WARNING\n"
- )
+ final_warn = "" if self._final_warn is not True else "USER IS ON FINAL WARNING\n"
# count infraction levels
# legacies are handled by the infraction.level property
@@ -309,7 +330,7 @@ def summary(self) -> str:
valuelist.append(f"{legacy}x{dectoroman['legacy']}")
for key in sorted(inflevels):
- valuelist.append(f"{inflevels[key]}x{dectoroman[key]}")
+ valuelist.append(f"{inflevels[key]}x{dectoroman[key]}") # type: ignore
return f"Total Infractions: {inf_sum}\n{final_warn}{', '.join(valuelist)}"
@@ -317,7 +338,7 @@ def new_infraction(
self,
kind: InfractionKind,
level: int,
- author: discord.User,
+ author: discord.User | discord.Member,
reason: str,
duration=None,
final=False,
@@ -343,10 +364,7 @@ def new_infraction(
final,
)
- def detail_infraction(
- self, kind: InfractionKind, id: int, title: str, description: str
- ) -> bool:
-
+ def detail_infraction(self, kind: InfractionKind, id: int, title: str, description: str) -> bool:
"""
Allows the local editing of extra info on the infraction.
@@ -364,12 +382,11 @@ def detail_infraction(
return True
def delete_infraction(self, kind: InfractionKind, id: int) -> bool:
-
"""
Deletes an infraction from the list.
Returns the success status as a bool in case an out of range index is
- provided
+ provided.
"""
try:
@@ -384,13 +401,22 @@ def delete_infraction(self, kind: InfractionKind, id: int) -> bool:
def get_infractions_of_kind(self, kind: InfractionKind) -> discord.Embed:
"""
- Returns a discord.Embed with a list and information of infractions of
- the given kind
+ Returns a discord.Embed with a list and information of infractions of the given kind.
"""
-
# enumerate over infractions of kind requested to insert into the embed
infractions = self._kind_to_list(kind)
- infractions_info = [v.info_str(i) for i, v in enumerate(infractions)]
+ infractions_info = [[]]
+ idx = 0
+ curr_len = 0
+ for i, v in enumerate(infractions):
+ info_str = v.info_str(i)
+ if curr_len + len(info_str) > 1000:
+ infractions_info.append([])
+ idx += 1
+ curr_len = 0
+
+ curr_len += len(info_str)
+ infractions_info[idx].append(info_str)
embed = discord.Embed(
title="Infractions",
@@ -399,21 +425,20 @@ def get_infractions_of_kind(self, kind: InfractionKind) -> discord.Embed:
timestamp=discord.utils.utcnow(),
)
- if len(infractions_info) == 0:
- infractions_info.append("```\nNone\n```")
+ if len(infractions_info[0]) == 0:
+ infractions_info.append(["```\nNone\n```"])
- embed.add_field(
- name=self._user_id, value=f"```\n{self.summary()}\n```", inline=False
- )
- embed.add_field(name=kind.name.title() + "s", value="\n".join(infractions_info))
+ embed.add_field(name=f"{self._user_name} ({self._user_id})", value=f"```\n{self.summary()}\n```", inline=False)
+
+ for i, infr_info in enumerate(infractions_info):
+ embed.add_field(name=f"{kind.name.title()}s", value="\n".join(infr_info), inline=False)
return embed
- def get_infraction_info_str(
- self, kind: InfractionKind, id: int
- ) -> typing.Optional[str]:
+ def get_infraction_info_str(self, kind: InfractionKind, id: int) -> Optional[str]:
"""
Returns a string of infraction info for the infraction requested.
+
None is returned if the index is out of range
"""
@@ -423,9 +448,7 @@ def get_infraction_info_str(
except IndexError:
return None
- def get_detailed_infraction(
- self, kind: InfractionKind, id: int
- ) -> typing.Optional[discord.Embed]:
+ def get_detailed_infraction(self, kind: InfractionKind, id: int) -> Optional[discord.Embed]:
"""
Returns a discord.Embed with information about the requested infraction.
@@ -440,7 +463,7 @@ def get_detailed_infraction(
def to_dict(self):
"""
- Serialize the data into a dict for storage
+ Serialize the data into a dict for storage.
"""
data = {
@@ -458,11 +481,8 @@ def to_dict(self):
return data
def update(self):
-
"""
- syncs local updates to the database
-
- convert the data stored in the class into a dict
+ Converts the data stored in the class into a dict and updates the database.
"""
logger.debug("updating infraction info for %s", self._user_id)
diff --git a/birdbot.py b/birdbot.py
deleted file mode 100644
index f8666de9..00000000
--- a/birdbot.py
+++ /dev/null
@@ -1,258 +0,0 @@
-import logging
-import asyncio
-import os
-import discord
-import dotenv
-import certifi
-import traceback, io
-
-from contextlib import contextmanager, suppress
-from logging.handlers import TimedRotatingFileHandler
-from discord.ext import commands
-from rich.logging import RichHandler
-
-from discord import app_commands, Interaction
-from utils import app_errors
-
-logger = logging.getLogger("BirdBot")
-
-
-@contextmanager
-def setup():
- try:
- dotenv.load_dotenv()
- logging.getLogger("discord").setLevel(logging.INFO)
- logging.getLogger("discord.http").setLevel(logging.INFO)
-
- logger = logging.getLogger()
- logger.setLevel(logging.DEBUG)
- dtfmt = "%Y-%m-%d %H:%M:%S"
- if not os.path.isdir("logs/"):
- os.mkdir("logs/")
- handlers = [
- RichHandler(rich_tracebacks=True),
- TimedRotatingFileHandler(filename="logs/birdbot.log", when="d", interval=5),
- ]
- fmt = logging.Formatter(
- "[{asctime}] [{levelname:<7}] {name}: {message}", dtfmt, style="{"
- )
-
- for handler in handlers:
- if isinstance(handler, TimedRotatingFileHandler):
- handler.setFormatter(fmt)
- logger.addHandler(handler)
-
- yield
- finally:
- handlers = logger.handlers[:]
- for handler in handlers:
- handler.close()
- logger.removeHandler(handler)
-
-
-# TODO Move to separate file once util is organized
-# currently would throw a circular import error
-async def maybe_responded(interaction: Interaction, *args, **kwargs):
- """
- Either responds or sends a followup on an interaction response
- """
- if interaction.response.is_done():
- await interaction.followup.send(*args, **kwargs)
-
- return
-
- await interaction.response.send_message(*args, **kwargs)
-
-
-class BirdTree(app_commands.CommandTree):
- """
- Subclass of app_commands.CommandTree to define the behavior for the birdbot
- tree.
-
- Handles thrown errors within the tree and interactions between all commands
- """
-
- async def alert(self, interaction: Interaction, error: Exception):
- """
- Attempts to altert the discord channel logs of an exception
- """
-
- channel = await interaction.client.fetch_channel(865321589919055882)
-
- content = traceback.format_exc()
-
- file = discord.File(
- io.BytesIO(bytes(content, encoding="UTF-8")), filename=f"{type(error)}.py"
- )
-
- embed = discord.Embed(
- title="Unhandled Exception Alert",
- description=f"```\nContext: \nguild:{repr(interaction.guild)}\n{repr(interaction.channel)}\n{repr(interaction.user)}\n```", # f"```py\n{content[2000:].strip()}\n```"
- )
-
- await channel.send(embed=embed, file=file)
-
- async def on_error(
- self, interaction: Interaction, error: app_commands.AppCommandError
- ):
- """Handles errors thrown within the command tree"""
- if isinstance(error, app_commands.CheckFailure):
- # Inform user of failure ephemerally
-
- if isinstance(error, app_errors.InvalidAuthorizationError):
-
- msg = f"<:kgsNo:955703108565098496> {str(error)}"
- await maybe_responded(interaction, msg, ephemeral=True)
-
- return
-
- elif isinstance(error, app_errors.InvalidInvocationError):
- # inform user of invalid interaction input ephemerally
-
- msg = f"<:kgsNo:955703108565098496> {str(error)}"
- await maybe_responded(interaction, msg, ephemeral=True)
-
- return
-
- if isinstance(error, app_commands.errors.CommandOnCooldown):
- msg = f"<:kgsNo:955703108565098496> {str(error)}"
- await maybe_responded(interaction, msg, ephemeral=True)
-
- return
- # most cases this will consist of errors thrown by the actual code
- # TODO send in <#865321589919055882>
-
- msg = f"an internal error occurred. if this continues please inform an active bot dev"
- await maybe_responded(
- interaction,
- msg,
- ephemeral=interaction.channel.category_id
- != 414095379156434945, # is_public_channel(interaction.channel)
- )
-
- try:
- await self.alert(interaction, error)
- except Exception as e:
- await super().on_error(interaction, e)
-
-
-class BirdBot(commands.AutoShardedBot):
- """Main Bot"""
-
- currently_raided = False
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.get_database()
-
- @classmethod
- def from_parseargs(cls, args):
- """Create and return an instance of a Bot."""
- logger.info(args)
- allowed_mentions = discord.AllowedMentions(
- roles=False, everyone=False, users=True
- )
- loop = asyncio.get_event_loop()
- intents = discord.Intents(
- guilds=True,
- members=True,
- bans=True,
- emojis=True,
- webhooks=True,
- messages=True,
- reactions=True,
- message_content=True,
- presences=True,
- )
- max_messages = 1000
- if args.beta:
- prefix = "b!"
- owner_ids = {
- 389718094270038018, # FC
- 424843380342784011, # Oeav
- 248790213386567680, # Austin
- 183092910495891467, # Sloth
- 229779964898181120, # Source
- }
- activity = discord.Activity(
- type=discord.ActivityType.watching, name="for bugs"
- )
- elif args.alpha:
- prefix = "a!"
- owner_ids = {
- 389718094270038018, # FC
- 424843380342784011, # Oeav
- 248790213386567680, # Austin
- 183092910495891467, # Sloth
- 229779964898181120, # Source
- }
- activity = discord.Activity(
- type=discord.ActivityType.playing, name="imagine being a beta"
- )
- else:
- prefix = "!"
- owner_ids = {183092910495891467} # Sloth
- max_messages = 10000
- activity = discord.Activity(
- type=discord.ActivityType.listening, name="Steve's voice"
- )
- x = cls(
- loop=loop,
- max_messages=max_messages,
- command_prefix=commands.when_mentioned_or(prefix),
- owner_ids=owner_ids,
- activity=activity,
- case_insensitive=True,
- allowed_mentions=allowed_mentions,
- intents=intents,
- tree_cls=BirdTree,
- )
-
- x.get_database()
- return x
-
- @classmethod
- def get_database(cls):
- """Return MongoClient instance to self.db"""
- from pymongo import MongoClient
-
- db_key = os.environ.get("DB_KEY")
- if db_key is None:
- logger.critical("NO DB KEY FOUND, USING LOCAL DB INSTEAD")
- client = MongoClient(db_key, tlsCAFile=certifi.where())
- db = client.KurzBot
- logger.info("Connected to mongoDB")
- cls.db = db
-
- async def load_extensions(self, args):
- """Loads all cogs from cogs/ without the '_' prefix"""
- for filename in os.listdir("cogs/"):
- if not (
- filename[:-3] in ("antiraid", "automod", "giveaway")
- and (args.beta or args.alpha)
- ):
- if not filename.startswith("_"):
- logger.info(f"loading {f'cogs.{filename[:-3]}'}")
- try:
- await self.load_extension(f"cogs.{filename[:-3]}")
- except Exception as e:
- logger.error(f"cogs.{filename[:-3]} cannot be loaded. [{e}]")
- logger.exception(f"Cannot load cog {f'cogs.{filename[:-3]}'}")
-
- async def close(self):
- """Close the Discord connection and the aiohttp sessions if any (future perhaps?)."""
- for ext in list(self.extensions):
- with suppress(Exception):
- await self.unload_extension(ext)
-
- for cog in list(self.cogs):
- with suppress(Exception):
- await self.remove_cog(cog)
-
- await super().close()
-
- async def on_ready(self):
- logger.info("Logged in as")
- logger.info(f"\tUser: {self.user.name}")
- logger.info(f"\tID : {self.user.id}")
- logger.info("------")
diff --git a/cogs/_quiz.py b/cogs/_quiz.py
deleted file mode 100644
index 9b35e6ea..00000000
--- a/cogs/_quiz.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import logging
-import numpy as np
-import os
-import asyncio
-from utils.helper import admin_and_above
-
-import pymongo
-from discord.ext import commands
-
-
-class Quiz(commands.Cog):
- db_qz_key = os.environ.get("DB_QZ_KEY")
- qclient = pymongo.MongoClient(db_qz_key)
- qdb = qclient.QZ
- quiz_db = qdb.AndrewQuiz
- id_and_tickets = []
-
- def __init__(self, bot):
- self.logger = logging.getLogger("Quiz")
- self.bot = bot
- self.id_and_tickets = list(
- self.quiz_db.find({"tickets": {"$ne": 0}}, {"id": 1, "tickets": 1})
- )
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("loaded Quiz")
-
- @commands.command()
- async def tickets(self, ctx):
- if ctx.channel.id != 414452106129571842:
- return
- try:
- user = self.quiz_db.find_one({"id": str(ctx.author.id)})
- if user:
- pipe = [{"$group": {"_id": None, "total": {"$sum": "$tickets"}}}]
- total_tickets = list(self.quiz_db.aggregate(pipeline=pipe))[0]["total"]
- percentage = round((user["tickets"] / total_tickets) * 100, 3)
-
- await ctx.send(
- f'You have {user["tickets"]} tickets of { total_tickets } tickets ( {percentage}% chance of winning ).'
- )
- else:
- await ctx.send(
- f"You have not participated. To participate please vist `https://quiz.birdbot.xyz`"
- )
-
- except Exception as e:
- logging.error(str(e))
-
- @admin_and_above()
- @commands.command(hidden=True)
- async def end(self, ctx, winners, *, prize):
- """end giveaway"""
- role_boosts = {
- 821961644425871390: 0.01,
- 637114722675851302: 0.02,
- 637114849570062347: 0.04,
- 637114873268142081: 0.06,
- 637114897544511488: 0.08,
- 637114917178048543: 0.10,
- 662937489220173884: 0.12,
- 698479120878665729: 0.15,
- }
-
- async def process_users():
- """remove users not in server and add ticket boost"""
- kgs_guild = self.bot.get_guild(414027124836532234)
-
- for i in self.id_and_tickets:
- user = kgs_guild.get_member(int(i["id"]))
- if user is None:
- self.id_and_tickets.remove(i)
- continue
-
- # check if user has any leveled roles
- to_boost = [
- role
- for role in list(role_boosts.keys())
- if role in [user_role.id for user_role in user.roles]
- ]
- if len(to_boost) != 0:
- self.logger.info(
- f"boosting {user.name} for having role {to_boost[0]} by {role_boosts[to_boost[0]]}"
- )
- i["tickets"] += round(i["tickets"] * role_boosts[to_boost[0]])
-
- await process_users()
-
- async with ctx.channel.typing():
-
- tickets = [x["tickets"] for x in self.id_and_tickets]
- total = sum(tickets)
- probability = []
-
- for i in tickets:
- probability.append(i / total)
-
- rng = np.random.default_rng()
- winner = rng.choice(
- self.id_and_tickets, size=int(winners), replace=False, p=probability
- )
- self.logger.info(winner)
- # await asyncio.sleep(4)
- await ctx.send(
- f'**And the winner for {prize} {"is" if int(winners) == 1 else "are "}...**'
- )
- await asyncio.sleep(3)
- for i in winner:
-
- await asyncio.sleep(3)
- await ctx.send(
- f"<@{i['id']}> - {i['tickets']} tickets ({round((i['tickets']/total)*100,3)}% chance of winning)"
- )
- self.id_and_tickets.remove(i)
-
-
-async def setup(bot):
- await bot.add_cog(Quiz(bot))
diff --git a/cogs/anon.py b/cogs/anon.py
deleted file mode 100644
index 10e17cbc..00000000
--- a/cogs/anon.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import logging
-import typing
-import numpy as np
-import os
-from utils import app_checks
-
-import pymongo
-import discord
-from discord.ext import commands
-from discord import ui, app_commands
-
-
-
-class VideoQuiz(commands.Cog):
-
-
- def __init__(self, bot) -> None:
- self.logger = logging.getLogger("Spot The Scene")
- self.bot = bot
- self.db_qz_key = os.environ.get("DB_QZ_KEY")
- self.qclient = pymongo.MongoClient(self.db_qz_key)
- self.qdb = self.qclient.QZ
- self.quiz_db = self.qdb.SpotScene
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("loaded Spot The Scene")
-
- @app_commands.command()
- @app_commands.guilds(414027124836532234)
- @app_checks.mod_and_above()
- async def add_q(self,
- interaction: discord.Interaction,
- image: discord.Attachment,
- answer: str,
- difficulty: typing.Literal["Easy","Medium","Hard"]
- ):
- """Add a question for the upcoming quiz
-
- Parameters
- ----------
- image: discord.Attachment
- The image which the user has to find the timetamp for
- answer: str
- The timetamp URL (eg: https://youtu.be/dQw4w9WgXcQ?t=29)
- difficulty: str
- How hard is this question? Must be "Easy", "Medium" or "Hard"
- """
-
- await interaction.response.defer(thinking=True)
- bot_testing = interaction.guild.get_channel(414179142020366336)
- f = await image.to_file()
- msg = await bot_testing.send(file=f)
- self.quiz_db.insert_one({"user_id":interaction.user.id,
- "url":msg.attachments[0].url,
- "answer":answer,
- "difficulty":difficulty})
- return await interaction.edit_original_response(content="Done!")
-
-async def setup(bot):
- await bot.add_cog(VideoQuiz(bot))
\ No newline at end of file
diff --git a/cogs/antiraid.py b/cogs/antiraid.py
deleted file mode 100644
index 127c3881..00000000
--- a/cogs/antiraid.py
+++ /dev/null
@@ -1,134 +0,0 @@
-import logging
-import datetime
-import typing
-import json
-
-import discord
-from discord.ext import commands
-from birdbot import BirdBot
-
-from utils.helper import mod_and_above, devs_only
-
-
-class Antiraid(commands.Cog):
- def __init__(self, bot):
- self.logger = logging.getLogger("Antiraid")
- self.bot = bot
- with open("config.json", "r") as config_file:
- config_json = json.loads(config_file.read())
- self.logging_channel = config_json["logging"]["logging_channel"]
- with open("antiraid.json", "r") as config_file:
- antiraid_json = json.loads(config_file.read())
- self.raidinfo = antiraid_json["raidmode"]
- self.newjoins = []
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("loaded Antiraid")
-
- @devs_only()
- @commands.command()
- async def raidmode(self, ctx, args=""):
- """
- Changes the anti-raidmode settings
- Usage: raidmode / or
- """
- if args == "off" or args == "on":
- if args == "on":
- self.raidinfo["active"] = True
- elif args == "off":
- self.raidinfo["active"] = False
- with open("antiraid.json", "w") as config_file:
- json.dump({"raidmode": self.raidinfo}, config_file, indent=4)
- await ctx.send(f"Raidmode is turned {args}.")
- return
-
- if args != "":
- try:
- joins = int(args.split("/")[0])
- during = int(args.split("/")[1])
- except:
- raise commands.BadArgument(
- message="Improper argument syntax. Example: 30/10 to trigger with 30 joins every 10 seconds."
- )
-
- if args == "":
- if self.raidinfo["active"] == False:
- activityinfo = "Raidmode is turned off."
- else:
- activityinfo = "Raidmode is turned on."
- await ctx.send(
- f'Raidmode will activate when there are {self.raidinfo["joins"]} joins every {self.raidinfo["during"]} seconds. {activityinfo}'
- )
- return
-
- if joins > 100:
- raise commands.BadArgument(message="Can't do more than 100 joins.")
-
- self.raidinfo["joins"] = joins
- self.raidinfo["during"] = during
- with open("antiraid.json", "w") as config_file:
- json.dump({"raidmode": self.raidinfo}, config_file, indent=4)
- await ctx.send(
- f"Set raidmode when there are {joins} joins every {during} seconds."
- )
-
- @commands.Cog.listener()
- async def on_member_join(self, member):
- if member.guild.id != 414027124836532234:
- return
- if member.bot:
- return
-
- self.newjoins.append({"id": member.id, "time": member.joined_at})
-
- if len(self.newjoins) >= 101:
- i = len(self.newjoins) - 100
- del self.newjoins[0:i]
-
- if self.raidinfo["active"] == False:
- return
-
- if len(self.newjoins) >= self.raidinfo["joins"]:
- index = -self.raidinfo["joins"]
- if member.joined_at - self.newjoins[index]["time"] < datetime.timedelta(
- seconds=self.raidinfo["during"]
- ):
- try:
- await member.send(
- "The Kurzgesagt - In a Nutshell server might currently be under a raid. You were kicked as a precaution, if you did not take part in the raid try joining again in an hour!"
- )
- except:
- pass
- try:
- await member.kick(reason="Raid counter")
- except:
- pass
-
- if not BirdBot.currently_raided:
- server = member.guild
- await server.get_channel(self.logging_channel).send(
- "Detected a raid."
- )
- firstbots = self.newjoins[-index:]
- for memberid in firstbots:
- member = await server.get_member(memberid["id"])
- if member.pending:
- try:
- await member.send(
- "The Kurzgesagt - In a Nutshell server might currently be under a raid. You were kicked as a precaution, if you did not take part in the raid try joining again in an hour!"
- )
- except:
- pass
- try:
- await member.kick(reason="Raid counter")
- except:
- pass
- BirdBot.currently_raided = True
- return
- else:
- BirdBot.currently_raided = False
-
-
-async def setup(bot):
- await bot.add_cog(Antiraid(bot))
diff --git a/cogs/banner.py b/cogs/banner.py
deleted file mode 100644
index f9b0c820..00000000
--- a/cogs/banner.py
+++ /dev/null
@@ -1,337 +0,0 @@
-import logging
-import json
-import typing
-import aiohttp
-
-import io
-
-import discord
-from discord.ext import commands, tasks
-from discord import app_commands
-
-from utils import app_checks
-from utils.helper import (
- calc_time,
- get_time_string,
-)
-
-
-class Banner(commands.Cog):
- def __init__(self, bot):
- self.logger = logging.getLogger("Banners")
- self.bot = bot
-
- with open("config.json", "r") as config_file:
- config_json = json.loads(config_file.read())
-
- self.mod_role = config_json["roles"]["mod_role"]
- self.automated_channel = config_json["logging"]["automated_channel"]
-
- self.index = 0
- self.banner_db = self.bot.db.Banners
-
- def cog_load(self) -> None:
- self.banners = self.banner_db.find_one({"name": "banners"})["banners"]
-
- def cog_unload(self):
- self.timed_banner_rotation.cancel()
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("loaded Banners")
-
- banner_commands = app_commands.Group(
- name="banner",
- description="Guild banner commands",
- guild_ids=[414027124836532234],
- default_permissions=discord.permissions.Permissions(manage_messages=True),
- )
-
- async def verify_url(self, url: str, byte: bool = False):
- """
- returns url after verifyng size and content_type
- returns bytes object if byte is set to True
- """
- try:
- async with aiohttp.ClientSession() as session:
- async with session.get(url) as response:
- if response.content_type.startswith("image"):
- banner = await response.content.read()
-
- if len(banner) / 1024 < 10240:
- if byte:
- return banner
- return url
- raise commands.BadArgument(
- message=f"Image must be less than 10240kb, yours is {int(len(banner)/1024)}kb."
- )
-
- raise commands.BadArgument(
- message=f"Link must be for an image file not {response.content_type}."
- )
-
- except aiohttp.InvalidURL:
- raise commands.BadArgument(
- message="You must provide a link or an attachment."
- )
-
- @banner_commands.command()
- @app_checks.mod_and_above()
- async def add(
- self,
- interaction: discord.Interaction,
- image: typing.Optional[discord.Attachment] = None,
- url: typing.Optional[str] = None,
- ):
- """Add or upload a banner
-
- Parameters
- ----------
- image: discord.Attachment
- An image file
- url: str
- URL or Link of an image
- """
-
- await interaction.response.defer(ephemeral=True)
-
- if image:
-
- try:
- fp = await self.verify_url(url=image.url, byte=True)
- except commands.BadArgument as ba:
- return await interaction.edit_original_response(content=str(ba))
- except Exception as e:
- raise e
-
- if fp:
- banners_channel = interaction.guild.get_channel(self.automated_channel)
- msg = await banners_channel.send(
- file=discord.File(io.BytesIO(fp), filename=image.filename)
- )
-
- url = msg.attachments[0].url
-
- elif url:
- try:
- url = await self.verify_url(url=url)
- except commands.BadArgument as ba:
- return await interaction.edit_original_response(content=str(ba))
- except Exception as e:
- raise e
-
- else:
- return await interaction.edit_original_response(
- content="Required any one of the parameters."
- )
-
- self.banners.append(url)
-
- self.banner_db.update_one(
- {"name": "banners"}, {"$set": {"banners": self.banners}}
- )
- await interaction.edit_original_response(content="Banner added successfully.")
-
- @banner_commands.command()
- @app_checks.mod_and_above()
- async def rotate(
- self,
- interaction: discord.Interaction,
- duration: typing.Optional[str] = None,
- stop: typing.Optional[bool] = False,
- ):
- """Change server banner rotation duration or stop the rotation
-
- Parameters
- ----------
- duration: str
- Time (example: 3hr or 1d)
- stop: bool
- Weather to stop banner rotation
- """
-
- if not stop and not duration:
- return await interaction.response.send_message(
- "Please provide value for atleast one argument.", ephemeral=True
- )
-
- if stop:
- self.timed_banner_rotation.cancel()
- return await interaction.response.send_message(
- "Banner rotation stopped.", ephemeral=True
- )
-
- time, extra = calc_time([duration, ""])
- if time == 0:
- return await interaction.response.send_message(
- "Wrong time syntax.", ephemeral=True
- )
-
- if not self.timed_banner_rotation.is_running():
- self.timed_banner_rotation.start()
-
- self.timed_banner_rotation.change_interval(seconds=time)
- await interaction.response.send_message(
- f"Banners are rotating every {get_time_string(time)}.", ephemeral=True
- )
-
- # Making this standalone command cause can not override default permissions, and we need only this command to be visible to users.
- @app_commands.command()
- @app_commands.default_permissions(send_messages=True)
- @app_commands.guilds(414027124836532234)
- @app_commands.checks.cooldown(1, 60, key=lambda i: (i.guild_id, i.user.id))
- async def banner_suggest(
- self,
- interaction: discord.Interaction,
- image: typing.Optional[discord.Attachment] = None,
- url: typing.Optional[str] = None,
- ):
- """Suggest an image from kurzgesagt for server banner
-
- Parameters
- ----------
- image: discord.Attachment
- An image file
- url: str
- URL or Link of an image
- """
- await interaction.response.defer(ephemeral=True)
- automated_channel = interaction.guild.get_channel(self.automated_channel)
-
- if image:
- try:
- url = await self.verify_url(url=image.url, byte=True)
- except commands.BadArgument as ba:
- return await interaction.edit_original_response(content=str(ba))
- except Exception as e:
- raise e
-
- elif url:
- try:
- url = await self.verify_url(url=url, byte=True)
- except commands.BadArgument as ba:
- return await interaction.edit_original_response(content=str(ba))
- except Exception as e:
- raise e
-
- else:
- return await interaction.edit_original_response(
- content="Required any one of the parameters."
- )
-
- file = discord.File(io.BytesIO(url), filename="banner.png")
-
- embed = discord.Embed(color=0xC8A2C8)
- embed.set_author(
- name=interaction.user.name + "#" + interaction.user.discriminator,
- icon_url=interaction.user.display_avatar.url,
- )
- embed.set_image(url="attachment://banner.png")
- embed.set_footer(text="banner")
- message = await automated_channel.send(embed=embed, file=file)
- await message.add_reaction("<:kgsYes:955703069516128307>")
- await message.add_reaction("<:kgsNo:955703108565098496>")
-
- await interaction.edit_original_response(content="Banner suggested.")
-
- @banner_commands.command()
- @app_checks.mod_and_above()
- @app_commands.checks.cooldown(1, 30, key=lambda i: (i.guild_id, i.user.id))
- async def change(
- self,
- interaction: discord.Interaction,
- image: typing.Optional[discord.Attachment] = None,
- url: typing.Optional[str] = None,
- ):
- """Change server banner
-
- Parameters
- ----------
- image: discord.Attachment
- An image file
- url: str
- URL or Link of an image
- """
- if url is None:
- if image:
- url = image.url
- else:
- return await interaction.response.send_message(
- "You must provide a url or attachment.", ephemeral=True
- )
-
- try:
- banner = await self.verify_url(url, byte=True)
- except commands.BadArgument as ba:
- return await interaction.response.send_message(str(ba), ephemeral=True)
- except Exception as e:
- raise e
-
- await interaction.guild.edit(banner=banner)
-
- await interaction.response.send_message(
- "Server banner changed!", ephemeral=True
- )
-
- @tasks.loop()
- async def timed_banner_rotation(self):
- """
- Task that rotates the banner
- """
- guild = discord.utils.get(self.bot.guilds, id=414027124836532234)
- if self.index >= len(self.banners):
- self.index = 0
- async with aiohttp.ClientSession() as session:
- async with session.get(self.banners[self.index]) as response:
- banner = await response.content.read()
- await guild.edit(banner=banner)
- self.index += 1
-
- @commands.Cog.listener()
- async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
- """
- Check if reaction added is by mod+ and approve/deny banner accordingly
- """
- if payload.channel_id == self.automated_channel and not payload.member.bot:
- guild = discord.utils.get(self.bot.guilds, id=414027124836532234)
- mod_role = guild.get_role(self.mod_role)
-
- if payload.member.top_role >= mod_role:
- message = await self.bot.get_channel(payload.channel_id).fetch_message(
- payload.message_id
- )
-
- if message.embeds and message.embeds[0].footer.text == "banner":
- if payload.emoji.id == 955703069516128307: # kgsYes emote
- url = message.embeds[0].image.url
- author = message.embeds[0].author
- embed = discord.Embed(colour=discord.Colour.green())
- embed.set_image(url=url)
- embed.set_author(name=author.name, icon_url=author.icon_url)
-
- image = await self.verify_url(url, byte=True)
- channel = self.bot.get_channel(546689491486769163)
- file = discord.File(io.BytesIO(image), filename="banner.png")
- banner = await channel.send(file=file)
- url = banner.attachments[0].url
- self.banners.append(url)
- self.banner_db.update_one(
- {"name": "banners"}, {"$set": {"banners": self.banners}}
- )
-
- await message.edit(embed=embed, delete_after=6)
- member = guild.get_member_named(author.name)
- try:
- await member.send(
- f"Your banner suggestion was accepted {url}"
- )
- except discord.Forbidden:
- pass
-
- elif payload.emoji.id == 955703108565098496: # kgsNo emoji
- embed = discord.Embed(title="Banner suggestion removed!")
- await message.edit(embed=embed, delete_after=6)
-
-
-async def setup(bot):
- await bot.add_cog(Banner(bot))
diff --git a/cogs/global_listeners.py b/cogs/global_listeners.py
deleted file mode 100644
index 35ee5b93..00000000
--- a/cogs/global_listeners.py
+++ /dev/null
@@ -1,666 +0,0 @@
-import io
-import asyncio
-import requests
-from requests.models import PreparedRequest
-import json
-import aiohttp
-import logging
-import random
-import re
-
-from traceback import TracebackException
-
-import discord
-from discord.ext import commands
-from discord.ext.commands import errors
-from discord import app_commands
-
-from birdbot import BirdBot
-
-from utils import app_checks
-from utils.infraction import InfractionList
-from utils.helper import (
- NoAuthorityError,
- DevBotOnly,
- WrongChannel,
- is_internal_command,
-)
-
-
-async def translate_bannsystem(message: discord.Message):
- """Translate incoming bannsystem reports"""
- if not (
- message.channel.id == 1009138597221372044 # bannsystem channel
- and message.author.id == 697374082509045800 # bannsystem bot
- ):
- return
-
- embed = message.embeds[0].to_dict()
- to_translate = sum(
- [[embed["description"]], [field["value"] for field in embed["fields"]]], []
- ) # flatten without numpy
-
- embed["fields"][0]["name"] = "Reason"
- embed["fields"][1]["name"] = "Proof"
-
- url = "https://translate.birdbot.xyz/translate?"
-
- # TODO add keys in the future
- payload = {
- "q": " ### ".join(to_translate),
- "target": "en",
- "source": "de",
- "format": "text",
- }
-
- req = PreparedRequest()
- req.prepare_url(url, payload)
- response = requests.request("POST", req.url, verify=False).json()
- replace_str = response["translatedText"].split(" ### ")
- embed["description"] = replace_str[0]
- embed["fields"][0]["value"] = replace_str[1]
- embed["fields"][1]["value"] = replace_str[2]
- to_send = discord.Embed.from_dict(embed)
-
- translated_msg = await message.channel.send(embed=to_send)
-
- await translated_msg.add_reaction("<:kgsYes:955703069516128307>")
- await translated_msg.add_reaction("<:kgsNo:955703108565098496>")
- await message.delete()
-
-
-# janky fix for server memories, will make permanent once out of experimentation
-async def check_server_memories(message):
-
- if message.channel.id == 960927545639972994: # server memories // media only
- if any(
- _id in [role.id for role in message.author.roles]
- for _id in [414092550031278091, 414029841101225985]
- ): # mod or admin
- return
- if message.author.bot:
- return
- if len(message.attachments) == 0 and len(message.embeds) == 0:
- await message.delete()
- await message.channel.send(
- f"{message.author.mention} You can only send screenshots in this channel. ",
- delete_after=5,
- )
- return
- else:
- for e in message.embeds:
- if e.type != "image":
- await message.delete()
- await message.channel.send(
- f"{message.author.mention} You can only send screenshots in this channel. ",
- delete_after=5,
- )
- return
-
-
-class GuildLogger(commands.Cog):
- """Log events neccesary for moderation"""
-
- def __init__(self, bot):
- self.logger = logging.getLogger("Guild Logs")
- self.bot = bot
-
- with open("config.json", "r") as config_file:
- self.config_json = json.loads(config_file.read())
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("Loaded Guild Event Logging")
-
- @commands.Cog.listener()
- async def on_message_edit(self, before, after):
-
- # mainbot only
- if self.bot.user.id != 471705718957801483:
- return
-
- if before.guild.id != 414027124836532234: # kgs guild id
- return
- if before.author.bot:
- return
-
- await check_server_memories(after)
- if (
- before.channel.category.id == 414095379156434945
- or before.channel.category.id == 879399341561892905
- ):
- # mod category and logging category gets ignored
- return
- if before.content == after.content:
- return
-
- embed = discord.Embed(
- title="Message Edited",
- description=f"Message edited in {before.channel.mention}",
- color=0xEE7600,
- timestamp=discord.utils.utcnow(),
- )
- embed.set_author(
- name=before.author.display_name, icon_url=before.author.display_avatar.url
- )
- embed.add_field(name="Before", value=before.content, inline=False)
- embed.add_field(name="After", value=after.content, inline=False)
- search_terms = f"""
- ```Edited in {before.channel.id}\nEdited by {before.author.id}\nMessage edited in {before.channel.id} by {before.author.id}```
- """
-
- embed.add_field(name="Search terms", value=search_terms, inline=False)
- embed.set_footer(
- text="Input the search terms in your discord search bar to easily sort through specific logs"
- )
-
- message_logging_channel = self.bot.get_channel(
- self.config_json["logging"]["message_logging_channel"]
- )
- await message_logging_channel.send(embed=embed)
-
- @commands.Cog.listener()
- async def on_message_delete(self, message):
-
- # mainbot only
- if self.bot.user.id != 471705718957801483:
- return
-
- if message.guild.id != 414027124836532234: # kgs guild id
- return
- if message.author.bot:
- return
- if message.channel.category.id == 414095379156434945: # mod category
- return
-
- if is_internal_command(self.bot, message):
- return
-
- embed = discord.Embed(
- title="Message Deleted",
- description=f"Message deleted in {message.channel.mention}",
- color=0xC9322C,
- timestamp=discord.utils.utcnow(),
- )
- embed.set_author(
- name=message.author.display_name, icon_url=message.author.display_avatar.url
- )
- embed.add_field(name="Content", value=message.content)
- search_terms = f"```Deleted in {message.channel.id}"
-
- latest_logged_delete = [
- log
- async for log in message.guild.audit_logs(
- limit=1, action=discord.AuditLogAction.message_delete
- )
- ][0]
-
- self_deleted = False
- if message.author == latest_logged_delete.target:
- embed.description += f"\nDeleted by {latest_logged_delete.user.mention} {latest_logged_delete.user.name}"
- search_terms += f"\nDeleted by {latest_logged_delete.user.id}"
- else:
- self_deleted = True
- search_terms += f"\nDeleted by {message.author.id}"
- embed.description += (
- f"\nDeleted by {message.author.mention} {message.author.name}"
- )
-
- search_terms += f"\nSent by {message.author.id}"
- search_terms += f"\nMessage from {message.author.id} deleted by {message.author.id if self_deleted else latest_logged_delete.user.id} in {message.channel.id}```"
-
- embed.add_field(name="Search terms", value=search_terms, inline=False)
- embed.set_footer(
- text="Input the search terms in your discord search bar to easily sort through specific logs"
- )
-
- message_logging_channel = self.bot.get_channel(
- self.config_json["logging"]["message_logging_channel"]
- )
- await message_logging_channel.send(embed=embed)
-
- @commands.Cog.listener()
- async def on_member_join(self, member):
-
- # mainbot only
-
- if self.bot.user.id != 471705718957801483:
- return
-
- embed = discord.Embed(
- title="Member joined",
- description=f"{member.name}#{member.discriminator} ({member.id}) {member.mention}",
- color=0x45E65A,
- timestamp=discord.utils.utcnow(),
- )
- embed.set_author(name=member.name, icon_url=member.display_avatar.url)
-
- embed.add_field(
- name="Account Created",
- value=f"",
- inline=True,
- )
-
- embed.add_field(
- name="Search terms", value=f"```{member.id} joined```", inline=False
- )
- embed.set_footer(
- text="Input the search terms in your discord search bar to easily sort through specific logs"
- )
-
- member_logging_channel = self.bot.get_channel(
- self.config_json["logging"]["member_logging_channel"]
- )
- await member_logging_channel.send(embed=embed)
-
- @commands.Cog.listener()
- async def on_member_remove(self, member):
-
- # mainbot only
- if self.bot.user.id != 471705718957801483:
- return
-
- embed = discord.Embed(
- title="Member Left",
- description=f"{member.name}#{member.discriminator} ({member.id})",
- color=0xFF0004,
- timestamp=discord.utils.utcnow(),
- )
- embed.set_author(name=member.name, icon_url=member.display_avatar.url)
-
- embed.add_field(
- name="Account Created",
- value=f"",
- inline=True,
- )
- embed.add_field(
- name="Joined Server",
- value=f"",
- inline=True,
- )
- embed.add_field(
- name="Roles",
- value=f"{' '.join([role.mention for role in member.roles])}",
- inline=False,
- )
-
- embed.add_field(
- name="Search terms", value=f"```{member.id} left```", inline=False
- )
- embed.set_footer(
- text="Input the search terms in your discord search bar to easily sort through specific logs"
- )
-
- member_logging_channel = self.bot.get_channel(
- self.config_json["logging"]["member_logging_channel"]
- )
- await member_logging_channel.send(embed=embed)
-
- @commands.Cog.listener()
- async def on_member_update(self, before, after):
-
- # mainbot only
- if self.bot.user.id != 471705718957801483:
- return
-
- if before.nick == after.nick:
- return
-
- embed = discord.Embed(
- title="Nickname changed",
- description=f"{before.name}#{before.discriminator} ({before.id})",
- color=0xFF6633,
- timestamp=discord.utils.utcnow(),
- )
- embed.set_author(name=before.name, icon_url=before.display_avatar.url)
- embed.add_field(name="Previous Nickname", value=f"{before.nick}", inline=True)
- embed.add_field(name="Current Nickname", value=f"{after.nick}", inline=True)
-
- embed.add_field(
- name="Search terms",
- value=f"```{before.id} changed nickname```",
- inline=False,
- )
- embed.set_footer(
- text="Input the search terms in your discord search bar to easily sort through specific logs"
- )
-
- member_logging_channel = self.bot.get_channel(
- self.config_json["logging"]["member_logging_channel"]
- )
- await member_logging_channel.send(embed=embed)
-
-
-class GuildChores(commands.Cog):
- """Chores that need to performed during guild events"""
-
- def __init__(self, bot):
- self.logger = logging.getLogger("Guild Chores")
- self.bot = bot
-
- with open("config.json", "r") as config_file:
- self.config_json = json.loads(config_file.read())
-
- self.mod_role = self.config_json["roles"]["mod_role"]
- self.admin_role = self.config_json["roles"]["admin_role"]
- self.patreon_roles = [
- self.config_json["roles"]["patreon_blue_role"],
- self.config_json["roles"]["patreon_green_role"],
- self.config_json["roles"]["patreon_orange_role"],
- ]
- self.pfp_list = [
- "https://cdn.discordapp.com/emojis/909047588160942140.png?size=256",
- "https://cdn.discordapp.com/emojis/909047567030059038.png?size=256",
- "https://cdn.discordapp.com/emojis/909046980599250964.png?size=256",
- "https://cdn.discordapp.com/emojis/909047000253734922.png?size=256",
- ]
- self.greeting_webhook_url = "https://discord.com/api/webhooks/909052135864410172/5Fky0bSJMC3vh3Pz69nYc2PfEV3W2IAwAsSFinBFuUXXzDc08X5dv085XlLDGz3MmQvt"
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("Loaded Guild Chores")
-
- @commands.Cog.listener()
- async def on_message(self, message):
- """Remind mods to use correct prefix, alert mod pings etc"""
-
- if isinstance(message.channel, discord.DMChannel):
- return
-
- await check_server_memories(message)
- await translate_bannsystem(message)
- if any(
- x in message.raw_role_mentions
- for x in [414092550031278091, 905510680763969536]
- ):
- # if message.channel.category.id in [414095379156434945, 738847867266924545]: # mod category and mod bulletin
- # return
-
- if message.author.top_role >= await message.guild.fetch_role(
- 414092550031278091
- ):
- # dont react to mod ping
- return
-
- role_names = [
- discord.utils.get(message.guild.roles, id=role).name
- for role in message.raw_role_mentions
- ]
- mod_channel = self.bot.get_channel(1092578562608988290)
- # mod_channel = self.bot.get_channel(414179142020366336)
-
- embed = discord.Embed(
- title="Mod ping alert!",
- description=f"{' and '.join(role_names)} got pinged in {message.channel.mention} - [view message]({message.jump_url})",
- color=0x00FF00,
- )
- embed.set_author(
- name=message.author.display_name,
- icon_url=message.author.display_avatar.url,
- )
- embed.set_footer(
- text="Last 50 messages in the channel are attached for reference"
- )
-
- to_file = ""
- async for msg in message.channel.history(limit=50):
- to_file += f"{msg.author.display_name}: {msg.content}\n"
-
- await mod_channel.send(
- embed=embed,
- file=discord.File(io.BytesIO(to_file.encode()), filename="history.txt"),
- )
-
- if not message.author.bot:
-
- guild = discord.utils.get(self.bot.guilds, id=414027124836532234)
- mod_role = discord.utils.get(guild.roles, id=self.mod_role)
- admin_role = discord.utils.get(guild.roles, id=self.admin_role)
-
- if not (
- (mod_role in message.author.roles)
- or (admin_role in message.author.roles)
- ):
- return
- if re.match("^-(kick|ban|mute|warn)", message.content):
- await message.channel.send(f"ahem.. {message.author.mention}")
-
- @commands.Cog.listener()
- async def on_member_update(self, before, after):
- """Grant roles upon passing membership screening"""
-
- if before.pending and (not after.pending):
- guild = discord.utils.get(self.bot.guilds, id=414027124836532234)
- await after.add_roles(
- guild.get_role(542343829785804811), # Verified
- guild.get_role(901136119863844864), # English
- reason="Membership screening passed",
- )
-
- @commands.Cog.listener()
- async def on_member_join(self, member):
- """Listen for new patrons and provide
- them the option to unenroll from autojoining
- Listen for new members and fire webhook for greeting"""
-
- # mainbot only
- if self.bot.user.id != 471705718957801483:
- return
-
- # temp fix to remove clonex bots and Apàche guy
- if "clonex" in str(member.name).lower() or "apà" in str(member.name).lower():
- guild = discord.utils.get(self.bot.guilds, id=414027124836532234)
- await guild.kick(member)
- return
-
- diff_roles = [role.id for role in member.roles]
- if any(x in diff_roles for x in self.patreon_roles):
-
- guild = discord.utils.get(self.bot.guilds, id=414027124836532234)
- await member.add_roles(
- guild.get_role(542343829785804811), # Verified
- guild.get_role(901136119863844864), # English
- reason="Patron auto join",
- )
-
- try:
- embed = discord.Embed(
- title="Hey there patron! Annoyed about auto-joining the server?",
- description="Unfortunately Patreon doesn't natively support a way to disable this- "
- "but you have the choice of getting volutarily banned from the server "
- "therby preventing your account from rejoining. To do so simply type ```!unenrol```"
- "If you change your mind in the future just fill out [this form!](https://forms.gle/m4KPj2Szk1FKGE6F8)",
- color=0xFFFFFF,
- )
- embed.set_thumbnail(
- url="https://cdn.discordapp.com/emojis/824253681443536896.png?size=256"
- )
-
- await member.send(embed=embed)
- except discord.Forbidden:
- return
- else:
- if BirdBot.currently_raided:
- return
- async with aiohttp.ClientSession() as session:
- hook = discord.Webhook.from_url(
- self.greeting_webhook_url,
- session=session,
- )
- await hook.send(
- f"Welcome hatchling {member.mention}!\n"
- "Make sure to read the <#414268041787080708> and say hello to our <@&584461501109108738>s",
- avatar_url=random.choice(self.pfp_list),
- allowed_mentions=discord.AllowedMentions(users=True, roles=True),
- )
-
- # TODO: Move to slash
- @commands.command()
- async def translate(self, ctx, msg_id):
- msg = await ctx.channel.fetch_message(msg_id)
- embed = await translate_bannsystem(msg)
- # await ctx.send(embed=embed)
-
- @app_commands.command()
- @app_checks.patreon_only()
- @app_commands.checks.cooldown(1, 300, key=lambda i: (i.user.id))
- async def unenrol(self, interaction: discord.Interaction):
- """Unenrol from Patron auto join"""
-
- embed = discord.Embed(
- title="We're sorry to see you go",
- description="Are you sure you want to get banned from the server?"
- "If you change your mind in the future you can simply fill out [this form.](https://forms.gle/m4KPj2Szk1FKGE6F8)",
- color=0xFFCB00,
- )
- embed.set_thumbnail(
- url="https://cdn.discordapp.com/emojis/736621027093774467.png?size=96"
- )
-
- def check(reaction, user):
- return user == interaction.user
-
- fallback_embed = discord.Embed(
- title="Action Cancelled",
- description="Phew, That was close.",
- color=0x00FFA9,
- )
-
- try:
- confirm_msg = await interaction.user.send(embed=embed)
- await interaction.response.send_message("Please check your DMs.")
- await confirm_msg.add_reaction("<:kgsYes:955703069516128307>")
- await confirm_msg.add_reaction("<:kgsNo:955703108565098496>")
- reaction, user = await self.bot.wait_for(
- "reaction_add", timeout=120, check=check
- )
-
- if reaction.emoji.id == 955703069516128307:
-
- member = discord.utils.get(
- self.bot.guilds, id=414027124836532234
- ).get_member(interaction.user.id)
-
- user_infractions = InfractionList.from_user(member)
- user_infractions.banned_patreon = True
- user_infractions.update()
-
- await interaction.user.send(
- "Success! You've been banned from the server."
- )
- await member.ban(reason="Patron Voluntary Removal")
- return
- if reaction.emoji.id == 955703108565098496:
- await confirm_msg.edit(embed=fallback_embed)
- return
-
- except discord.Forbidden:
- await interaction.response.send_message(
- "I can't seem to DM you. please check your privacy settings and try again",
- ephemeral=True,
- )
-
- except asyncio.TimeoutError:
- await confirm_msg.edit(embed=fallback_embed)
-
-
-class Errors(commands.Cog):
- """Catches all exceptions coming in through commands"""
-
- def __init__(self, bot):
- with open("config.json", "r") as config_file:
- self.config_json = json.loads(config_file.read())
- self.dev_logging_channel = self.config_json["logging"]["dev_logging_channel"]
-
- self.logger = logging.getLogger("Listeners")
- self.bot = bot
-
- async def react_send_delete(
- self,
- ctx: commands.Context,
- reaction: str = None,
- message: str = None,
- delay: int = 6,
- ):
- """React to the command, send a message and delete later"""
- if reaction is not None:
- await ctx.message.add_reaction(reaction)
- if message is not None:
- await ctx.send(message, delete_after=delay)
- await ctx.message.delete(delay=delay)
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("loaded Error listener")
-
- @commands.Cog.listener()
- async def on_command_error(self, ctx: commands.Context, err):
-
- traceback_txt = "".join(TracebackException.from_exception(err).format())
- channel = await self.bot.fetch_channel(self.dev_logging_channel)
-
- if isinstance(
- err,
- (
- errors.MissingPermissions,
- NoAuthorityError,
- errors.NotOwner,
- errors.CheckAnyFailure,
- errors.CheckFailure,
- ),
- ):
- await self.react_send_delete(ctx, reaction="<:kgsNo:955703108565098496>")
-
- elif isinstance(err, DevBotOnly):
- await self.react_send_delete(
- ctx,
- message="This command can only be run on the main bot",
- reaction="<:kgsNo:955703108565098496>",
- )
-
- elif isinstance(err, commands.MissingRequiredArgument):
- await self.react_send_delete(
- ctx,
- message=f"You're missing the {err.param.name} argument. Please check syntax using the help command.",
- reaction="<:kgsNo:955703108565098496>",
- )
-
- elif isinstance(err, commands.CommandNotFound):
- pass
-
- elif isinstance(err, errors.CommandOnCooldown):
- await self.react_send_delete(ctx, reaction="\U000023f0", delay=4)
-
- elif isinstance(err, (WrongChannel, errors.BadArgument)):
- await self.react_send_delete(
- ctx,
- message=err,
- reaction="<:kgsNo:955703108565098496>",
- delay=4,
- )
-
- else:
- self.logger.exception(traceback_txt)
- await ctx.message.add_reaction("<:kgsStop:579824947959169024>")
- if self.bot.user.id != 471705718957801483:
- return
- await ctx.send(
- "Uh oh, an unhandled exception occured, if this issue persists please contact any of bot devs (Sloth, FC, Austin, Orav)."
- )
- description = (
- f"An [**unhandled exception**]({ctx.message.jump_url}) occured in <#{ctx.message.channel.id}> when "
- f"running the **{ctx.command.name}** command.```\n{err}```"
- )
- embed = discord.Embed(
- title="Unhandled Exception", description=description, color=0xFF0000
- )
- file = discord.File(
- io.BytesIO(traceback_txt.encode()), filename="traceback.txt"
- )
- await channel.send(embed=embed, file=file)
-
-
-async def setup(bot):
- await bot.add_cog(Errors(bot))
- await bot.add_cog(GuildChores(bot))
- await bot.add_cog(GuildLogger(bot))
diff --git a/cogs/misc.py b/cogs/misc.py
deleted file mode 100644
index beada499..00000000
--- a/cogs/misc.py
+++ /dev/null
@@ -1,342 +0,0 @@
-import logging
-import asyncio
-import json
-import re
-
-import demoji
-import pymongo
-
-# Do not import panda for VM.
-# import pandas as pd
-import discord
-from discord.ext import commands
-from discord import app_commands
-
-from utils import app_checks
-from utils.helper import role_and_above, bot_commands_only
-
-# Mongo schema to store intros
-# {
-# "_id": user_id,
-# "message_id": message_id,
-# "tz_text": timezone_text,
-# "bio": bio
-# }
-
-
-class Misc(commands.Cog):
- def __init__(self, bot):
- self.logger = logging.getLogger("Misc")
- self.bot = bot
- self.intro_db = self.bot.db.StaffIntros
- self.kgs_guild = None
- self.role_precendence = (
- 915629257470906369,
- 414029841101225985,
- 414092550031278091,
- 1058243220817063936,
- 681812574026727471,
- )
- with open("config.json", "r") as f:
- self.config = json.load(f)
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("Loaded Misc Cog")
-
- @commands.Cog.listener()
- async def on_member_update(self, before, after):
- if before.nick == after.nick:
- return
-
- self.kgs_guild = self.bot.get_guild(414027124836532234)
- subreddit_role = discord.utils.get(self.kgs_guild.roles, id=681812574026727471)
- if not after.top_role >= subreddit_role:
- return
-
- intro = self.intro_db.find_one({"_id": before.id})
- intro_channel = self.kgs_guild.get_channel(
- self.config["logging"]["intro_channel"]
- )
- msg = await intro_channel.fetch_message(intro["message_id"])
- embed = msg.embeds[0]
- embed.set_author(name=after.display_name, icon_url=after.avatar.url)
- await msg.edit(embed=embed)
-
- @commands.Cog.listener()
- async def on_user_update(self, before, after):
-
- self.kgs_guild = self.bot.get_guild(414027124836532234)
-
- member = self.kgs_guild.get_member(before.id)
- if not member:
- return
- subreddit_role = discord.utils.get(self.kgs_guild.roles, id=681812574026727471)
- if not member.top_role >= subreddit_role:
- return
-
- intro = self.intro_db.find_one({"_id": before.id})
- intro_channel = self.kgs_guild.get_channel(
- self.config["logging"]["intro_channel"]
- )
- msg = await intro_channel.fetch_message(intro["message_id"])
- embed = msg.embeds[0]
- embed.set_author(name=member.display_name, icon_url=after.avatar.url)
- await msg.edit(embed=embed)
-
- def parse_info(self, user_id, tz_text, bio, bird_icon):
- """Get all the neccesary info and return an introduction embed"""
- user = self.kgs_guild.get_member(user_id)
- description = f"**{tz_text}**\n\n" + bio
- footer_name = (
- "Kurzgesagt Official"
- if user.top_role.id == 915629257470906369
- else user.top_role.name
- )
- footer_icon = self.config["roleicons"][f"{user.top_role.id}"]
- embed = discord.Embed(description=description, color=user.top_role.color)
- embed.set_author(name=user.display_name, icon_url=user.avatar.url)
- embed.set_footer(text=footer_name, icon_url=footer_icon)
- embed.set_thumbnail(url=bird_icon)
- return embed
-
- async def reorder_intros(self, role, intro_channel) -> tuple:
- """Deletes intros that are before role_id and returns list of tuples of the form
- (mongodb.document, discord.Embed)"""
- embeds = []
- async for message in intro_channel.history():
- if not message.embeds:
- break
-
- if message.embeds[0].footer.text == role.name:
- break
-
- doc = self.intro_db.find_one({"message_id": message.id})
- embeds.append((doc, message.embeds[0]))
- await message.delete()
-
- return embeds
-
- @role_and_above(681812574026727471) # subreddit mods and above | exludes trainees
- @commands.group(hidden=True)
- async def intro(self, ctx):
- """
- Staff intro commands
- Usage: intro add/edit
- """
-
- @intro.command()
- async def add(self, ctx):
-
- self.kgs_guild = self.bot.get_guild(414027124836532234)
-
- intro = self.intro_db.find_one({"_id": ctx.author.id})
- if intro:
- await ctx.send(
- "It looks like you already have an intro. Use '!intro edit' to make changes to it"
- )
- return
-
- def check(m):
- return m.channel == ctx.channel and m.author == ctx.author
-
- try:
- await ctx.send(
- "Enter your timezone. This info need not neccesarily be accurate and can be memed."
- )
- tz_text = await self.bot.wait_for("message", check=check, timeout=180)
- await ctx.send("Okay, Enter your bio")
- bio = await self.bot.wait_for("message", check=check, timeout=240)
- await ctx.send(
- "Neat bio. Now give me the image link for your personal bird. The image should be fully transparent"
- )
- img = await self.bot.wait_for("message", check=check, timeout=240)
- if not img.content.startswith("http"): # dont wanna use regex
- await ctx.send(
- "That does not appear to be a valid link. Please run the command again"
- )
- return
- except asyncio.TimeoutError:
- await ctx.send(
- "You took too long to respond :(\nPlease run the command again"
- )
- return
- else:
- embed = self.parse_info(
- ctx.author.id, tz_text.content, bio.content, img.content
- )
- intro_channel = self.kgs_guild.get_channel(
- self.config["logging"]["intro_channel"]
- )
-
- embeds_to_add = await self.reorder_intros(
- ctx.author.top_role, intro_channel
- )
- msg = await intro_channel.send(embed=embed)
- self.intro_db.insert_one(
- {
- "_id": ctx.author.id,
- "tz_text": tz_text.content,
- "bio": bio.content,
- "message_id": msg.id,
- }
- )
-
- if embeds_to_add:
- for doc, embed in embeds_to_add:
- msg = await intro_channel.send(embed=embed)
- self.intro_db.update_one(
- {"_id": doc["_id"]}, {"$set": {"message_id": msg.id}}
- )
-
- await ctx.send("Success.")
-
- @intro.command()
- async def edit(self, ctx):
-
- self.kgs_guild = self.bot.get_guild(414027124836532234)
- intro = self.intro_db.find_one({"_id": ctx.author.id})
- if not intro:
- await ctx.send(
- "It looks like you dont have an intro. Use '!intro add' to add one"
- )
- return
-
- def check(m):
- return m.channel == ctx.channel and m.author == ctx.author
-
- try:
- await ctx.send(
- "Enter your timezone. This info need not neccesarily be accurate and can be memed.\nType `skip` if you want to leave this unchanged"
- )
- tz_text = await self.bot.wait_for("message", check=check, timeout=180)
- if tz_text.content.lower() == "skip":
- str_tz_text = intro["tz_text"]
- else:
- str_tz_text = tz_text.content
-
- await ctx.send(
- "Okay, Enter your bio. Type 'skip' if you want to leave this unchanged"
- )
- bio = await self.bot.wait_for("message", check=check, timeout=240)
- if bio.content.lower() == "skip":
- str_bio = intro["bio"]
- else:
- str_bio = bio.content
-
- except asyncio.TimeoutError:
- await ctx.send(
- "You took too long to respond :( Please run the command again"
- )
- return
- else:
- intro_channel = self.kgs_guild.get_channel(
- self.config["logging"]["intro_channel"]
- )
- msg = await intro_channel.fetch_message(intro["message_id"])
- embed = msg.embeds[0]
- embed.description = f"**{str_tz_text}**\n\n" + str_bio
- await msg.edit(embed=embed)
-
- self.intro_db.update_one(
- {"_id": ctx.author.id},
- {"$set": {"tz_text": str_tz_text, "bio": str_bio}},
- )
- await ctx.send("Success.")
-
- # TODO: Move to slash
- @commands.command()
- @commands.is_owner()
- async def mod_intros(self, ctx):
- """Only works with the intro spreadsheet. For local testing only."""
- df = pd.read_csv("intro.csv")
-
- self.kgs_guild = self.bot.get_guild(414027124836532234)
- intro_channel = self.kgs_guild.get_channel(
- self.config["logging"]["intro_channel"]
- )
- for _, row in df.iterrows():
- user = self.kgs_guild.get_member(int(row["ID"]))
- bird_icon = self.config["roleicons"][f"{user.top_role.id}"]
- embed = self.parse_info(
- int(row["ID"]), row["Region"], row["Bio"], row["Bird"]
- )
- msg = await intro_channel.send(embed=embed)
- try:
- self.intro_db.insert_one(
- {
- "_id": int(row["ID"]),
- "tz_text": row["Region"],
- "bio": row["Bio"],
- "message_id": msg.id,
- }
- )
- except pymongo.errors.DuplicateKeyError:
- pass
-
-
- @app_commands.command()
- @app_commands.guilds(414027124836532234)
- @app_commands.checks.cooldown(1, 10)
- @app_checks.bot_commands_only()
- async def big_emote(self, interaction: discord.Interaction, emoji: str):
- """Get image for server emote
-
- Parameters
- ----------
- emoji: str
- Discord Emoji (only use in #bot-commands)
- """
- """
- if len(args) > 1:
- ctx.send("Please only send one emoji at a time")
- """
- print(len(demoji.findall_list(emoji)))
- if len(demoji.findall_list(emoji)) == 1:
- code = str(emoji.encode('unicode-escape')).replace('U000','-').replace('\\','').replace('\'','').replace('u','-')[2:]
- print(code)
- name = demoji.replace_with_desc(emoji).replace(' ','-').replace(":","").replace("_","-")
- await interaction.response.send_message("https://em-content.zobj.net/thumbs/160/twitter/322/" + name\
- + "_" + code + ".png")
- elif len(demoji.findall_list(emoji)) > 1:
- await interaction.response.send_message("please only send one emoji")
- else:
- if re.match(r"", str(emoji)):
- emoji = str(re.findall(r"", str(emoji))[0]) + ".gif"
- await interaction.response.send_message("https://cdn.discordapp.com/emojis/" + str(emoji))
- elif re.match(r"<:\w+:(\d{17,19})>", str(emoji)):
- print("png")
- emoji = str(re.findall(r"<:\w+:(\d{17,19})>", str(emoji))[0]) + ".png"
- await interaction.response.send_message("https://cdn.discordapp.com/emojis/" + str(emoji))
- else:
- await interaction.response.send_message("Could not process this emoji")
-
- @commands.Cog.listener()
- async def on_reaction_add(self, reaction, user):
- """
- Provide mods with command to forceban users instead of self banning because I dont want to rework the entire command
- """
- if (
- user.bot or reaction.message.channel.id != 1009138597221372044
- ): # bannsystem channel
- return
- embed = reaction.message.embeds[0]
-
- if embed.footer.text.startswith(
- "Report-"
- ): # TODO account for multireports with interactions
-
- if reaction.emoji.id == 955703069516128307: # kgsYes
- reported_user = re.findall(
- r"[0-9]{11,}", reaction.message.embeds[0].description
- )[0]
- await reaction.message.channel.send(
- "Copy the command below to ban the user from the server:\n"
- f"```!fban {reported_user} {embed.fields[0].value} "
- f"|| bannsystem ID: {embed.footer.text.split()[1]}```",
- delete_after=15,
- )
-
-
-async def setup(bot):
- await bot.add_cog(Misc(bot))
diff --git a/cogs/roleassign.py b/cogs/roleassign.py
deleted file mode 100644
index 6bc178cb..00000000
--- a/cogs/roleassign.py
+++ /dev/null
@@ -1,144 +0,0 @@
-from importlib.util import LazyLoader
-import logging
-
-import json
-
-import discord
-from discord.ext import commands
-from utils.helper import devs_only
-
-
-class Roleassign(commands.Cog):
- def __init__(self, bot):
- self.logger = logging.getLogger("Roleassign")
- self.bot = bot
- with open("roleassigns.json", "r") as f:
- self.roles = json.loads(f.read())
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("loaded Roleassign")
- guild = discord.utils.get(self.bot.guilds, id=414027124836532234)
- for messageid in self.roles:
- channel = discord.utils.get(
- guild.channels, id=self.roles[messageid]["channel"]
- )
- message = await channel.fetch_message(messageid)
- view = discord.ui.View(timeout=None)
- for button in self.roles[messageid]["buttons"]:
- view.add_item(
- Button(
- label=button["label"],
- role=button["role"],
- roles=self.roles[messageid]["roles"],
- )
- )
- await message.edit(view=view)
-
- @devs_only()
- @commands.command(hidden=True)
- async def register_messages(self, ctx, confirm):
- """Command description"""
-
- if confirm != "yes":
- return
-
- roles = {
- "colours": {
- "embed": {
- "thumbnail": {
- "url": "https://cdn.discordapp.com/attachments/522777063376158760/569239529148645376/bird.gif"
- },
- "color": 4572415,
- "description": "**:Violet Bird**\n**Pink Bird**\n**Blue Bird**\n**Green Bird**\n**Yellow Bird**\n**Orange Bird**\n**Red Bird**",
- "title": "Press the button to get the role",
- },
- "list": [
- {"label": "Violet", "role": 558336866621980673},
- {"label": "Pink", "role": 557944766869012510},
- {"label": "Blue", "role": 557944673205747733},
- {"label": "Green", "role": 557944598857646080},
- {"label": "Yellow", "role": 557944535288643616},
- {"label": "Orange", "role": 557944471736680474},
- {"label": "Red", "role": 557944390753058816},
- ],
- "addroles": False,
- },
- "welcome": {
- "embed": {
- "thumbnail": {
- "url": "https://cdn.discordapp.com/emojis/588032757133869097.png"
- },
- "color": 4572415,
- "description": "The <&584461501109108738> role, you will be pinged in <#526882555174191125> every time someone joins.",
- "title": "Press the button to get the role",
- },
- "list": [{"label": "Welcome Bird", "role": 584461501109108738}],
- "addroles": True,
- },
- "languages": {
- "embed": {
- "thumbnail": {
- "url": "https://cdn.discordapp.com/emojis/672323362797780992.png"
- },
- "color": 4572415,
- "description": "English\n\nDeutsch\n\nEspañol",
- "title": "React to get notified when a video goes live",
- },
- "list": [
- {"label": "English", "role": 901136119863844864},
- {"label": "Deutsch", "role": 642097150158962688},
- {"label": "Español", "role": 677171902397284363},
- ],
- "addroles": True,
- },
- }
- rolemessages = {}
-
- for message in roles:
- items = roles[message]["list"]
- if roles[message]["addroles"] == False:
- otherroles = [x["role"] for x in items]
- else:
- otherroles = []
- view = discord.ui.View(timeout=None)
- for button in items:
- view.add_item(
- Button(label=button["label"], role=button["role"], roles=otherroles)
- )
- embed = discord.Embed.from_dict(roles[message]["embed"])
- messageobj = await ctx.send(embed=embed, view=view)
- rolemessages[messageobj.id] = {"buttons": items}
- rolemessages[messageobj.id].update({"roles": otherroles})
- rolemessages[messageobj.id].update({"channel": ctx.channel.id})
-
- with open("roleassigns.json", "w") as f:
- f.write(json.dumps(rolemessages, indent=4))
-
-
-class Button(discord.ui.Button):
- def __init__(self, label, role, roles):
- super().__init__(label=label)
- self.role = role
- self.roles = roles
-
- async def callback(self, interaction):
- role = discord.utils.get(interaction.guild.roles, id=self.role)
- if role in interaction.user.roles:
- status = "removed"
- await interaction.user.remove_roles(role)
- else:
- status = "assigned"
- roleids = [r.id for r in interaction.user.roles]
- for id in roleids:
- if id in self.roles:
- await interaction.user.remove_roles(
- discord.utils.get(interaction.guild.roles, id=id)
- )
- await interaction.user.add_roles(role)
-
- await interaction.response.send_message(f"{role.name} {status}", ephemeral=True)
-
-
-async def setup(bot):
- await bot.add_cog(Roleassign(bot))
diff --git a/cogs/smfeed.py b/cogs/smfeed.py
deleted file mode 100644
index ec32a2bd..00000000
--- a/cogs/smfeed.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import logging
-import json
-
-import discord
-from discord.ext import commands
-
-
-class Smfeed(commands.Cog):
- def __init__(self, bot):
- self.logger = logging.getLogger("Smfeed")
- self.bot = bot
-
- with open("config.json", "r") as config_file:
- config_json = json.loads(config_file.read())
-
- self.trainee_mod_role = config_json["roles"]["trainee_mod_role"]
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("loaded Smfeed")
-
- @commands.Cog.listener()
- async def on_message(self, message):
- """React to the twitter webhooks"""
-
- if self.bot.user.id != 471705718957801483:
- return
- if message.channel.id == 580354435302031360:
- await message.add_reaction("<:kgsYes:955703069516128307>")
-
- @commands.Cog.listener()
- async def on_raw_reaction_add(self, payload):
- """If mod or above reacts to twitter webhook tweet, sends it to proper channel"""
-
- if self.bot.user.id != 471705718957801483:
- return
- if (
- payload.channel_id == 580354435302031360
- and not payload.member.bot
- and payload.emoji.id == 955703069516128307
- ):
- guild = discord.utils.get(self.bot.guilds, id=414027124836532234)
- trainee_mod_role = guild.get_role(self.trainee_mod_role)
- if payload.member.top_role >= trainee_mod_role:
- channel = guild.get_channel(580354435302031360) # twitter posts
- message = await channel.fetch_message(payload.message_id)
- for reaction in message.reactions:
- if type(reaction.emoji) != type(""):
- if reaction.emoji.id == 955703069516128307:
- if reaction.count < 3:
- channel = guild.get_channel(
- 489450008643502080
- ) # social-media-feed
- await channel.send(message.content)
- break
-
-
-async def setup(bot):
- await bot.add_cog(Smfeed(bot))
diff --git a/cogs/topic.py b/cogs/topic.py
deleted file mode 100644
index 7483f033..00000000
--- a/cogs/topic.py
+++ /dev/null
@@ -1,296 +0,0 @@
-import logging
-import json
-import random
-import typing
-from fuzzywuzzy import process
-import asyncio
-import copy
-
-import discord
-from discord.ext import commands
-from discord import app_commands
-
-from utils import app_checks
-
-
-class Topic(commands.Cog):
- def __init__(self, bot):
- self.logger = logging.getLogger("Fun")
- self.bot = bot
-
- self.topics_db = self.bot.db.Topics
- self.topics = self.topics_db.find_one({"name": "topics"})[
- "topics"
- ] # Use this for DB interaction
-
- self.topics_list = copy.deepcopy(
- self.topics
- ) # This is used to stop topic repeats
-
- config_file = open("config.json", "r")
- config_json = json.loads(config_file.read())
- config_file.close()
-
- self.mod_role = config_json["roles"]["mod_role"]
- self.automated_channel = config_json["logging"]["automated_channel"]
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info("loaded Topic")
-
- topics_command = app_commands.Group(
- name="topics",
- description="Topic commands",
- guild_ids=[414027124836532234],
- default_permissions=discord.permissions.Permissions(manage_messages=True),
- )
-
- @app_commands.command()
- @app_commands.default_permissions(send_messages=True)
- @app_commands.guilds(414027124836532234)
- @app_checks.general_only()
- @app_checks.topic_perm_check()
- @app_commands.checks.cooldown(1, 300, key=lambda i: (i.guild_id, i.user.id))
- async def topic(self, interaction: discord.Interaction):
- """Fetches a random topic"""
- random_index = random.randint(0, len(self.topics_list) - 1)
- await interaction.response.send_message(f"{self.topics_list.pop(random_index)}")
-
- if self.topics_list == []:
- self.topics_list = copy.deepcopy(self.topics)
-
- @topics_command.command()
- @app_checks.mod_and_above()
- async def search(self, interaction: discord.Interaction, text: str):
- """Search a topic
-
- Parameters
- ----------
- text: str
- Search string
- """
-
- await interaction.response.defer(ephemeral=True)
-
- search_result = process.extractBests(text, self.topics, limit=9)
-
- t = [topic[0] for topic in search_result if topic[1] > 75]
-
- if t == []:
- return await interaction.edit_original_response(content="No match found.")
-
- embed_desc = "".join(
- f"{self.topics.index(tp) + 1}. {tp}\n" for _, tp in enumerate(t)
- )
-
- embed = discord.Embed(
- title="Best matches for search: ",
- description=embed_desc,
- )
-
- await interaction.edit_original_response(embed=embed)
-
- @topics_command.command()
- @app_checks.mod_and_above()
- async def add(self, interaction: discord.Interaction, text: str):
- """Add a topic
-
- Parameters
- ----------
- text: str
- New topic
- """
-
- self.topics.append(text)
-
- self.topics_db.update_one({"name": "topics"}, {"$set": {"topics": self.topics}})
-
- await interaction.response.send_message(
- f"Topic added at index {len(self.topics)}"
- )
-
- @topics_command.command()
- @app_checks.mod_and_above()
- async def remove(
- self,
- interaction: discord.Interaction,
- index: typing.Optional[int] = None,
- search_text: typing.Optional[str] = None,
- ):
- """Removes a topic
-
- Parameters
- ----------
- index: int
- Index of topic
- search_text: str
- Search string
- """
-
- if index is None and search_text is None:
- return await interaction.response.send_message(
- "Please provide value for one of the arguments.", ephemeral=True
- )
-
- await interaction.response.defer()
-
- if index is not None:
- if index < 1 or index > len(self.topics):
- return await interaction.edit_original_response(
- content=f"Invalid index. Min value: 1, Max value: {len(self.topics)}"
- )
-
- index = index - 1
- topic = self.topics[index]
- del self.topics[index]
-
- self.topics_db.update_one(
- {"name": "topics"}, {"$set": {"topics": self.topics}}
- )
-
- emb = discord.Embed(
- title="Success",
- description=f"**{topic}** removed.",
- colour=discord.Colour.green(),
- )
- await interaction.edit_original_response(embed=emb)
-
- else:
- if search_text is None:
- return await interaction.edit_original_response(
- content="Invalid arguments. Please specify either index or search string."
- )
-
- search_result = process.extractBests(search_text, self.topics, limit=9)
-
- t = [topic[0] for topic in search_result if topic[1] > 75]
-
- if t == []:
- return await interaction.edit_original_response(
- content="No match found."
- )
-
- embed_desc = "".join(f"{index + 1}. {tp}\n" for index, tp in enumerate(t))
-
- embed = discord.Embed(
- title="React on corresponding number to delete topic.",
- description=embed_desc,
- )
-
- msg = await interaction.edit_original_response(embed=embed)
-
- emote_list = [
- "\u0031\uFE0F\u20E3",
- "\u0032\uFE0F\u20E3",
- "\u0033\uFE0F\u20E3",
- "\u0034\uFE0F\u20E3",
- "\u0035\uFE0F\u20E3",
- "\u0036\uFE0F\u20E3",
- "\u0037\uFE0F\u20E3",
- "\u0038\uFE0F\u20E3",
- "\u0039\uFE0F\u20E3",
- ]
-
- for emote in emote_list[: len(t)]:
- await msg.add_reaction(emote)
-
- def check(reaction, user):
- return user == interaction.user and str(reaction.emoji) in emote_list
-
- try:
- reaction, user = await self.bot.wait_for(
- "reaction_add", timeout=30.0, check=check
- )
-
- i = emote_list.index(str(reaction.emoji))
-
- emb = discord.Embed(
- title="Success!",
- description=f"**{search_result[i][0]}**\nremoved",
- colour=discord.Colour.green(),
- )
-
- self.topics.remove(search_result[i][0])
-
- self.topics_db.update_one(
- {"name": "topics"}, {"$set": {"topics": self.topics}}
- )
-
- await msg.edit(embed=emb)
- await msg.clear_reactions()
-
- except asyncio.TimeoutError:
- await msg.delete()
- return
-
- @app_commands.command()
- @app_commands.default_permissions(send_messages=True)
- @app_commands.guilds(414027124836532234)
- @app_commands.checks.cooldown(1, 60, key=lambda i: (i.guild_id, i.user.id))
- async def topic_suggest(self, interaction: discord.Interaction, topic: str):
- """Suggest a topic
-
- Parameters
- ----------
- topic: str
- Topic to suggest
- """
- await interaction.response.defer(ephemeral=True)
- automated_channel = self.bot.get_channel(self.automated_channel)
- embed = discord.Embed(description=f"**{topic}**", color=0xC8A2C8)
- embed.set_author(
- name=interaction.user.name + "#" + interaction.user.discriminator,
- icon_url=interaction.user.display_avatar.url,
- )
- embed.set_footer(text="topic")
- message = await automated_channel.send(embed=embed)
-
- await message.add_reaction("<:kgsYes:955703069516128307>")
- await message.add_reaction("<:kgsNo:955703108565098496>")
-
- await interaction.edit_original_response(
- content="Topic suggested.",
- )
-
- @commands.Cog.listener()
- async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
- # User topic suggestions
- if payload.channel_id == self.automated_channel and not payload.member.bot:
- guild = discord.utils.get(self.bot.guilds, id=414027124836532234)
- mod_role = guild.get_role(self.mod_role)
- if payload.member.top_role >= mod_role:
- message = await self.bot.get_channel(payload.channel_id).fetch_message(
- payload.message_id
- )
- if message.embeds and message.embeds[0].footer.text == "topic":
- if payload.emoji.id == 955703069516128307:
- topic = message.embeds[0].description
- author = message.embeds[0].author
- self.topics.append(topic.strip("*"))
- self.topics_db.update_one(
- {"name": "topics"}, {"$set": {"topics": self.topics}}
- )
- embed = discord.Embed(
- description=f"**{topic}**", colour=discord.Colour.green()
- )
- embed.set_author(name=author.name, icon_url=author.icon_url)
- await message.edit(embed=embed, delete_after=6)
-
- member = guild.get_member_named(author.name)
- try:
- await member.send(
- f"Your topic suggestion was accepted: **{topic}**"
- )
- except discord.Forbidden:
- pass
-
- elif payload.emoji.id == 955703108565098496:
- message = await self.bot.get_channel(
- payload.channel_id
- ).fetch_message(payload.message_id)
- embed = discord.Embed(title="Suggestion removed!")
- await message.edit(embed=embed, delete_after=6)
-
-
-async def setup(bot):
- await bot.add_cog(Topic(bot))
diff --git a/config.json b/config.json
deleted file mode 100644
index ece65ad1..00000000
--- a/config.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
- "logging": {
- "logging_channel": 543884016282239006,
- "automod_logging_channel": 966769038879498301,
- "message_logging_channel": 879399217511161887,
- "member_logging_channel": 939570758903005296,
- "dev_logging_channel": 865321589919055882,
- "automated_channel": 546689491486769163,
- "intro_channel": 981620309163655218
- },
- "roles": {
- "mute_role": 681323126252240899,
- "helper_role": 849423379706150946,
- "mod_role": 414092550031278091,
- "admin_role": 414029841101225985,
- "kgsofficial_role": 414954904382210049,
- "patreon_blue_role": 753258289185161248,
- "patreon_green_role": 415154206970740737,
- "patreon_orange_role": 753268671107039274,
- "trainee_mod_role": 905510680763969536
- },
- "giveaway": {
- "roles": [
- {
- "id": 698479120878665729,
- "bias": 11,
- "name": "galacduck"
- },
- {
- "id": 662937489220173884,
- "bias": 7,
- "name": "legendary duck"
- },
- {
- "id": 637114917178048543,
- "bias": 4,
- "name": "super duck"
- },
- {
- "id": 637114897544511488,
- "bias": 3,
- "name": "duck"
- },
- {
- "id": 637114873268142081,
- "bias": 2,
- "name": "smol duck"
- }
- ],
- "default": 1
- },
- "roleicons": {
- "915629257470906369": "https://cdn.discordapp.com/attachments/414179142020366336/981591366805119006/officialkurzgesagt.png",
- "414029841101225985": "https://cdn.discordapp.com/attachments/414179142020366336/981591366201126963/administratorkurzgesagt.png",
- "414092550031278091": "https://cdn.discordapp.com/attachments/414179142020366336/981591366524084244/moderatorkurzgesagt.png",
- "1058243220817063936": "https://cdn.discordapp.com/emojis/892827414097440839.png",
- "681812574026727471": "https://cdn.discordapp.com/emojis/892827414097440839.png"
- }
-}
diff --git a/installservicelinux.sh b/installservicelinux.sh
deleted file mode 100644
index 0dd5fc81..00000000
--- a/installservicelinux.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/bash
-if [ "$EUID" -ne 0 ]; then
- echo "Please run as root"
- exit
-fi
-
-FILE=birdbot.service
-if test -f "$FILE"; then
- mv birdbot.service /home/austin
- systemctl daemon-reload
- systemctl enable birdbot
- systemctl start birdbot
- systemctl status birdbot
- echo "service should be enabled"
-else
- echo "file not found"
-fi
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..e0385acd
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,40 @@
+[project]
+name = "birdbot"
+version = "3.0.0"
+description = "The fully featured official discord bot for the kurzgesagt server"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "aiohttp==3.8.3",
+ "black==22.12.0",
+ "certifi==2022.12.7",
+ "demoji==1.1.0",
+ "discord.py==2.3.2",
+ "fuzzywuzzy==0.18.0",
+ "numpy==1.24.1",
+ "pymongo==4.3.3",
+ "python-dotenv==0.21.0",
+ "requests==2.28.1",
+ "isort==5.12.0",
+ "rich==13.0.0",
+ "pyright==1.1.327",
+]
+
+
+[tool.black]
+line-length = 120
+target-version = ['py311']
+
+[tool.isort]
+line_length = 120
+profile = "black"
+skip_gitignore = true
+
+[tool.pyright]
+pythonVersion = "3.11"
+useLibraryCodeForTypes = true
+reportUnusedImport = "error"
+typeCheckingMode = "basic"
+ignore=[
+ 'app/cogs/automod.py'
+]
diff --git a/requirements.txt b/requirements.txt
index 1ff0627b..8b359a0b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,35 +1,34 @@
-aiohttp==3.8.3
+aiohttp==3.9.3
aiosignal==1.3.1
-async-timeout==4.0.2
-attrs==22.2.0
-better-profanity==0.7.0
-black==22.12.0
-certifi==2022.12.7
-charset-normalizer==2.1.1
-click==8.1.3
-commonmark==0.9.1
+attrs==23.2.0
+black==24.3.0
+certifi==2024.2.2
+charset-normalizer==3.3.2
+click==8.1.7
demoji==1.1.0
-discord.py==2.1.0
-dnspython==2.2.1
-frozenlist==1.3.3
+discord.py==2.3.2
+dnspython==2.6.1
+frozenlist==1.4.1
fuzzywuzzy==0.18.0
-gitdb==4.0.10
-GitPython==3.1.30
-idna==3.4
-Levenshtein==0.20.9
-multidict==6.0.4
-mypy-extensions==0.4.3
-numpy==1.24.1
-pathspec==0.10.3
-platformdirs==2.6.2
-Pygments==2.13.0
-pymongo==4.3.3
-python-dotenv==0.21.0
-python-Levenshtein==0.20.9
-rapidfuzz==2.13.7
-requests==2.28.1
-rich==13.0.0
-smmap==5.0.0
-tomli==2.0.1
-urllib3==1.26.18
-yarl==1.8.2
+idna==3.6
+isort==5.13.2
+Levenshtein==0.25.0
+markdown-it-py==3.0.0
+mdurl==0.1.2
+multidict==6.0.5
+mypy-extensions==1.0.0
+nodeenv==1.8.0
+numpy==1.26.4
+packaging==24.0
+pathspec==0.12.1
+platformdirs==4.2.0
+Pygments==2.17.2
+pymongo==4.6.2
+pyright==1.1.355
+python-dotenv==1.0.1
+python-Levenshtein==0.25.0
+rapidfuzz==3.7.0
+requests==2.31.0
+rich==13.7.1
+urllib3==2.2.1
+yarl==1.9.4
\ No newline at end of file
diff --git a/roleassigns.json b/roleassigns.json
deleted file mode 100644
index 6979bcb1..00000000
--- a/roleassigns.json
+++ /dev/null
@@ -1,72 +0,0 @@
-{
- "950118877646442627": {
- "buttons": [
- {
- "label": "Violet",
- "role": 558336866621980673
- },
- {
- "label": "Pink",
- "role": 557944766869012510
- },
- {
- "label": "Blue",
- "role": 557944673205747733
- },
- {
- "label": "Green",
- "role": 557944598857646080
- },
- {
- "label": "Yellow",
- "role": 557944535288643616
- },
- {
- "label": "Orange",
- "role": 557944471736680474
- },
- {
- "label": "Red",
- "role": 557944390753058816
- }
- ],
- "roles": [
- 558336866621980673,
- 557944766869012510,
- 557944673205747733,
- 557944598857646080,
- 557944535288643616,
- 557944471736680474,
- 557944390753058816
- ],
- "channel": 414179142020366336
- },
- "950118878497865799": {
- "buttons": [
- {
- "label": "Welcome Bird",
- "role": 584461501109108738
- }
- ],
- "roles": [],
- "channel": 414179142020366336
- },
- "950118879366099035": {
- "buttons": [
- {
- "label": "English",
- "role": 901136119863844864
- },
- {
- "label": "Deutsch",
- "role": 642097150158962688
- },
- {
- "label": "Espa\u00f1ol",
- "role": 677171902397284363
- }
- ],
- "roles": [],
- "channel": 414179142020366336
- }
-}
\ No newline at end of file
diff --git a/startbot.py b/startbot.py
index 661416a5..355955be 100644
--- a/startbot.py
+++ b/startbot.py
@@ -1,51 +1,60 @@
-import logging
-import os
-import dotenv
+# Copyright (C) 2024, Kurzgesagt Community Devs
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""
+This is the entrypoint used to start the bot.
+It provides command-line arguments to specify which instance of the bot to run (beta, alpha, or main).
+It loads the necessary environment variables from a .env file and starts the bot with the specified token.
+
+Usage:
+ python3 startbot.py [-b] [-a]
+
+Options:
+ -b, --beta Run the beta instance of the bot
+ -a, --alpha Run the alpha instance of the bot
+"""
+
import argparse
import asyncio
+import logging
+import os
-from birdbot import BirdBot
-from birdbot import setup
+import dotenv
+from app.birdbot import BirdBot, setup
parser = argparse.ArgumentParser()
-parser.add_argument(
- "-b", "--beta", help="Run the beta instance of the bot", action="store_true"
-)
-parser.add_argument(
- "-a", "--alpha", help="Run the alpha instance of the bot", action="store_true"
-)
-
-
-def is_member_whitelisted(ctx) -> bool:
- cmd_blacklist_db = BirdBot.db.CommandBlacklist
- cmd = cmd_blacklist_db.find_one({"command_name": ctx.command.name})
- if cmd is None:
- cmd_blacklist_db.insert_one(
- {"command_name": ctx.command.name, "blacklisted_users": []}
- )
- return True
+parser.add_argument("-b", "--beta", help="Run the beta instance of the bot", action="store_true")
+parser.add_argument("-a", "--alpha", help="Run the alpha instance of the bot", action="store_true")
- return ctx.author.id not in cmd["blacklisted_users"]
-
-async def main():
+async def main() -> None:
with setup():
-
logger = logging.getLogger("Startbot")
dotenv.load_dotenv()
args = parser.parse_args()
bot = BirdBot.from_parseargs(args)
- await bot.load_extensions(args)
- bot.add_check(is_member_whitelisted)
if args.beta:
- token = os.environ.get("BETA_BOT_TOKEN")
+ token: str | None = os.environ.get("BETA_BOT_TOKEN")
elif args.alpha:
- token = os.environ.get("ALPHA_BOT_TOKEN")
+ token: str | None = os.environ.get("ALPHA_BOT_TOKEN")
else:
- token = os.environ.get("MAIN_BOT_TOKEN")
+ token: str | None = os.environ.get("MAIN_BOT_TOKEN")
async with bot:
- await bot.start(token, reconnect=True)
+ if token:
+ await bot.start(token, reconnect=True)
+ else:
+ logger.critical("No token found")
+ exit(-1)
if __name__ == "__main__":
diff --git a/utils/app_checks.py b/utils/app_checks.py
deleted file mode 100644
index 170651f4..00000000
--- a/utils/app_checks.py
+++ /dev/null
@@ -1,101 +0,0 @@
-from .helper import config_roles
-from .app_errors import InvalidAuthorizationError
-
-from discord import Interaction, app_commands
-
-
-def mod_and_above():
- async def predicate(interaction: Interaction):
- user_role_ids = [x.id for x in interaction.user.roles]
- check_role_ids = [
- config_roles["mod_role"],
- config_roles["admin_role"],
- config_roles["kgsofficial_role"],
- config_roles["trainee_mod_role"],
- ]
- if not any(x in user_role_ids for x in check_role_ids):
- raise InvalidAuthorizationError
- return True
-
- return app_commands.check(predicate)
-
-
-def devs_only():
- async def predicate(interaction: Interaction):
- if not interaction.user.id in [
- 389718094270038018, # FC
- 424843380342784011, # Oeav
- 183092910495891467, # Sloth
- 248790213386567680, # Austin
- 229779964898181120, # source
- ]:
- raise InvalidAuthorizationError
- return True
-
- return app_commands.check(predicate)
-
-
-def general_only():
- async def predicate(interaction: Interaction):
- if (
- interaction.channel.category_id
- != 414095379156434945 # Mod channel category
- and interaction.channel_id != 414027124836532236 # general id
- ):
- raise InvalidAuthorizationError
- return True
-
- return app_commands.check(predicate)
-
-
-def bot_commands_only():
- async def predicate(interaction: Interaction):
- if (
- interaction.channel.category_id
- != 414095379156434945 # Mod channel category
- and interaction.channel_id != 414452106129571842 # bot_commands id
- ):
- raise InvalidAuthorizationError
- return True
-
- return app_commands.check(predicate)
-
-
-def topic_perm_check():
- # check for role >= Duck(637114897544511488) and Patron
- async def predicate(interaction: Interaction):
- check_role = interaction.guild.get_role(637114897544511488)
-
- user = interaction.guild.get_member(interaction.user.id)
- user_role_ids = [x.id for x in user.roles]
- check_role_ids = [
- config_roles["patreon_blue_role"],
- config_roles["patreon_green_role"],
- config_roles["patreon_orange_role"],
- ]
- if interaction.user.top_role >= check_role or any(
- x in user_role_ids for x in check_role_ids
- ):
- return True
- raise InvalidAuthorizationError
-
- return app_commands.check(predicate)
-
-
-def patreon_only():
- async def predicate(interaction: Interaction):
-
- user = interaction.client.get_guild(414027124836532234).get_member(
- interaction.user.id
- )
- user_role_ids = [x.id for x in user.roles]
- check_role_ids = [
- config_roles["patreon_blue_role"],
- config_roles["patreon_green_role"],
- config_roles["patreon_orange_role"],
- ]
- if not any(x in user_role_ids for x in check_role_ids):
- raise InvalidAuthorizationError
- return True
-
- return app_commands.check(predicate)
diff --git a/utils/app_errors.py b/utils/app_errors.py
deleted file mode 100644
index 2dd5c876..00000000
--- a/utils/app_errors.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from discord import app_commands
-
-
-class InvalidInvocationError(app_commands.AppCommandError):
-
- """
- Called when a combination of arguments used within a command are invalid or
- the state of the execution is not valid.
- """
-
-
-class InvalidAuthorizationError(app_commands.CheckFailure):
-
- """
- Base class to be thrown when invalid authorization is provided.
- """
diff --git a/utils/custom_converters.py b/utils/custom_converters.py
deleted file mode 100644
index 31fd38e3..00000000
--- a/utils/custom_converters.py
+++ /dev/null
@@ -1,62 +0,0 @@
-import logging
-import re
-
-from discord.ext import commands
-from discord.ext.commands.converter import _get_from_guilds, _utils_get
-
-logger = logging.getLogger("CustomConverters")
-
-
-def _get_id_match(argument):
- _id_regex = re.compile(r"([0-9]{15,21})$")
- return _id_regex.match(argument)
-
-
-def member_converter(ctx: commands.Context, argument):
- try:
- bot = ctx.bot
- guild = ctx.guild
- match = _get_id_match(argument) or re.match(r"<@!?([0-9]+)>$", argument)
- if match is None:
- result = None
- else:
- user_id = int(match.group(1))
- if guild:
- result = guild.get_member(user_id) or _utils_get(
- ctx.message.mentions, id=user_id
- )
- else:
- result = _get_from_guilds(bot, "get_member", user_id)
-
- return result
- except Exception as e:
- logging.error(str(e))
- return None
-
-
-def get_members(ctx: commands.Context, *args):
- try:
- members = []
- extra = []
- got_members = False
- for a in args:
- result = member_converter(ctx, a)
- if not got_members:
- if result:
- members.append(result)
- else:
- got_members = True
- extra.append(a)
- else:
- extra.append(a)
-
- if not members:
- members = None
- if not extra:
- extra = None
-
- return members, extra
-
- except Exception as e:
- logging.error(e)
- return None, None
diff --git a/utils/make_cog.py b/utils/make_cog.py
deleted file mode 100644
index bf0cd1e9..00000000
--- a/utils/make_cog.py
+++ /dev/null
@@ -1,30 +0,0 @@
-name = input("Cog Name: ")
-text = f"""import logging
-
-import discord
-from discord.ext import commands
-
-
-class {''.join(name.title().split())}(commands.Cog):
- def __init__(self, bot):
- self.logger = logging.getLogger('{''.join(name.title().split())}')
- self.bot = bot
-
- @commands.Cog.listener()
- async def on_ready(self):
- self.logger.info('loaded {''.join(name.title().split())}')
-
- @commands.command()
- async def your_command(self, ctx):
- \"\"\"Command description\"\"\"
- await ctx.send('thing')
-
-
-async def setup(bot):
- await bot.add_cog({''.join(name.title().split())}(bot))
-
-"""
-f = open(f"cogs/{'_'.join(name.casefold().split())}.py", "w+")
-f.write(text)
-f.close()
-print(f"Created cog with name {'_'.join(name.casefold().split())}.py in cogs directory")