diff --git a/.env.template b/.env.template
index 2e236a4..413f244 100644
--- a/.env.template
+++ b/.env.template
@@ -10,6 +10,7 @@ SPOTIFY_CLIENT_ID=123123123 # https://developer.spotify.com/dashboard/
SPOTIFY_CLIENT_SECRET=123123123
# Settings
+COMMAND_PREFIX="" # Set to something like an abbreviation of the client's name so all commands have that prefix and can be distinguished better - empty for none
DEV_IDS="123,456" # All user IDs that should have dev perms
BELL_ON_READY=false # Set to true to send a console bell sound when the client is ready
EXEC_CMD_ENABLED=true # Set to false to disable the /exec command
diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml
index a692f7b..e21e0cd 100644
--- a/.github/workflows/node.yml
+++ b/.github/workflows/node.yml
@@ -22,14 +22,14 @@ jobs:
- name: Install node-canvas dependencies
run: |
sudo apt update
- sudo apt install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
- if: matrix.node-version == '18.x'
+ sudo apt install -y build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
- name: Install npm dependencies
run: |
- npm install -g tsc
- npm install
+ npm i -g tsc
+ npm ci
- - run: npm run lint
+ - name: Run ESLint
+ run: npm run lint
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 8f9e90b..c419e06 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -44,9 +44,7 @@
"editor.defaultFormatter": "vscode.json-language-features",
},
"files.exclude": {
- "**/.env": true,
"out/**": true,
- "node_modules/": true
},
"git.branchPrefix": "feat/",
"git.branchProtection": [ "main" ],
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are 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.
+
+ 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.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ 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 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 work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ 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 AGPL, see
+.
diff --git a/package-lock.json b/package-lock.json
index ab8583b..ca1878a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,7 +7,7 @@
"": {
"name": "brewbot",
"version": "0.2.0",
- "license": "UNLICENSED",
+ "license": "AGPL-3.0",
"dependencies": {
"@discordjs/rest": "^0.5.0",
"@prisma/client": "^4.3.1",
@@ -26,7 +26,7 @@
"redis": "^4.2.0",
"simple-statistics": "^7.7.5",
"steamapi": "^2.2.0",
- "svcorelib": "^1.16.0",
+ "svcorelib": "^1.18.1",
"yargs": "^17.5.1"
},
"bin": {
@@ -806,14 +806,6 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
- "node_modules/at-least-node": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
- "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
- "engines": {
- "node": ">= 4.0.0"
- }
- },
"node_modules/axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
@@ -3424,28 +3416,33 @@
}
},
"node_modules/svcorelib": {
- "version": "1.16.0",
- "resolved": "https://registry.npmjs.org/svcorelib/-/svcorelib-1.16.0.tgz",
- "integrity": "sha512-fUd47yM8R5KY8LkrGqvUSYeQZtnlcbjuOHbOuLS3ItasavLZoP9Weg3BW4ZuPUjSAmf+zrH7rVzhONMzmOsZUw==",
+ "version": "1.18.1",
+ "resolved": "https://registry.npmjs.org/svcorelib/-/svcorelib-1.18.1.tgz",
+ "integrity": "sha512-OJBg7OAX75v3aPKzOwSlj9KSvdorXm65SSsSxlTSf1ZLE6Mn6LjXLDfPd09pEZrFlm8bMBsiDmAE1P0woA6J0A==",
"dependencies": {
"deep-diff": "^1.0.2",
- "fs-extra": "^9.0.1",
+ "fs-extra": "^10.1.0",
"keypress": "^0.2.1",
- "minimatch": "^3.0.4"
+ "minimatch": "^5.1.0"
},
"optionalDependencies": {
"mysql": "^2.18.1"
}
},
- "node_modules/svcorelib/node_modules/fs-extra": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
- "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "node_modules/svcorelib/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
- "at-least-node": "^1.0.0",
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/svcorelib/node_modules/minimatch": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz",
+ "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
@@ -4487,11 +4484,6 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
- "at-least-node": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
- "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="
- },
"axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
@@ -6420,26 +6412,31 @@
}
},
"svcorelib": {
- "version": "1.16.0",
- "resolved": "https://registry.npmjs.org/svcorelib/-/svcorelib-1.16.0.tgz",
- "integrity": "sha512-fUd47yM8R5KY8LkrGqvUSYeQZtnlcbjuOHbOuLS3ItasavLZoP9Weg3BW4ZuPUjSAmf+zrH7rVzhONMzmOsZUw==",
+ "version": "1.18.1",
+ "resolved": "https://registry.npmjs.org/svcorelib/-/svcorelib-1.18.1.tgz",
+ "integrity": "sha512-OJBg7OAX75v3aPKzOwSlj9KSvdorXm65SSsSxlTSf1ZLE6Mn6LjXLDfPd09pEZrFlm8bMBsiDmAE1P0woA6J0A==",
"requires": {
"deep-diff": "^1.0.2",
- "fs-extra": "^9.0.1",
+ "fs-extra": "^10.1.0",
"keypress": "^0.2.1",
- "minimatch": "^3.0.4",
+ "minimatch": "^5.1.0",
"mysql": "^2.18.1"
},
"dependencies": {
- "fs-extra": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
- "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "requires": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "minimatch": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz",
+ "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==",
"requires": {
- "at-least-node": "^1.0.0",
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
+ "brace-expansion": "^2.0.1"
}
}
}
diff --git a/package.json b/package.json
index 2ffbe33..532b85a 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
"type": "git",
"url": "git+https://github.com/codedrunks/BrewBot.git"
},
- "license": "UNLICENSED",
+ "license": "AGPL-3.0",
"bugs": {
"url": "https://github.com/codedrunks/BrewBot/issues"
},
@@ -58,7 +58,7 @@
"redis": "^4.2.0",
"simple-statistics": "^7.7.5",
"steamapi": "^2.2.0",
- "svcorelib": "^1.16.0",
+ "svcorelib": "^1.18.1",
"yargs": "^17.5.1"
},
"bin": {
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0b90c34..cb74c15 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -37,6 +37,7 @@ model Reminder {
dueTimestamp DateTime
guild String?
channel String?
+ private Boolean @default(false)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([reminderId, userId])
@@ -82,6 +83,7 @@ model Guild {
premium Boolean? @default(false)
lastLogColor String?
contests Contest[]
+ polls Poll[]
GuildSettings GuildSettings?
}
@@ -97,6 +99,22 @@ model GuildSettings {
Guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
+model Poll {
+ pollId Int
+ guildId String
+ channel String
+ messages String[]
+ createdBy String
+ headline String?
+ topic String?
+ voteOptions String[]
+ votesPerUser Int
+ dueTimestamp DateTime
+ Guild Guild @relation(fields: [guildId], references: [id])
+
+ @@id([pollId, guildId])
+}
+
//#MARKER contest
model Contest {
id Int
@@ -136,8 +154,8 @@ model SubmissionVote {
model TwentyFortyEightLeaderboardEntry {
guildId String
userId String
- score Int @default(0)
- gamesWon Int @default(0)
+ score Int @default(0)
+ gamesWon Int @default(0)
member Member @relation(fields: [guildId, userId], references: [guildId, userId])
@@id([guildId, userId])
diff --git a/src/Command.ts b/src/Command.ts
index ec6d456..1b8af5d 100644
--- a/src/Command.ts
+++ b/src/Command.ts
@@ -46,7 +46,7 @@ export abstract class Command
this.meta = { ...fallbackMeta, ...cmdMeta };
const { name, desc, args } = this.meta;
- data.setName(name)
+ data.setName(this.getFullCmdName(name))
.setDescription(desc);
Array.isArray(args) && args.forEach(arg => {
@@ -96,7 +96,7 @@ export abstract class Command
opt.setName(arg.name)
.setDescription(arg.desc)
.setRequired(arg.required ?? false)
- .addChannelTypes(ChannelType.GuildText, ChannelType.GuildNews, ChannelType.GuildPublicThread)
+ .addChannelTypes(ChannelType.GuildText, ChannelType.GuildNews, ChannelType.GuildPublicThread, ChannelType.GuildPrivateThread, ChannelType.GuildVoice)
);
else if(arg.type === ApplicationCommandOptionType.Role)
data.addRoleOption(opt =>
@@ -121,7 +121,7 @@ export abstract class Command
return opt;
});
else
- throw new Error("Unimplemented option type");
+ throw new Error(`Unimplemented argument type in /${cmdMeta.name}`);
});
}
else
@@ -129,7 +129,7 @@ export abstract class Command
// subcommands
this.meta = { ...fallbackMeta, ...cmdMeta };
- data.setName(cmdMeta.name)
+ data.setName(this.getFullCmdName(cmdMeta.name))
.setDescription(cmdMeta.desc);
cmdMeta.subcommands.forEach(scmd => {
@@ -187,7 +187,7 @@ export abstract class Command
opt.setName(arg.name)
.setDescription(arg.desc)
.setRequired(arg.required ?? false)
- .addChannelTypes(ChannelType.GuildText, ChannelType.GuildNews, ChannelType.GuildPublicThread)
+ .addChannelTypes(ChannelType.GuildText, ChannelType.GuildNews, ChannelType.GuildPublicThread, ChannelType.GuildPrivateThread, ChannelType.GuildVoice)
);
else if(arg.type === ApplicationCommandOptionType.Role)
sc.addRoleOption(opt =>
@@ -212,7 +212,7 @@ export abstract class Command
return opt;
});
else
- throw new Error("Unimplemented option");
+ throw new Error(`Unimplemented argument type in /${cmdMeta.name} ${scmd.name}`);
});
return sc;
@@ -226,6 +226,12 @@ export abstract class Command
//#SECTION public
+ /** Returns the full name of the given command name, including optional prefix */
+ public getFullCmdName(cmdName: string)
+ {
+ return `${settings.client.commandPrefix ?? ""}${cmdName}`;
+ }
+
/** Called when a user tries to run this command (if the user doesn't have perms this resolves null) */
public async tryRun(int: CommandInteraction, opt?: CommandInteractionOption<"cached">): Promise
{
diff --git a/src/bot.ts b/src/bot.ts
index fc8c5f1..93f6e5b 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -153,7 +153,7 @@ async function registerCommands(client: Client)
const opts = options.data && options.data.length > 0 ? options.data : undefined;
- const cmd = cmds.find(({ meta }) => meta.name === commandName);
+ const cmd = cmds.find((cmd) => cmd.getFullCmdName(cmd.meta.name) === commandName);
if(!cmd || !cmd.enabled)
return;
diff --git a/src/commands/economy/Job.ts b/src/commands/economy/Job.ts
index cfab2e0..117f598 100644
--- a/src/commands/economy/Job.ts
+++ b/src/commands/economy/Job.ts
@@ -3,7 +3,7 @@ import { Command } from "@src/Command";
import { getTotalWorks } from "@database/economy";
import { settings } from "@src/settings";
import { embedify } from "@utils/embedify";
-import { Levels, totalWorksToLevel, baseAward } from "@commands/economy/Jobs";
+import { jobLevels, totalWorksToLevel, baseAward } from "@src/commands/economy/jobs";
export class Job extends Command {
constructor() {
@@ -26,7 +26,7 @@ export class Job extends Command {
if(!totalworks || totalworks == 0) return this.reply(int, embedify("We have no job records for you, ya bum! Consider doing something using `/work`"), true);
const jobidx = totalWorksToLevel(totalworks);
- const job = Levels[jobidx as keyof typeof Levels];
+ const job = jobLevels[jobidx as keyof typeof jobLevels];
const { name, multiplier } = job;
diff --git a/src/commands/economy/Work.ts b/src/commands/economy/Work.ts
index 46ae98f..df85ec3 100644
--- a/src/commands/economy/Work.ts
+++ b/src/commands/economy/Work.ts
@@ -3,7 +3,7 @@ import { Command } from "@src/Command";
import { embedify, formatSeconds, nowInSeconds } from "@utils/index";
import { addCoins, getLastWork, getTotalWorks, incrementTotalWorks, setLastWork } from "@database/economy";
import { createNewMember, getMember } from "@database/users";
-import { Levels, totalWorksToLevel, baseAward } from "@commands/economy/Jobs";
+import { jobLevels, totalWorksToLevel, baseAward } from "@src/commands/economy/jobs";
import { randomItem } from "svcorelib";
const secs4hours = 14400;
@@ -41,7 +41,7 @@ export class Work extends Command {
await setLastWork(userid, guildid);
const jobidx = totalWorksToLevel(totalworks);
- const job = Levels[jobidx as keyof typeof Levels];
+ const job = jobLevels[jobidx as keyof typeof jobLevels];
const payout = Math.round(job.multiplier * baseAward);
@@ -60,7 +60,7 @@ export class Work extends Command {
await setLastWork(userid, guildid);
const jobidx = totalWorksToLevel(totalworks);
- const job = Levels[jobidx as keyof typeof Levels];
+ const job = jobLevels[jobidx as keyof typeof jobLevels];
const payout = Math.round(job.multiplier * baseAward);
diff --git a/src/commands/economy/jobs/index.ts b/src/commands/economy/jobs/index.ts
new file mode 100644
index 0000000..7af5bbb
--- /dev/null
+++ b/src/commands/economy/jobs/index.ts
@@ -0,0 +1 @@
+export * from "./jobs";
diff --git a/src/commands/economy/Jobs.ts b/src/commands/economy/jobs/jobs.ts
similarity index 73%
rename from src/commands/economy/Jobs.ts
rename to src/commands/economy/jobs/jobs.ts
index 0c0132e..0ddeb93 100644
--- a/src/commands/economy/Jobs.ts
+++ b/src/commands/economy/jobs/jobs.ts
@@ -1,15 +1,18 @@
-export interface ILevel {
- [key: number]: {
- name: string,
- multiplier: number,
- phrases: string[]
- }
+interface JobLevelObj {
+ name: string;
+ multiplier: number;
+ worksRequired: number;
+ phrases: string[];
}
-export const Levels: ILevel = {
+
+const baseAward = 50;
+
+const jobLevels: Record = {
1: {
name: "Beggar",
multiplier: .1,
+ worksRequired: 0,
phrases: [
"begging for a few hours on the street corner",
"selling your kidney",
@@ -22,6 +25,7 @@ export const Levels: ILevel = {
2: {
name: "McDonald's Employee",
multiplier: .4,
+ worksRequired: 10,
phrases: [
"flipping patties",
"getting beaten by your manager",
@@ -31,6 +35,7 @@ export const Levels: ILevel = {
3: {
name: "ShitCoin Trader",
multiplier: .8,
+ worksRequired: 25,
phrases: [
"selling some doge",
"foreseeing the future",
@@ -40,6 +45,7 @@ export const Levels: ILevel = {
4: {
name: "Twitch Streamer",
multiplier: 1,
+ worksRequired: 50,
phrases: [
"destroying some 12 year olds",
"playing some fortnite",
@@ -50,6 +56,7 @@ export const Levels: ILevel = {
5: {
name: "E-Thot",
multiplier: 1.5,
+ worksRequired: 100,
phrases: [
"selling feet pics",
"enticing men",
@@ -60,6 +67,7 @@ export const Levels: ILevel = {
6: {
name: "CEO of FartBux",
multiplier: 2.2,
+ worksRequired: 200,
phrases: [
"providing value to your company",
"adding to the price of FartBux",
@@ -70,6 +78,7 @@ export const Levels: ILevel = {
7: {
name: "Jeff Bezos Jr.",
multiplier: 3,
+ worksRequired: 300,
phrases: [
"consuming the blood of children",
"using your dad's wealth to build your own",
@@ -80,6 +89,7 @@ export const Levels: ILevel = {
8: {
name: "Elon Musk, Lord and King of Mars",
multiplier: 5,
+ worksRequired: 500,
phrases: [
"inventing new particles such as Assium and Coomium",
"stealing your employees' ideas",
@@ -89,16 +99,20 @@ export const Levels: ILevel = {
}
};
-export const totalWorksToLevel = (works: number): number => {
- // let worksMap = [0, 10, 25, 50, 100, 200, 300, 500];
- if(works >= 500) return 8;
- else if(works >= 300) return 7;
- else if(works >= 200) return 6;
- else if(works >= 100) return 5;
- else if(works >= 50) return 4;
- else if(works >= 25) return 3;
- else if(works >= 10) return 2;
- else return 1;
-};
+function totalWorksToLevel(works: number) {
+ const entries = Object.entries(jobLevels)
+ .reverse()
+ .map(([l, o]) => ([Number(l), o])) as [number, JobLevelObj][];
-export const baseAward = 50;
+ for(const [lvl, { worksRequired }] of entries) {
+ if(works >= worksRequired)
+ return lvl;
+ }
+ return 1;
+}
+
+export {
+ baseAward,
+ jobLevels,
+ totalWorksToLevel,
+};
diff --git a/src/commands/fun/Cat.ts b/src/commands/fun/Cat.ts
index 86658ea..b664285 100644
--- a/src/commands/fun/Cat.ts
+++ b/src/commands/fun/Cat.ts
@@ -1,14 +1,14 @@
import { CommandInteraction, EmbedBuilder } from "discord.js";
import { randomItem } from "svcorelib";
import { AxiosError } from "axios";
-import { axios } from "@src/utils";
+import { axios, emojis } from "@src/utils";
import { Command } from "@src/Command";
import { settings } from "@src/settings";
const apiInfo = {
illusion: {
name: "KotAPI",
- url: "https://api.illusionman1212.tech/kotapi",
+ url: "https://api.illusionman1212.com/kotapi",
embedFooter: "https://github.com/IllusionMan1212/kotAPI"
},
};
@@ -18,7 +18,7 @@ const embedTitles = [
"Good cat",
"Aww, look at it",
"What a cutie",
- "<:qt_cett:610817939276562433>",
+ emojis.qtCett,
];
export class Cat extends Command
diff --git a/src/commands/fun/Cheese.ts b/src/commands/fun/Cheese.ts
index c314dfc..2c2663e 100644
--- a/src/commands/fun/Cheese.ts
+++ b/src/commands/fun/Cheese.ts
@@ -85,7 +85,7 @@ export class Cheese extends Command
ebdTitle = "**{NAME}**:";
}
- const { data, status, statusText } = await axios.get(`https://api.illusionman1212.tech/cheese${urlPath}${urlParams}`, { timeout: 10000 });
+ const { data, status, statusText } = await axios.get(`https://api.illusionman1212.com/cheese${urlPath}${urlParams}`, { timeout: 10000 });
if(status < 200 || status >= 300)
return await this.editReply(int, embedify(`Say Cheese is currently unreachable. Please try again later.\nStatus: ${status} - ${statusText}`, settings.embedColors.error));
diff --git a/src/commands/fun/Mock.ts b/src/commands/fun/Mock.ts
index 55e5347..acab096 100644
--- a/src/commands/fun/Mock.ts
+++ b/src/commands/fun/Mock.ts
@@ -1,5 +1,6 @@
import { ApplicationCommandOptionType, CommandInteraction } from "discord.js";
import { Command } from "@src/Command";
+import { emojis } from "@src/utils";
export class Mock extends Command
{
@@ -41,7 +42,7 @@ export class Mock extends Command
if(ephemeral)
return await this.reply(int, mockified, ephemeral);
- await channel.send(`<:mock:506303207400669204> ${mockified}`);
+ await channel.send(`${emojis.mock} ${mockified}`);
await this.reply(int, "Sent the message.", true);
}
diff --git a/src/commands/fun/Steam.ts b/src/commands/fun/Steam.ts
index d691c20..666e1c5 100644
--- a/src/commands/fun/Steam.ts
+++ b/src/commands/fun/Steam.ts
@@ -206,6 +206,7 @@ export class Steam extends Command
}
catch(err)
{
+ console.error("/steam error:", err);
await this.editReply(int, embedify("Can't connect to the Steam API. Please try again later.", settings.embedColors.error));
}
}
diff --git a/src/commands/index.ts b/src/commands/index.ts
index 9a5f6b0..d2c8c57 100644
--- a/src/commands/index.ts
+++ b/src/commands/index.ts
@@ -24,6 +24,7 @@ import { Emoji } from "@commands/util/Emoji";
import { Exec } from "@commands/util/Exec";
import { Help } from "@commands/util/Help";
import { Ping } from "@commands/util/Ping";
+import { Poll } from "@commands/util/Poll";
import { Reminder } from "@commands/util/Reminder";
import { Server } from "@commands/util/Server";
import { Translate } from "@commands/util/Translate";
@@ -87,6 +88,7 @@ export const commands = [
Exec,
Help,
Ping,
+ Poll,
Reminder,
Server,
Translate,
diff --git a/src/commands/mod/Log.ts b/src/commands/mod/Log.ts
index 82a0d8a..74b5077 100644
--- a/src/commands/mod/Log.ts
+++ b/src/commands/mod/Log.ts
@@ -4,7 +4,7 @@ import { Command } from "@src/Command";
import { settings } from "@src/settings";
import { PermissionFlagsBits } from "discord-api-types/v10";
import { createGuildSettings, createNewGuild, getGuild, getGuildSettings, setGuild } from "@src/database/guild";
-import { embedify, toUnix10 } from "@src/utils";
+import { embedify, toUnix10, emojis } from "@src/utils";
export class Log extends Command {
constructor() {
@@ -63,8 +63,8 @@ export class Log extends Command {
}
try {
- if (channel?.type === ChannelType.GuildText && typeof(logChannel?.send) === "function") {
-
+ const chanTypes = [ChannelType.GuildText, ChannelType.GuildNews, ChannelType.GuildPublicThread, ChannelType.GuildPrivateThread, ChannelType.GuildVoice];
+ if (chanTypes.includes(channel?.type)) {
if(!start) {
channel.messages.fetch({ limit: 1 }).then(messages => {
const lastMessage = messages?.first();
@@ -135,14 +135,14 @@ export class Log extends Command {
<@${message.author.id}> -
${message.embeds.length > 0 ? "(embed)" : (messageAttachmentString.length > 0 ? messageAttachmentString : "(other)")}
- > [show message <:open_in_browser:994648843331309589>](${message.url})`);
+ > [show message ${emojis.openInBrowser}](${message.url})`);
} else {
messageEmbedString += (`\
<@${message.author.id}> -
> ${message.embeds.length > 0 ? "(embed)" : (message.content && message.content.length > 0 ? message.content : "(other)")}
- > [show message <:open_in_browser:994648843331309589>](${message.url})`);
+ > [show message ${emojis.openInBrowser}](${message.url})`);
}
if(i === 10 || (10 * setNum) + i === messages.size) {
@@ -170,14 +170,19 @@ export class Log extends Command {
}
});
}).then(async () => {
- for await(const embed of messageSet) {
- gld.lastLogColor = String(newEmbedColor);
+ if(logChannel)
+ {
+ for await(const embed of messageSet) {
+ gld.lastLogColor = String(newEmbedColor);
- await setGuild(gld);
- logChannel.send({ embeds: [embed] });
- }
+ await setGuild(gld);
+ logChannel.send({ embeds: [embed] });
+ }
- return await this.editReply(int, embedify(`Successfully logged **${amount}** message${amount === 1 ? "" : "s"} to **#${logChannel.name}**`, settings.embedColors.default));
+ return this.editReply(int, embedify(`Successfully logged **${amount}** message${amount === 1 ? "" : "s"} to **#${logChannel.name}**`, settings.embedColors.default));
+ }
+ // TODO: ". or edit your guild settings in the [control panel.](https://brewbot.co/guild/123/settings)"
+ return this.editReply(int, embedify("Couldn't find the log channel. Please specify one with the argument `channel`.", settings.embedColors.error));
});
}
else
diff --git a/src/commands/mod/Warning.ts b/src/commands/mod/Warning.ts
index 6007119..e18a36b 100644
--- a/src/commands/mod/Warning.ts
+++ b/src/commands/mod/Warning.ts
@@ -2,7 +2,7 @@ import { CommandInteraction, GuildMember, EmbedBuilder, ApplicationCommandOption
import { Command } from "@src/Command";
import { settings } from "@src/settings";
import { PermissionFlagsBits } from "discord-api-types/v10";
-import { BtnMsg, embedify, PageEmbed, toUnix10, useEmbedify } from "@src/utils";
+import { BtnMsg, embedify, emojis, PageEmbed, toUnix10, useEmbedify } from "@src/utils";
import { addWarning, createNewMember, deleteWarnings, getMember, getWarnings } from "@src/database/users";
import { Warning as WarningObj } from "@prisma/client";
import { allOfType } from "svcorelib";
@@ -172,7 +172,7 @@ export class Warning extends Command
const headerEbd = new EmbedBuilder()
.setTitle("You've received a warning")
- .setDescription(`You have been warned in the server [${guild.name}](https://discord.com/channels/${guild.id})\n**Reason:** ${reason}\n\nYou have been warned ${allWarnings.length} time${allWarnings.length === 1 ? "" : "s"} in this server.`)
+ .setDescription(`You have been warned in the server [${guild.name}](https://discord.com/channels/${guild.id})\n**Reason:** ${reason}\n\n${allWarnings.length === 1 ? "This is your first warning" : `You have been warned ${allWarnings.length} times`} in this server.`)
.setFooter({ text: "If you have questions, please contact the moderators of the server." })
.setColor(settings.embedColors.warning);
@@ -240,7 +240,7 @@ export class Warning extends Command
bm.on("destroy", () => alertMsg.edit(bm.getMsgOpts()));
- alertMsg.react("<:banhammer:1015632683096871055>");
+ alertMsg.react(emojis.banHammer);
const coll = alertMsg.createReactionCollector({
filter: (re, usr) => {
diff --git a/src/commands/music/NowPlaying.ts b/src/commands/music/NowPlaying.ts
index 5ea43d8..09c9608 100644
--- a/src/commands/music/NowPlaying.ts
+++ b/src/commands/music/NowPlaying.ts
@@ -1,7 +1,7 @@
import { CommandInteraction, GuildMemberRoleManager, ButtonBuilder, User, ButtonStyle } from "discord.js";
import { Command } from "@src/Command";
import { getMusicManager } from "@src/lavalink/client";
-import { embedify, musicReadableTimeString, BtnMsg } from "@src/utils";
+import { embedify, musicReadableTimeString, BtnMsg, emojis } from "@src/utils";
import { formatDuration, parseDuration } from "svcorelib";
import { getPremium, isDJOnlyandhasDJRole } from "@src/database/music";
import { fetchSongInfo, resolveTitle } from "./global.music";
@@ -44,7 +44,7 @@ export class NowPlaying extends Command {
if(await getPremium(int.guild.id))
{
if(info?.url)
- lyricsLink = `Lyrics: [click to open <:open_in_browser:994648843331309589>](${info.url})\n`;
+ lyricsLink = `Lyrics: [click to open ${emojis.openInBrowser}](${info.url})\n`;
}
const embed = embedify(`Artist: \`${info?.meta.artists ?? current.author}\`\n${lyricsLink}\n\`${current.isStream ? formatDuration(player.position, "%h:%m:%s", true) : readableTime}\`\nRequested by: <@${(current.requester as User).id}>`)
diff --git a/src/commands/music/Queue.ts b/src/commands/music/Queue.ts
index ee67305..dd5f31b 100644
--- a/src/commands/music/Queue.ts
+++ b/src/commands/music/Queue.ts
@@ -56,7 +56,7 @@ export class Queue extends Command {
// return "";
// const lyricsUrl = await fetchLyricsUrl(title);
// if(lyricsUrl)
- // return ` - [lyrics <:open_in_browser:994648843331309589>](${lyricsUrl})\n`;
+ // return ` - [lyrics ${emojis.openInBrowser}](${lyricsUrl})\n`;
// return "";
// };
diff --git a/src/commands/util/Avatar.ts b/src/commands/util/Avatar.ts
index 5fab5fc..cc5aecc 100644
--- a/src/commands/util/Avatar.ts
+++ b/src/commands/util/Avatar.ts
@@ -49,7 +49,7 @@ export class Avatar extends Command
try
{
if(requestedAvUrl)
- status = (await axios.get(requestedAvUrl)).status;
+ status = (await axios.head(requestedAvUrl)).status;
}
catch(err)
{
diff --git a/src/commands/util/Define.ts b/src/commands/util/Define.ts
index 20ff24f..045a6d6 100644
--- a/src/commands/util/Define.ts
+++ b/src/commands/util/Define.ts
@@ -2,7 +2,7 @@ import { CommandInteraction, ButtonBuilder, EmbedBuilder, ButtonStyle, Applicati
import { embedify, useEmbedify } from "@utils/embedify";
import { Command } from "@src/Command";
import { settings } from "@src/settings";
-import { followRedirects, rankVotes, axios } from "@src/utils";
+import { followRedirects, rankVotes, axios, emojis } from "@src/utils";
import { Tuple } from "@src/types";
type WikiArticle = {
@@ -126,7 +126,7 @@ export class Define extends Command
author && thumbs_up && thumbs_down &&
embed.setFooter({
- text: `By ${trimLength(author, 32)} - 👍 ${thumbs_up} 👎 ${thumbs_down}`,
+ text: `👍 ${thumbs_up} 👎 ${thumbs_down}`,
iconURL: icons.urbandictionary,
});
@@ -278,7 +278,7 @@ export class Define extends Command
/** Returns an embed description for an emoji-choice-dialog */
emojiChoiceDesc(choices: { name: string, url?: string }[]): string
{
- return choices.map((a, i) => `${settings.emojiList[i]} **${a.name}**${a.url ? ` - [open <:open_in_browser:994648843331309589>](${a.url})` : ""}`).join("\n");
+ return choices.map((a, i) => `${settings.emojiList[i]} **${a.name}**${a.url ? ` - [open ${emojis.openInBrowser}](${a.url})` : ""}`).join("\n");
}
async findWikiArticle(int: CommandInteraction, articles: WikiArticle[])
@@ -360,9 +360,3 @@ export class Define extends Command
}
}
}
-
-/** Trims a `str`ing if it's longer than `len` (32 by default) and adds `trimChar` (`…` by default) */
-function trimLength(str: string, len = 32, trimChar = "…")
-{
- return str.length > len ? str.substring(0, len) + trimChar : str;
-}
diff --git a/src/commands/util/Emoji.ts b/src/commands/util/Emoji.ts
index 25bef1c..58e1b10 100644
--- a/src/commands/util/Emoji.ts
+++ b/src/commands/util/Emoji.ts
@@ -29,8 +29,9 @@ export class Emoji extends Command
{
const emoji = int.options.get("emoji", true).value as string;
+ // TODO: make this URL auto download the image somehow
const getEmUrl = (id: string, fmt: string) => `https://cdn.discordapp.com/emojis/${id}.${fmt}?size=4096&quality=lossless`;
- const trimmed = (str: string) => str.length > 16 ? str.substring(0, 16) + "+" : str;
+ const trimmed = (str: string) => str.length > 24 ? str.substring(0, 24) + "+" : str;
const embeds: EmbedBuilder[] = [];
const btns: ButtonBuilder[] = [];
diff --git a/src/commands/util/Poll.ts b/src/commands/util/Poll.ts
new file mode 100644
index 0000000..7242a6a
--- /dev/null
+++ b/src/commands/util/Poll.ts
@@ -0,0 +1,461 @@
+import { ApplicationCommandOptionType, ButtonBuilder, ButtonStyle, Client, Collection, CommandInteraction, CommandInteractionOption, EmbedBuilder, Message, PermissionFlagsBits, ReactionCollector, TextChannel } from "discord.js";
+import k from "kleur";
+import { Command } from "@src/Command";
+import { CreatePollModal } from "@src/modals/poll";
+import { autoPlural, BtnMsg, embedify, emojis, PageEmbed, toUnix10, truncStr, useEmbedify } from "@src/utils";
+import { settings } from "@src/settings";
+import { deletePoll, getActivePolls, getExpiredPolls, getPolls } from "@src/database/guild";
+import { Poll as PollObj } from "@prisma/client";
+import { getRedis } from "@src/redis";
+import { Tuple } from "@src/types";
+
+const redis = getRedis();
+
+/** Object that keeps track of one poll option's votes */
+interface PollOptionVotes {
+ msg: Message;
+ emoji: string;
+ option: string;
+ /** user ids that voted for this option */
+ votes: string[];
+}
+
+export class Poll extends Command
+{
+ private client: Client;
+ private reactionCollectors: { pollId: number, guildId: string, collectors: ReactionCollector[] }[] = [];
+
+ constructor(client: Client)
+ {
+ super({
+ name: "poll",
+ desc: "This command allows you to create reaction-based polls that users can vote on",
+ category: "util",
+ subcommands: [
+ {
+ name: "create",
+ desc: "Creates a poll in this channel",
+ args: [
+ {
+ name: "title",
+ desc: "The title of the poll message. This is different from the topic of the poll.",
+ type: ApplicationCommandOptionType.String,
+ },
+ {
+ name: "headline",
+ desc: "Enter pings (be mindful of who you ping) or extra explanatory text to notify users of this poll.",
+ type: ApplicationCommandOptionType.String,
+ },
+ {
+ name: "votes_per_user",
+ desc: "How many times a user is allowed to vote (1 time by default).",
+ type: ApplicationCommandOptionType.Number,
+ min: 1,
+ },
+ // TODO:
+ // {
+ // name: "allow_changing",
+ // desc: "Set this to False to disallow people to change their mind and choose another option.",
+ // type: ApplicationCommandOptionType.Boolean,
+ // },
+ ],
+ },
+ {
+ name: "list",
+ desc: "Lists all polls that are active on this server",
+ },
+ {
+ name: "delete",
+ desc: "Deletes an active poll",
+ args: [
+ {
+ name: "poll_id",
+ desc: "The numerical ID of the poll to delete. View IDs with /poll list",
+ type: ApplicationCommandOptionType.Number,
+ required: true,
+ },
+ ],
+ },
+ ],
+ });
+
+ this.client = client;
+
+ this.setupActivePolls();
+
+ try {
+ this.checkPolls();
+ // setInterval(() => this.checkPolls(), 2000);
+ setInterval(() => this.checkPolls(), 20000); //#DEBUG
+ }
+ catch(err) {
+ console.error(k.red("Error while checking polls:"), err);
+ }
+ }
+
+ /** Run once at startup to set up the reaction collectors */
+ async setupActivePolls()
+ {
+ const polls = await getActivePolls();
+ polls.forEach(async (poll) => {
+ const { votesPerUser, guildId, pollId, voteOptions } = poll;
+ const msgs = (await this.fetchPollMsgs(poll))?.map(m => m);
+ if(msgs)
+ {
+ const collectors = CreatePollModal.watchReactions(this.client.user!.id, msgs, voteOptions, votesPerUser);
+ this.reactionCollectors.push({ guildId, pollId, collectors });
+ }
+ });
+ }
+
+ async run(int: CommandInteraction, opt: CommandInteractionOption): Promise
+ {
+ let action = "";
+
+ try
+ {
+ const { guild, channel } = int;
+
+ if(!guild || !channel)
+ return this.reply(int, embedify("Please use this command in a server.", settings.embedColors.error), true);
+
+ switch(opt.name)
+ {
+ case "create":
+ {
+ action = "creating a new poll";
+
+ const headline = int.options.get("headline")?.value as string | undefined;
+ const votes_per_user = int.options.get("votes_per_user")?.value as number | undefined;
+ const title = int.options.get("title")?.value as string | undefined;
+
+ const redisKey = `poll-modal-data_${guild.id}_${int.user.id}`;
+
+ const modalData = await redis.get(redisKey);
+ const modal = new CreatePollModal(this.client.user!.id, headline, votes_per_user, title, modalData ? JSON.parse(modalData) : undefined);
+
+ modal.on("invalid", (data) => redis.set(redisKey, JSON.stringify(data)));
+ modal.on("deleteCachedData", () => redis.del(redisKey));
+ setTimeout(() => redis.del(redisKey), 1000 * 60 * 5);
+
+ return await int.showModal(modal.getInternalModal());
+ }
+ case "list":
+ {
+ action = "listing polls";
+
+ await this.deferReply(int);
+
+ const polls = await getPolls(guild.id);
+
+ if(!polls || polls.length === 0)
+ return this.editReply(int, embedify("Currently no polls are active on this server.\nYou can create a new one with `/poll create`", settings.embedColors.error));
+
+ const pollList = [...polls];
+ const pages: EmbedBuilder[] = [];
+ const pollsPerPage = 4;
+ const totalPages = Math.ceil(polls.length / pollsPerPage);
+
+ while(pollList.length > 0)
+ {
+ const pollSlice = pollList.splice(0, pollsPerPage);
+
+ const ebd = new EmbedBuilder()
+ .setTitle("Active polls")
+ .setDescription(polls.length != 1 ? `Currently there's ${polls.length} active ${autoPlural("poll", polls)}.` : "Currently there's only 1 active poll.")
+ .setColor(settings.embedColors.default)
+ .addFields({
+ name: "\u200B",
+ value: pollSlice.reduce((a, c) => ([
+ `${a}\n`,
+ `> **\`${c.pollId}\`** - by <@${c.createdBy}>${c.topic ? "" : ` in <#${c.channel}>`} - [show ${emojis.openInBrowser}](https://discord.com/channels/${c.guildId}/${c.channel}/${c.messages[0]})`,
+ ...(c.topic ? [`> **Topic:** ${truncStr(c.topic.replace(/[`]{3}\w*/gm, "").replace(/\n+/gm, " "), 80)}`] : ["> (no topic)"]),
+ `> Ends `,
+ ].join("\n")), ""),
+ });
+
+ totalPages > 1 && ebd.setFooter({ text: `(${pages.length + 1}/${totalPages}) - showing ${pollsPerPage} of ${polls.length} total ${autoPlural("poll", polls)}` });
+
+ pages.push(ebd);
+ }
+
+ if(pages.length > 1)
+ {
+ const pe = new PageEmbed(pages, int.user.id, {
+ allowAllUsersTimeout: 60 * 1000,
+ goToPageBtn: pages.length > 5,
+ });
+
+ return pe.useInt(int);
+ }
+ else
+ return this.editReply(int, pages[0]);
+ }
+ case "delete":
+ {
+ action = "deleting a poll";
+
+ await this.deferReply(int);
+
+ const pollId = int.options.get("poll_id", true).value as number;
+
+ const polls = await getPolls(guild.id);
+
+ const poll = polls.find(p => p.pollId === pollId);
+
+ if(!poll)
+ return this.editReply(int, embedify("Couldn't find a poll with this ID.\nUse `/poll list` to list all active polls with their IDs.", settings.embedColors.error));
+
+ const { user, memberPermissions } = int;
+
+ if(!memberPermissions?.has(PermissionFlagsBits.ManageChannels) && user.id !== poll.createdBy)
+ return this.editReply(int, embedify("You are not the author of this poll so you can't delete it.", settings.embedColors.error));
+
+ const pollMsgs = await this.fetchPollMsgs(poll);
+
+ if(!pollMsgs)
+ return this.editReply(int, embedify("You can only use this command in a server.", settings.embedColors.error));
+
+ const finalVotes = await this.accumulateVotes(pollMsgs.reduce((a, c) => { a.push(c as never); return a; }, []), poll);
+
+ const { peopleVoted, getReducedWinningOpts } = this.parseFinalVotes(finalVotes);
+
+ const btns: Tuple, 1> = [[
+ new ButtonBuilder()
+ .setStyle(ButtonStyle.Danger)
+ .setLabel("Delete")
+ .setEmoji("🗑️"),
+ new ButtonBuilder()
+ .setStyle(ButtonStyle.Secondary)
+ .setLabel("Cancel")
+ .setEmoji("❌"),
+ ]];
+
+ const bm = new BtnMsg(embedify(`You are about to delete a poll that ${peopleVoted} ${peopleVoted === 1 ? "person" : "people"} have voted on.\nAre you sure you want to delete the poll? This cannot be undone.`, settings.embedColors.warning), btns, { timeout: 30_000 });
+
+ bm.on("press", async (btn, btInt) => {
+ btInt.deferUpdate();
+
+ let embed: EmbedBuilder | undefined;
+
+ if(btInt.user.id !== int.user.id)
+ return;
+
+ if(btn.data.label === "Delete")
+ {
+ try
+ {
+ await Promise.all(pollMsgs.map(m => m.delete()));
+
+ await deletePoll(poll.guildId, poll.pollId);
+
+ embed = embedify(`The poll was successfully deleted.\nThe results were:\n${getReducedWinningOpts()}`);
+ }
+ catch(err)
+ {
+ embed = embedify("Couldn't delete the poll due to an error.");
+ }
+ }
+ else if(btn.data.label === "Cancel")
+ embed = embedify("Canceled deletion of the poll.");
+
+ bm.destroy();
+
+ embed && int.editReply({
+ ...bm.getReplyOpts(),
+ embeds: [embed],
+ });
+ });
+
+ int.editReply(bm.getReplyOpts());
+ return;
+ }
+ }
+ }
+ catch(err)
+ {
+ console.log(`Error while ${action}:\n`, err);
+
+ const errReply = useEmbedify(`Error while ${action}: ${err}`, settings.embedColors.error);
+
+ if(int.deferred || int.replied)
+ int.editReply(errReply);
+ else
+ int.reply(errReply);
+ }
+ }
+
+ async checkPolls()
+ {
+ const expPolls = await getExpiredPolls();
+
+ // can't Promise.all() else the Discord API could rate limit us, so instead use for ... await
+ for await(const poll of expPolls) {
+ const { guildId, pollId, channel, dueTimestamp: endTime } = poll;
+ const redisKey = `check_poll_${guildId}-${pollId}`;
+
+ this.reactionCollectors.forEach((c, i) => {
+ if(c.guildId === guildId && c.pollId === pollId)
+ this.reactionCollectors.splice(i, 1);
+ });
+
+ const hasKey = await redis.get(redisKey);
+ if(hasKey)
+ continue;
+
+ await redis.set(redisKey, 1);
+
+ // there is a tiny potential for the in-progress "delete jobs" cache to get
+ // out of whack so this timeout provides eventual consistency after a minute
+ // this problem only really occurs while debugging but who knows
+ const redisDelTimeout = setTimeout(() => redis.del(redisKey), 60_000);
+
+ try {
+ const msgs = await this.fetchPollMsgs(poll);
+
+ if(msgs && msgs.size > 0) {
+ const firstMsg = msgs.at(0)!;
+
+ const finalVotes = await this.accumulateVotes(msgs.reduce((a, c) => { a.push(c as never); return a; }, []), poll);
+
+ const startTime = firstMsg.createdAt;
+
+ await firstMsg.edit({
+ embeds: [ this.getConclusionEmbed(finalVotes, startTime, endTime, guildId, channel, firstMsg.id) ],
+ });
+
+ await this.sendConclusion(firstMsg, finalVotes, startTime, endTime);
+ }
+
+ await deletePoll(guildId, pollId);
+ }
+ catch(err) {
+ // TODO: add logging lib
+ console.error(`Error while checking poll with guildId=${guildId} and pollId=${pollId}:\n`, err);
+ }
+ finally {
+ clearTimeout(redisDelTimeout);
+ await redis.del(redisKey);
+ }
+ }
+ }
+
+ /** Fetches all messages of the provided poll */
+ async fetchPollMsgs({ guildId, channel, messages }: PollObj)
+ {
+ const gui = this.client.guilds.cache.find(g => g.id === guildId);
+ if(!gui) return;
+ const chan = (await gui.channels.fetch()).find(c => c.id === channel) as TextChannel | undefined;
+
+ const msgs = await chan?.messages.fetch({
+ // fetch around the centermost message
+ around: messages[Math.floor(messages.length / 2)],
+ // buffer for when the poll is sent in an active channel, as other members could send messages in between
+ limit: messages.length + 20,
+ });
+
+ return msgs
+ ?.filter(m => messages.includes(m.id)) // filter out all extra messages captured above
+ .sort((a, b) => a.createdTimestamp < b.createdTimestamp ? 1 : -1);
+ }
+
+ getConclusionEmbed(finalVotes: PollOptionVotes[], startTime: Date, endTime: Date, guildId: string, channelId: string, firstMsgId: string)
+ {
+ const {
+ finalVotesFields, peopleVoted, totalVotes, winningOptions, getReducedWinningOpts
+ } = this.parseFinalVotes(finalVotes);
+
+ return new EmbedBuilder()
+ .setTitle("Poll (closed)")
+ .setColor(settings.embedColors.default)
+ .setDescription([
+ `The poll has ended!\nIt ran from to `,
+ `${peopleVoted} people have voted ${totalVotes} times and this is what they selected:\n`,
+ `${getReducedWinningOpts(3)}${winningOptions.length > 3 ? `\n\nAnd ${winningOptions.length - 3} more.` : ""}\n`,
+ `[Click here](https://discord.com/channels/${guildId}/${channelId}/${firstMsgId}) to view the full poll.`,
+ ].join("\n"))
+ .addFields(finalVotesFields);
+ }
+
+ parseFinalVotes(finalVotes: PollOptionVotes[])
+ {
+ const finalVotesFields = CreatePollModal.reduceOptionFields(finalVotes.map(v => `${v.option} - **${v.votes.length}**`, true));
+
+ const scoreColl = new Collection();
+ finalVotes.forEach(({ votes }) => {
+ votes.forEach(user => {
+ if(!scoreColl.has(user))
+ scoreColl.set(user, 1);
+ else
+ scoreColl.set(user, scoreColl.get(user)! + 1);
+ });
+ });
+
+ let peopleVoted = 0, totalVotes = 0;
+
+ scoreColl.forEach((score) => {
+ peopleVoted++;
+ totalVotes += score;
+ });
+
+ const sortedVotes = finalVotes.sort((a, b) => a.votes.length === b.votes.length ? 0 : (a.votes.length < b.votes.length ? 1 : -1));
+ const winningOptions = sortedVotes.filter(v => v.votes.length === sortedVotes[0].votes.length);
+
+ const getReducedWinningOpts = (limit?: number) =>
+ winningOptions.reduce((a, c, i) => `${a}${
+ limit === undefined || i < limit
+ ? `\n> ${c.emoji} \u200B ${c.option}${winningOptions.length > 1 ? `\nWith **${c.votes} ${autoPlural("vote", c.votes)}**` : ""}`
+ : ""
+ }`, "");
+
+ return {
+ finalVotesFields,
+ scoreColl,
+ peopleVoted,
+ totalVotes,
+ sortedVotes,
+ winningOptions,
+ /** Call to get a markdown string of the winning options. Default limit (undefined) = list all */
+ getReducedWinningOpts,
+ };
+ }
+
+ sendConclusion(msg: Message, finalVotes: PollOptionVotes[], startTime: Date, endTime: Date)
+ {
+ const { peopleVoted, getReducedWinningOpts } = this.parseFinalVotes(finalVotes);
+
+ return msg.reply({ embeds: [
+ new EmbedBuilder()
+ .setColor(settings.embedColors.default)
+ .setTitle("This poll has ended.")
+ .setDescription(`It ran from to \n${peopleVoted} people have voted and these are the results:\n${getReducedWinningOpts()}`)
+ ]});
+ }
+
+ /** Takes in Message instances to accumulate the provided reaction emoji votes on */
+ async accumulateVotes(msgs: Message[], { voteOptions }: PollObj): Promise
+ {
+ const votes: PollOptionVotes[] = [];
+ const voteOpts: { opt: string, emoji: string }[] = voteOptions.map((opt, i) => ({ opt, emoji: settings.emojiList[i] }));
+
+ for await(const msg of msgs) {
+ for await(const [em, re] of [...msg.reactions.cache.entries()]) {
+ const voteOpt = voteOpts.find(vo => vo.emoji === em);
+ if(voteOpt)
+ {
+ if(!votes.find(v => v.emoji === em))
+ votes.push({
+ emoji: voteOpt.emoji,
+ msg: re.message as Message,
+ option: voteOpt.opt,
+ votes: (await re.users.fetch())
+ .filter(u => !u.bot && !u.system)
+ .map(u => u.id),
+ });
+ }
+ }
+ }
+
+ return votes;
+ }
+}
diff --git a/src/commands/util/Reminder.ts b/src/commands/util/Reminder.ts
index d966115..8d77458 100644
--- a/src/commands/util/Reminder.ts
+++ b/src/commands/util/Reminder.ts
@@ -5,16 +5,21 @@ import { settings } from "@src/settings";
import { time } from "@discordjs/builders";
import { createNewUser, deleteReminder, deleteReminders, getExpiredReminders, getReminder, getReminders, getUser, setReminder } from "@src/database/users";
import { Reminder as ReminderObj } from "@prisma/client";
-import { BtnMsg, embedify, PageEmbed, toUnix10, useEmbedify } from "@src/utils";
+import { autoPlural, BtnMsg, embedify, PageEmbed, timeToMs, toUnix10, useEmbedify } from "@src/utils";
import { Tuple } from "@src/types";
/** Max reminders per user (global) */
const reminderLimit = 10;
-
-type TimeObj = Record<"days"|"hours"|"minutes"|"seconds"|"months"|"years", number>;
+const reminderCheckInterval = 2000;
export class Reminder extends Command
{
+ /**
+ * Contains all compound keys of reminders that are currently being checked.
+ * Format: `userId-reminderId`
+ */
+ private reminderCheckBuffer = new Set();
+
constructor(client: Client)
{
super({
@@ -32,6 +37,11 @@ export class Reminder extends Command
type: ApplicationCommandOptionType.String,
required: true,
},
+ {
+ name: "private",
+ desc: "Set to True to hide this reminder from other people",
+ type: ApplicationCommandOptionType.Boolean,
+ },
{
name: "seconds",
desc: "In how many seconds",
@@ -101,14 +111,12 @@ export class Reminder extends Command
if(client instanceof Client)
{
- try
- {
+ try {
// since the constructor is called exactly once at startup, this should work just fine
this.checkReminders(client);
- setInterval(() => this.checkReminders(client), 1000);
+ setInterval(() => this.checkReminders(client), reminderCheckInterval);
}
- catch(err)
- {
+ catch(err) {
console.error(k.red("Error while checking reminders:"), err);
}
}
@@ -116,15 +124,6 @@ export class Reminder extends Command
async run(int: CommandInteraction, opt: CommandInteractionOption<"cached">)
{
- const getTime = (timeObj: TimeObj) => {
- return 1000 * timeObj.seconds
- + 1000 * 60 * timeObj.minutes
- + 1000 * 60 * 60 * timeObj.hours
- + 1000 * 60 * 60 * 24 * timeObj.days
- + 1000 * 60 * 60 * 24 * 30 * timeObj.months
- + 1000 * 60 * 60 * 24 * 365 * timeObj.years;
- };
-
const { user, guild, channel } = int;
let action = "";
@@ -139,6 +138,7 @@ export class Reminder extends Command
const args = {
name: int.options.get("name", true).value as string,
+ ephemeral: int.options.get("private")?.value as boolean ?? false,
seconds: int.options.get("seconds")?.value as number ?? 0,
minutes: int.options.get("minutes")?.value as number ?? 0,
hours: int.options.get("hours")?.value as number ?? 0,
@@ -147,19 +147,19 @@ export class Reminder extends Command
years: int.options.get("years")?.value as number ?? 0,
};
- const { name, ...timeObj } = args;
+ const { name, ephemeral, ...timeObj } = args;
- const dueInMs = getTime(timeObj);
+ const dueInMs = timeToMs(timeObj);
if(dueInMs < 1000 * 5)
return await this.reply(int, embedify("Please enter at least five seconds.", settings.embedColors.error), true);
- await this.deferReply(int, false);
+ await this.deferReply(int, ephemeral);
const reminders = await getReminders(user.id);
if(reminders && reminders.length >= reminderLimit)
- return await int.editReply(useEmbedify("Sorry, but you can't set more than 10 reminders.\nPlease free up some space with `/reminder delete`", settings.embedColors.error));
+ return await int.editReply(useEmbedify("Sorry, but you can't set more than 10 reminders.\nPlease wait until a reminder expires or free up some space with `/reminder delete`", settings.embedColors.error));
const reminderId = reminders && reminders.length > 0 && reminders.at(-1)
? reminders.at(-1)!.reminderId + 1
@@ -177,9 +177,10 @@ export class Reminder extends Command
channel: channel?.id ?? null,
reminderId,
dueTimestamp,
+ private: guild?.id ? ephemeral : true,
});
- return await this.editReply(int, embedify(`I've set a reminder with the name \`${name}\`\nDue: ${time(toUnix10(dueTimestamp), "f")}\n\nTo list your reminders, use \`/reminder list\``, settings.embedColors.success));
+ return await this.editReply(int, embedify(`I've set a reminder with the name \`${name}\` (ID \`${reminderId}\`)\nDue: ${time(toUnix10(dueTimestamp), "f")}\n\nTo list your reminders, use \`/reminder list\``, settings.embedColors.success));
}
case "list":
{
@@ -254,13 +255,15 @@ export class Reminder extends Command
return await int.editReply(useEmbedify(`Successfully deleted the reminder \`${name}\``, settings.embedColors.default));
};
- if(remIdent.match(/d+/))
+ const notFound = () => int.editReply(useEmbedify("Couldn't find a reminder with this name.\nUse `/reminder list` to list all your reminders and their IDs.", settings.embedColors.error));
+
+ if(remIdent.match(/\d+/))
{
const remId = parseInt(remIdent);
const rem = await getReminder(remId, user.id);
if(!rem)
- return await int.editReply(useEmbedify("Couldn't find a reminder with this ID.\nUse `/reminder list` to list all your reminders and their IDs.", settings.embedColors.error));
+ return notFound();
return deleteRem(rem);
}
@@ -268,8 +271,6 @@ export class Reminder extends Command
{
const rems = await getReminders(user.id);
- const notFound = () => int.editReply(useEmbedify("Couldn't find a reminder with this name.\nUse `/reminder list` to list all your reminders and their IDs.", settings.embedColors.error));
-
if(!rems || rems.length === 0)
return notFound();
@@ -301,8 +302,8 @@ export class Reminder extends Command
const cont = embedify(`Are you sure you want to delete ${rems.length > 1 ? `all ${rems.length} reminders` : "your 1 reminder"}?\nThis action cannot be undone.`, settings.embedColors.warning);
const btns: Tuple, 1> = [[
- new ButtonBuilder().setLabel("Delete all").setStyle(ButtonStyle.Danger).setEmoji("🗑️"),
- new ButtonBuilder().setLabel("Cancel").setStyle(ButtonStyle.Secondary).setEmoji("❌"),
+ new ButtonBuilder().setLabel(`Delete ${autoPlural("reminder", rems.length)}`).setStyle(ButtonStyle.Danger).setEmoji("🗑️"),
+ new ButtonBuilder().setLabel("Cancel").setStyle(ButtonStyle.Secondary),
]];
const bm = new BtnMsg(cont, btns, {
@@ -313,7 +314,7 @@ export class Reminder extends Command
if(bt.data.label === btns.flat().find(b => b.data.label)?.data.label)
{
await deleteReminders(rems.map(r => r.reminderId), user.id);
- await btInt.reply({ ...useEmbedify("Deleted all reminders.", settings.embedColors.default), ephemeral: true });
+ await btInt.reply({ ...useEmbedify(`${rems.length > 1 ? `All ${rems.length} reminders have` : "Your 1 reminder has"} been deleted successfully.`, settings.embedColors.default), ephemeral: true });
}
else
await btInt.reply({ ...useEmbedify("Canceled deletion.", settings.embedColors.default), ephemeral: true });
@@ -353,38 +354,57 @@ export class Reminder extends Command
const getExpiredEbd = ({ name }: ReminderObj) => new EmbedBuilder()
.setTitle("Reminder")
.setColor(settings.embedColors.default)
- .setDescription(`Your reminder with the name \`${name}\` has expired!`);
+ .setDescription(`The following reminder has expired:\n>>> ${name}`);
+
+ const reminderError = (err: Error) => console.error(k.red("Error while checking reminders:\n"), err);
+
+ const guildFallback = async (rem: ReminderObj) => {
+ try {
+ if(rem.private)
+ throw new Error("Can't send message in guild as reminder is private.");
- const guildFallback = (rem: ReminderObj) => {
- try
- {
const guild = client.guilds.cache.find(g => g.id === rem.guild);
const chan = guild?.channels.cache.find(c => c.id === rem.channel);
- if(chan && [ChannelType.GuildText, ChannelType.GuildPublicThread, ChannelType.GuildPrivateThread].includes(chan.type))
+ if(chan && [ChannelType.GuildText, ChannelType.GuildPublicThread, ChannelType.GuildPrivateThread, ChannelType.GuildForum].includes(chan.type))
{
const c = chan as TextBasedChannel;
c.send({ embeds: [ getExpiredEbd(rem) ] });
}
}
- catch(err)
- {
+ catch(err) {
// TODO: track reminder "loss rate"
//
- // I │ I I
- // ────┼────
- // I I │ I ⌐¬
+ // I │ I I
+ // ─────┼─────
+ // I I │ I ⌐¬
+
+ err instanceof Error && reminderError(err);
void err;
}
- finally
- {
- deleteReminder(rem.reminderId, rem.userId);
+ finally {
+ try {
+ await deleteReminder(rem.reminderId, rem.userId);
+ }
+ catch(err) {
+ // TODO: see above
+
+ err instanceof Error && reminderError(err);
+ }
+ finally {
+ this.reminderCheckBuffer.delete(`${rem.userId}-${rem.reminderId}`);
+ }
}
};
for(const rem of expRems)
{
+ if(this.reminderCheckBuffer.has(`${rem.userId}-${rem.reminderId}`))
+ continue;
+
+ this.reminderCheckBuffer.add(`${rem.userId}-${rem.reminderId}`);
+
const usr = client.users.cache.find(u => u.id === rem.userId);
promises.push((async () => {
@@ -393,7 +413,7 @@ export class Reminder extends Command
try
{
- const dm = await usr.createDM(true);
+ const dm = await usr.createDM(rem.private);
const msg = await dm.send({ embeds: [ getExpiredEbd(rem) ]});
@@ -401,6 +421,7 @@ export class Reminder extends Command
return guildFallback(rem);
await deleteReminder(rem.reminderId, rem.userId);
+ this.reminderCheckBuffer.delete(`${rem.userId}-${rem.reminderId}`);
}
catch(err)
{
diff --git a/src/commands/util/Server.ts b/src/commands/util/Server.ts
index 3730111..5f7d540 100644
--- a/src/commands/util/Server.ts
+++ b/src/commands/util/Server.ts
@@ -1,6 +1,7 @@
import { CommandInteraction, CommandInteractionOption, EmbedField, EmbedBuilder, ChannelType, PermissionFlagsBits } from "discord.js";
import { Command } from "@src/Command";
import { settings } from "@src/settings";
+import { toUnix10 } from "@src/utils";
export class Server extends Command
{
@@ -57,7 +58,7 @@ export class Server extends Command
guild.description && guild.description.length > 0 && fields.push({ name: "Description", value: guild.description, inline: true });
fields.push({ name: "Owner", value: `<@${guild.ownerId}>`, inline: true });
- fields.push({ name: "Created", value: guild.createdAt.toUTCString(), inline: true });
+ fields.push({ name: "Created", value: ``, inline: true });
const verifLevel = verifLevelMap[guild.verificationLevel];
fields.push({ name: "Verification level", value: verifLevel, inline: true });
@@ -66,7 +67,7 @@ export class Server extends Command
const botMembers = guild.members.cache.filter(m => m.user.bot).size ?? undefined;
const onlineMembers = botMembers ? guild.members.cache.filter(m => (!m.user.bot && ["online", "idle", "dnd"].includes(m.presence?.status ?? "_"))).size : undefined;
- const publicTxtChannelsAmt = guild.channels.cache.filter(ch => [ChannelType.GuildNews, ChannelType.GuildText].includes(ch.type) && ch.permissionsFor(guild.roles.everyone).has(PermissionFlagsBits.ViewChannel)).size;
+ const publicTxtChannelsAmt = guild.channels.cache.filter(ch => [ChannelType.GuildText, ChannelType.GuildPublicThread, ChannelType.GuildPrivateThread, ChannelType.GuildForum].includes(ch.type) && ch.permissionsFor(guild.roles.everyone).has(PermissionFlagsBits.ViewChannel)).size;
const publicVoiceChannelsAmt = guild.channels.cache.filter(ch => ch.type === ChannelType.GuildVoice && ch.permissionsFor(guild.roles.everyone).has(PermissionFlagsBits.Speak)).size;
let memberCount = `Total: ${allMembers}`;
diff --git a/src/commands/util/Translate.ts b/src/commands/util/Translate.ts
index 74b8aba..387eb5b 100644
--- a/src/commands/util/Translate.ts
+++ b/src/commands/util/Translate.ts
@@ -5,9 +5,13 @@ import { Command } from "@src/Command";
import { axios, embedify } from "@src/utils";
import { settings } from "@src/settings";
import languages from "@assets/languages.json";
+import { isArrayEmpty } from "svcorelib";
export class Translate extends Command
{
+ readonly LANGS = Object.entries(languages)
+ .map(([name, code]) => ({ name, code }));
+
constructor()
{
super({
@@ -15,6 +19,12 @@ export class Translate extends Command
desc: "Translate text",
category: "util",
args: [
+ {
+ name: "language",
+ desc: "English name of the language to translate to",
+ type: ApplicationCommandOptionType.String,
+ required: true,
+ },
{
name: "text",
desc: "The text to translate",
@@ -22,12 +32,12 @@ export class Translate extends Command
required: true,
},
{
- name: "language",
- desc: "English name of the language to translate to",
+ name: "input_language",
+ desc: "Language of the text to translate. Leave empty to auto-detect.",
type: ApplicationCommandOptionType.String,
- required: true,
- }
- ]
+ required: false,
+ },
+ ],
});
}
@@ -35,11 +45,9 @@ export class Translate extends Command
{
const text = (int.options.get("text", true).value as string).trim();
const lang = (int.options.get("language", true).value as string).trim();
+ const inLang = (int.options.get("input_language")?.value as string | null)?.trim();
- const langs = Object.entries(languages)
- .map(([name, code]) => ({ name, code }));
-
- const fuse = new Fuse(langs, {
+ const fuse = new Fuse(this.LANGS, {
keys: [ "name" ],
threshold: 0.5,
});
@@ -53,7 +61,7 @@ export class Translate extends Command
await this.deferReply(int);
- const tr = await this.getTranslation(text, resLang.code);
+ const tr = await this.getTranslation(text, resLang.code, inLang);
if(!tr)
return await this.editReply(int, embedify("Couldn't find a translation for that", settings.embedColors.error));
@@ -71,16 +79,16 @@ export class Translate extends Command
return await this.editReply(int, ebd);
}
- async getTranslation(text: string, targetLang: string): Promise<{ fromLang: string, translation: string } | null>
+ async getTranslation(text: string, targetLang: string, inLang = "auto")
{
try
{
- const { data, status } = await axios.get(`https://translate.googleapis.com/translate_a/single?sl=auto&tl=${targetLang}&q=${encodeURI(text)}&client=gtx&dt=t&ie=UTF-8&oe=UTF-8`);
+ const { data, status } = await axios.get(`https://translate.googleapis.com/translate_a/single?sl=${inLang}&tl=${targetLang}&q=${encodeURI(text)}&client=gtx&dt=t&ie=UTF-8&oe=UTF-8`);
if(status < 200 || status >= 300)
return null;
- const fromLang = data?.[2];
+ const fromLang = data?.[2] as string | undefined;
let trParts = data?.[0];
if(!fromLang || !trParts)
@@ -89,10 +97,13 @@ export class Translate extends Command
if(trParts.length > 1)
trParts = trParts.map((p: string[]) => p?.[0]);
else
- trParts = [trParts?.[0]];
+ trParts = [trParts?.[0]?.[0]];
+
+ if(isArrayEmpty(trParts) === true)
+ return null;
- const translation = trParts
- .filter((p: unknown) => typeof p === "string")
+ const translation = (trParts as unknown[])
+ .filter(p => typeof p === "string")
.join("").trim();
return { fromLang, translation };
diff --git a/src/commands/util/Whois.ts b/src/commands/util/Whois.ts
index 7607068..eddb314 100644
--- a/src/commands/util/Whois.ts
+++ b/src/commands/util/Whois.ts
@@ -78,8 +78,6 @@ export class Whois extends Command
goToPageBtn: pages.length > 5,
});
- pe.on("error", (err) => console.log(err));
-
await pe.useInt(int);
}
else
diff --git a/src/database/guild.ts b/src/database/guild.ts
index ea4c92f..d38c900 100644
--- a/src/database/guild.ts
+++ b/src/database/guild.ts
@@ -1,5 +1,5 @@
import { prisma } from "@database/client";
-import { Guild, GuildSettings } from "@prisma/client";
+import { Guild, GuildSettings, Poll } from "@prisma/client";
//#MARKER guild
@@ -96,3 +96,73 @@ export function getMultipleGuildSettings(guildIds: string[])
},
});
}
+
+//#SECTION polls
+
+export function getPolls(guildId: string)
+{
+ return prisma.poll.findMany({
+ where: {
+ guildId,
+ },
+ });
+}
+
+export function getPoll(guildId: string, pollId: number)
+{
+ return prisma.poll.findUnique({
+ where: {
+ pollId_guildId: {
+ guildId,
+ pollId,
+ },
+ },
+ });
+}
+
+export function getActivePolls()
+{
+ return prisma.poll.findMany({
+ where: {
+ dueTimestamp: {
+ gt: new Date(),
+ },
+ },
+ orderBy: {
+ dueTimestamp: "asc",
+ },
+ });
+}
+
+export function getExpiredPolls()
+{
+ return prisma.poll.findMany({
+ where: {
+ dueTimestamp: {
+ lt: new Date(),
+ },
+ },
+ orderBy: {
+ dueTimestamp: "asc",
+ },
+ });
+}
+
+export function createNewPoll(poll: Poll)
+{
+ return prisma.poll.create({
+ data: poll,
+ });
+}
+
+export function deletePoll(guildId: string, pollId: number)
+{
+ return prisma.poll.delete({
+ where: {
+ pollId_guildId: {
+ guildId,
+ pollId,
+ },
+ },
+ });
+}
diff --git a/src/lavalink/lib/trackStart.ts b/src/lavalink/lib/trackStart.ts
index 6527759..99ebf03 100644
--- a/src/lavalink/lib/trackStart.ts
+++ b/src/lavalink/lib/trackStart.ts
@@ -3,6 +3,7 @@ import { Player, Track } from "erela.js";
import { embedify } from "@utils/embedify";
import { fetchSongInfo, formatTitle, resolveTitle } from "@src/commands/music/global.music";
import { getPremium } from "@src/database/music";
+import { emojis } from "@src/utils";
export async function trackStart(player: Player, track: Track, client: Client) {
if(!player.textChannel || !player.voiceChannel) return;
@@ -18,7 +19,7 @@ export async function trackStart(player: Player, track: Track, client: Client) {
{
const lyrics = await fetchSongInfo(resolveTitle(track.title));
if(lyrics?.url)
- lyricsLink = `Lyrics: [click to open <:open_in_browser:994648843331309589>](${lyrics.url})\n`;
+ lyricsLink = `Lyrics: [click to open ${emojis.openInBrowser}](${lyrics.url})\n`;
}
(channel as TextChannel).send({
diff --git a/src/modals/exec.ts b/src/modals/exec.ts
index aaa97b2..f077a4a 100644
--- a/src/modals/exec.ts
+++ b/src/modals/exec.ts
@@ -4,7 +4,7 @@ import { transpile } from "typescript";
import { Modal } from "@utils/Modal";
import { settings } from "@src/settings";
-import { truncField, } from "@src/utils";
+import { embedify, truncField } from "@src/utils";
export class ExecModal extends Modal
{
@@ -18,7 +18,7 @@ export class ExecModal extends Modal
new TextInputBuilder()
.setCustomId("code")
.setLabel("Code")
- .setPlaceholder("Any TS or CJS code\n(Vars: channel, user, guild, client, and important stuff from D.JS and utils)")
+ .setPlaceholder("Any TS or CJS code\nImport with \"await import()\", relative to src/modals\nReturn to display result")
.setStyle(TextInputStyle.Paragraph)
.setRequired(true),
]
@@ -28,112 +28,124 @@ export class ExecModal extends Modal
}
async submit(int: ModalSubmitInteraction<"cached">): Promise {
- const { channel, user, guild, client } = int;
-
- unused(channel, user, guild, client);
-
- const code = int.fields.getTextInputValue("code").trim();
-
- await this.deferReply(int, this.ephemeral);
-
- let result, error;
- try
- {
- const lines = [
- "import { EmbedBuilder, ButtonBuilder, Colors, CommandInteraction, ButtonInteraction, Collection, User, Member, Guild } from \"discord.js\";",
- "import { BtnMsg, PageEmbed, embedify, useEmbedify, toUnix10, truncStr, truncField } from \"../utils/\";",
- "(async () => {",
- code,
- "})();",
- ];
-
- const jsCode = transpile(lines.join("\n"), {
- allowJs: true,
- allowSyntheticDefaultImports: true,
- allowUnreachableCode: true,
- esModuleInterop: true,
- experimentalDecorators: true,
- importHelpers: true,
- resolveJsonModule: true,
- strict: false,
- });
-
- result = eval(jsCode);
-
- if(result instanceof Promise)
- result = await result;
+ try {
+ const { channel, user, guild, client } = int;
+
+ unused(channel, user, guild, client);
+
+ const code = int.fields.getTextInputValue("code").trim();
+
+ await this.deferReply(int, this.ephemeral);
+
+ let result, error;
+ try
+ {
+ const lines = [
+ "import { EmbedBuilder, ButtonBuilder, Colors, CommandInteraction, ButtonInteraction, Collection, User, Member, Guild } from \"discord.js\";",
+ "import { BtnMsg, PageEmbed, embedify, useEmbedify, toUnix10, truncStr, truncField } from \"../utils/\";",
+ "(async () => {",
+ code,
+ "})();",
+ ];
+
+ const jsCode = transpile(lines.join("\n"), {
+ allowJs: true,
+ allowSyntheticDefaultImports: true,
+ allowUnreachableCode: true,
+ esModuleInterop: true,
+ experimentalDecorators: true,
+ importHelpers: true,
+ resolveJsonModule: true,
+ strict: false,
+ });
+
+ result = eval(jsCode);
+
+ if(result instanceof Promise)
+ result = await result;
+ }
+ catch(err)
+ {
+ if(err instanceof Error)
+ error = `${err.name}: ${err.message}\n${err.stack}`;
+ else
+ error = String(err);
+ }
+
+ const ebd = new EmbedBuilder()
+ .setTitle(`Execution ${error ? "Error" : "Result"}`)
+ .setColor(error ? settings.embedColors.error : settings.embedColors.success);
+
+ if(!error)
+ {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const findType = (val: any) => {
+ const primitives = [ "bigint", "boolean", "function", "number", "object", "string", "symbol", "undefined" ];
+
+ const cName = val?.constructor?.name;
+
+ for(const pr of primitives)
+ if(typeof val === pr)
+ return primitives.includes(cName?.toLowerCase() ?? "_") ? pr : cName;
+
+ return "unknown";
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const transformRes = (result: any) => {
+ const bufTruncLen = 16;
+
+ if(result instanceof Buffer)
+ result = ` i < bufTruncLen ?
+ (a + ((i > 0 ? " " : "") + c.toString(16)))
+ : (i === bufTruncLen ? a + " ..." : a),
+ "")
+ }>\n\nStringified:\n${result.toString()}`;
+
+ if(typeof result === "string" || String(result).length > 1000)
+ return truncField(String(result));
+ else return result;
+ };
+
+ const resStr = String(result).trim();
+
+ ebd.addFields([
+ {
+ name: `Result <\`${findType(result)}\`>:`,
+ value: `\`\`\`\n${resStr.length > 0 ? transformRes(result) : "(empty)"}\n\`\`\``,
+ inline: false
+ },
+ {
+ name: "Code:",
+ value: `\`\`\`ts\n${code}\n\`\`\``,
+ inline: false
+ }
+ ]);
+ }
+ else
+ ebd.addFields([
+ {
+ name: "Error:",
+ value: `\`\`\`\n${truncField(String(error))}\n\`\`\``,
+ inline: false
+ },
+ {
+ name: "Code:",
+ value: `\`\`\`ts\n${code}\n\`\`\``,
+ inline: false
+ }
+ ]);
+
+ await this.editReply(int, ebd);
}
catch(err)
{
- if(err instanceof Error)
- error = `${err.name}: ${err.message}\n${err.stack}`;
- else
- error = String(err);
- }
+ const ebd = embedify(`Couldn't exec due to an error: ${err}`, settings.embedColors.error);
- const ebd = new EmbedBuilder()
- .setTitle(`Execution ${error ? "Error" : "Result"}`)
- .setColor(error ? settings.embedColors.error : settings.embedColors.success);
-
- if(!error)
- {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const findType = (val: any) => {
- const primitives = [ "bigint", "boolean", "function", "number", "object", "string", "symbol", "undefined" ];
-
- const cName = val?.constructor?.name;
-
- for(const pr of primitives)
- if(typeof val === pr)
- return primitives.includes(cName?.toLowerCase() ?? "_") ? pr : cName;
-
- return "unknown";
- };
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const transformRes = (result: any) => {
- const bufTruncLen = 16;
-
- if(result instanceof Buffer)
- result = ` i < bufTruncLen ?
- (a + ((i > 0 ? " " : "") + c.toString(16)))
- : (i === bufTruncLen ? a + " ..." : a),
- "")
- }>\n\nStringified:\n${result.toString()}`;
-
- return truncField(result);
- };
-
- const resStr = String(result).trim();
-
- ebd.addFields([
- {
- name: `Result <\`${findType(result)}\`>:`,
- value: `\`\`\`\n${resStr.length > 0 ? transformRes(result) : "(empty)"}\n\`\`\``,
- inline: false
- },
- {
- name: "Code:",
- value: `\`\`\`ts\n${code}\n\`\`\``,
- inline: false
- }
- ]);
+ if(int.replied || int.deferred)
+ return this.editReply(int, ebd);
+ return this.reply(int, ebd);
}
- else
- ebd.addFields([
- {
- name: "Error:",
- value: `\`\`\`\n${truncField(error)}\n\`\`\``,
- inline: false
- },
- {
- name: "Code:",
- value: `\`\`\`ts\n${code}\n\`\`\``,
- inline: false
- }
- ]);
-
- await this.editReply(int, ebd);
}
}
diff --git a/src/modals/poll.ts b/src/modals/poll.ts
new file mode 100644
index 0000000..d9634bc
--- /dev/null
+++ b/src/modals/poll.ts
@@ -0,0 +1,294 @@
+import { EmbedBuilder, EmbedField, Message, MessageOptions, ModalSubmitInteraction, ReactionCollector, TextInputBuilder, TextInputStyle } from "discord.js";
+
+import { Modal } from "@utils/Modal";
+import { settings } from "@src/settings";
+import { embedify, zeroPad } from "@src/utils";
+import { createNewGuild, createNewPoll, getGuild, getPolls } from "@src/database/guild";
+import { halves } from "svcorelib";
+import { Tuple } from "@src/types";
+
+interface PollModalData {
+ topic: string | null;
+ expiry: string;
+ voteOptions: string[];
+}
+
+export interface CreatePollModal {
+ /** Emitted on error and unhandled Promise rejection */
+ on(event: "error", listener: (err: Error) => void): this;
+ /** Gets emitted when this modal has finished submitting and needs to be deleted from the registry */
+ on(event: "destroy", listener: (btnIds: string[]) => void): this;
+ /** Emitted when the user submits the modal but it is invalid */
+ on(evt: "invalid", listener: (modalData: PollModalData, pollId: string) => void): this;
+ /** Emitted when the cached modal data should be deleted */
+ on(evt: "deleteCachedData", listener: () => void): this;
+}
+
+export class CreatePollModal extends Modal
+{
+ private clientId;
+
+ private headline;
+ private votesPerUser;
+ private title;
+
+ public reactionCollectors: ReactionCollector[] = [];
+
+ constructor(clientId: string, headline?: string, votesPerUser = 1, title?: string, modalData?: PollModalData)
+ {
+ const p = zeroPad, d = new Date();
+ const defaultExpiry = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
+
+ super({
+ title: "Create a poll",
+ inputs: [
+ new TextInputBuilder()
+ .setCustomId("topic")
+ .setLabel("Topic of the poll")
+ .setPlaceholder("The topic of the poll.\nLeave empty if you sent your own message.\nSupports Discord's Markdown.")
+ .setStyle(TextInputStyle.Paragraph)
+ .setMaxLength(250)
+ .setRequired(false)
+ .setValue(modalData?.topic ?? ""),
+ new TextInputBuilder()
+ .setCustomId("expiry_date_time")
+ .setLabel("Poll end date and time (24h, UTC)")
+ .setPlaceholder("YYYY-MM-DD hh:mm (for today, remove date)")
+ .setMinLength(5)
+ .setMaxLength(16)
+ .setStyle(TextInputStyle.Short)
+ .setValue(defaultExpiry)
+ .setRequired(true),
+ new TextInputBuilder()
+ .setCustomId("vote_options")
+ .setLabel("Vote options")
+ // TODO: support custom emojis
+ .setPlaceholder(`The options the users can choose.\nOne option per line, ${settings.emojiList.length} max.\nSupports Discord's Markdown.`)
+ .setMaxLength(Math.min(settings.emojiList.length * 50, 5000))
+ .setStyle(TextInputStyle.Paragraph)
+ .setRequired(true)
+ .setValue(modalData?.voteOptions.join("\n") ?? ""),
+ ],
+ });
+
+ this.clientId = clientId;
+
+ this.headline = headline;
+ this.votesPerUser = votesPerUser;
+ this.title = title;
+ }
+
+ destroy()
+ {
+ this.reactionCollectors.forEach(c => c.stop());
+ this.reactionCollectors = [];
+ this._destroy();
+ }
+
+ public static reduceOptionFields(opts: string[]): EmbedField[]
+ {
+ const optHalves = opts.length > settings.emojiList.length / 3 ? halves(opts) : [opts];
+
+ const redOpts: { opt: string, emoji: string }[][] = [];
+
+ optHalves.forEach((half, i) =>
+ redOpts.push(half.map((opt, j) =>
+ ({ opt, emoji: settings.emojiList[j + (i === 1 ? optHalves[0].length : 0)] })
+ ))
+ );
+
+ return redOpts.map((red, i) => ({
+ name: i === 0 ? "**Options:**" : "\u200B",
+ value: red.reduce((a, c) => `${a}\n${c.emoji} \u200B ${c.opt}`, ""),
+ inline: true,
+ }));
+ }
+
+ // TODO: this might get costly if the bot grows
+ /**
+ * Watches for extraneous reactions on the passed poll messages
+ * @param clientId ID of the bot client so its reactions can be ignored
+ * @param msgs Messages to watch
+ * @param voteOptions The options users can vote on
+ * @static This function is static so it can be reused in @commands/Poll.ts
+ */
+ public static watchReactions(clientId: string, msgs: Message[], voteOptions: string[], votesPerUser: number)
+ {
+ const colls: ReactionCollector[] = [];
+ const pollEmojis = settings.emojiList.slice(0, voteOptions.length);
+
+ msgs.forEach(msg => {
+ const coll = msg.createReactionCollector();
+ coll.on("collect", (re, user) => {
+ if(user.bot && user.id !== clientId)
+ re.remove();
+ if(msgs.reduce(
+ (a, c) => a + c.reactions.cache.filter(r => r.users.cache.has(user.id)).size, 0
+ ) <= votesPerUser)
+ re.remove();
+ // remove emojis added by users
+ if(!pollEmojis.includes(re.emoji.name ?? "_"))
+ re.remove();
+ });
+ colls.push(coll);
+ });
+ return colls;
+ }
+
+ async submit(int: ModalSubmitInteraction<"cached">): Promise {
+ const { guild, channel } = int;
+
+ // already handled in the poll command
+ if(!guild || !channel)
+ return;
+
+ const topicRaw = int.fields.getTextInputValue("topic").trim();
+
+ const topic = topicRaw.length === 0 ? undefined : topicRaw;
+ const expiry = int.fields.getTextInputValue("expiry_date_time").trim();
+ const voteOptions = int.fields.getTextInputValue("vote_options").trim().split(/\n/gm).filter(v => v.length > 0);
+ const headline = this.headline;
+
+ const parsed = this.parsePollVals({
+ int,
+ topic: topic ?? null,
+ expiry,
+ voteOptions,
+ });
+
+ // is already handled in parsePollVals()
+ if(!parsed)
+ return;
+
+ const { dueTime, optionFields } = parsed;
+
+ const ebd = new EmbedBuilder()
+ .setTitle(this.title ?? "Poll")
+ .addFields(optionFields)
+ .setFooter({ text: `Click the reaction emojis below to cast ${this.votesPerUser === 1 ? "a vote" : "votes"}.` })
+ .setColor(settings.embedColors.default);
+
+ topic && topic.length > 0 && ebd.setDescription(`> ${topic.length > 64 ? "\n>" : ""} ${topic}\n`);
+
+ this.emit("deleteCachedData");
+
+ await this.deferReply(int, true);
+
+ // send messages & react
+ try
+ {
+ const voteOpts = voteOptions.map((value, i) => ({ emoji: settings.emojiList[i], value }));
+ const voteRows: Record<"emoji" | "value", string>[][] = [];
+
+ while(voteOpts.length > 0)
+ voteRows.push(voteOpts.splice(0, 10));
+
+ const msgs: Message[] = [];
+
+ const firstMsgOpts: MessageOptions = {};
+ if(headline)
+ firstMsgOpts.content = headline;
+ msgs.push(await channel.send({ ...firstMsgOpts, embeds: [ebd] }));
+
+ for await(const { emoji } of voteRows.shift()!)
+ await msgs[0].react(emoji);
+
+ let i = 1;
+ for(const row of voteRows)
+ {
+ channel.send({ content: "\u200B" }).then(async msg => {
+ msgs.push(msg);
+ for await(const { emoji } of row)
+ await msgs[i].react(emoji);
+ i++;
+ });
+ }
+
+ this.reactionCollectors = CreatePollModal.watchReactions(this.clientId, msgs, voteOptions, this.votesPerUser);
+
+ const dbGld = await getGuild(guild.id);
+
+ if(!dbGld)
+ await createNewGuild(guild.id);
+
+ const allPolls = await getPolls(guild.id);
+
+ let pollId = 1;
+ if(allPolls && allPolls.length)
+ {
+ allPolls.forEach(p => {
+ if(p.pollId >= pollId)
+ pollId = p.pollId + 1;
+ });
+ }
+
+ await createNewPoll({
+ pollId,
+ guildId: guild.id,
+ channel: channel.id,
+ messages: msgs.map(m => m.id),
+ createdBy: int.user.id,
+ headline: headline ?? null,
+ topic: topic && topic.length > 0 ? topic : null,
+ voteOptions,
+ votesPerUser: this.votesPerUser,
+ dueTimestamp: dueTime,
+ });
+
+ return this.editReply(int, embedify("Successfully created a poll in this channel."));
+ }
+ catch(err)
+ {
+ return this.editReply(int, embedify(`Couldn't create a poll due to an error: ${err}`, settings.embedColors.error));
+ }
+ }
+
+ parsePollVals({ int, topic, expiry, voteOptions }: { int: ModalSubmitInteraction } & PollModalData)
+ {
+ const longFmtRe = /^\d{4}[./-]\d{1,2}[./-]\d{1,2}[\s.,_T]+\d{1,2}:\d{1,2}(:\d{1,2})?$/,
+ shortFmtRe = /^\d{1,2}:\d{1,2}$/;
+
+ const modalInvalid = (msg: string) => {
+ this.emit("invalid", { topic: topic ?? null, expiry, voteOptions });
+ this.reply(int, embedify(msg, settings.embedColors.error), true);
+ return null;
+ };
+
+ if(!expiry.match(longFmtRe) && !expiry.match(shortFmtRe))
+ return modalInvalid("Please make sure the poll end date and time are formatted in one of these formats (24 hours, in UTC / GMT+0 time):\n- `YYYY/MM/DD hh:mm`\n- `hh:mm` (today)");
+ if(voteOptions.length > settings.emojiList.length)
+ return modalInvalid(`Please enter ${settings.emojiList.length} vote options at most.`);
+ if(voteOptions.length < 2)
+ return modalInvalid("Please enter at least two options to vote on.");
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [_, ...rest] = (expiry.match(longFmtRe)
+ ? /^(\d{4})[./-](\d{1,2})[./-](\d{1,2})[\s.,_T]+(\d{1,2}):(\d{1,2}):?(\d{1,2})?$/.exec(expiry)
+ : /^(\d{1,2}):(\d{1,2})$/.exec(expiry)
+ )?.filter(v => v) as string[];
+
+ const d = new Date();
+ let dateParts: number[] = [];
+
+ if(rest.length === 5)
+ dateParts = rest.map(v => parseInt(v));
+ else
+ dateParts = [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), ...rest.map(v => parseInt(v))];
+
+ // for some reason only months in JS are 0-indexed
+ dateParts[1]--;
+
+ const dueTs = Date.UTC(...>dateParts);
+ const nowTs = Date.now();
+
+ const optionFields = CreatePollModal.reduceOptionFields(voteOptions);
+
+ if(dueTs < nowTs + 30_000)
+ return modalInvalid("Please enter a date and time that is at least one minute from now.");
+
+ return {
+ dueTime: new Date(dueTs),
+ optionFields,
+ };
+ }
+}
diff --git a/src/settings.ts b/src/settings.ts
index 1f08354..5685c2c 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -41,6 +41,8 @@ export const settings: Settings = Object.freeze({
Intents.GuildMessageReactions,
Intents.DirectMessages,
],
+ /** Optional prefix to better distinguish commands of specific clients */
+ commandPrefix: getEnvVar("COMMAND_PREFIX", "stringNoEmpty"),
},
embedColors: {
default: getEnvVar("EMBED_COLOR_DEFAULT") ?? 0xfaba05,
@@ -58,12 +60,16 @@ export const settings: Settings = Object.freeze({
},
}) as Settings;
-/** Tests if the environment variable `varName` equals `value` casted to string - both params are case insensitive */
-function envVarEquals(varName: string, value: Stringifiable)
+/** Tests if the environment variable `varName` equals `value` casted to string */
+function envVarEquals(varName: string, value: Stringifiable, caseSensitive = false)
{
- return process.env[varName]?.toLowerCase() === value.toString().toLowerCase();
+ const envVal = process.env[varName];
+ const val = String(value);
+ return (caseSensitive ? envVal : envVal?.toLowerCase()) === (caseSensitive ? String(val) : String(val).toLowerCase());
}
+/** Grabs an environment variable's value, and casts it to a `string` - however if the string is empty (unset), undefined is returned */
+export function getEnvVar(varName: string, asType?: "stringNoEmpty"): undefined | string
/** Grabs an environment variable's value, and casts it to a `string` */
export function getEnvVar(varName: string, asType?: "string"): undefined | string
/** Grabs an environment variable's value, and casts it to a `number` */
@@ -95,6 +101,8 @@ export function getEnvVar v.split(commasRegex).map(n => parseInt(n.trim()));
break;
+ case "stringNoEmpty":
+ transform = v => String(v).trim().length == 0 ? undefined : String(v).trim();
}
return transform(val) as string; // I'm lazy and ts is happy, so we can all be happy and pretend this doesn't exist
@@ -111,6 +119,7 @@ interface Settings {
}
client: {
intents: GatewayIntentBits[];
+ commandPrefix?: string;
}
embedColors: {
default: ColorResolvable;
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 558727a..1573d8c 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -1,12 +1,10 @@
-import { ClientEvents, ApplicationCommandOptionType, BufferResolvable, JSONEncodable, APIAttachment, Attachment, AttachmentBuilder, AttachmentPayload } from "discord.js";
+import { ClientEvents, ApplicationCommandOptionType, BufferResolvable, JSONEncodable, APIAttachment, Attachment, AttachmentBuilder, AttachmentPayload, CommandInteraction, ButtonInteraction, ModalSubmitInteraction, ContextMenuCommandInteraction } from "discord.js";
import { PermissionFlagsBits } from "discord-api-types/v10";
import { ContextMenuCommandType } from "@discordjs/builders";
import { Stream } from "node:stream";
//#MARKER commands
-export type AnyCmdInteraction = CommandInteraction | ButtonInteraction | ModalSubmitInteraction | ContextMenuInteraction;
-
/** A single argument of a slash command */
type CommandArg = BaseCommandArg & (StringCommandArg | NumberCommandArg | IntegerCommandArg | BooleanCommandArg | UserCommandArg | ChannelCommandArg | RoleCommandArg | AttachmentCommandArg);
@@ -70,8 +68,12 @@ interface CmdMetaBase {
name: string;
/** Description that's displayed when typing the command in chat */
desc: string;
+ // TODO:
+ // /** Whether this is a global or guild command */
+ // type: "guild" | "global";
+ /** If this is used, the command is still displayed but errors on use. Only use this in subcommands. */
perms?: PermissionFlagsBits[];
- /** Default member permissions needed to view and use this command */
+ /** (for guild commands) member permissions needed to view and use this command */
memberPerms?: PermissionFlagsBits[];
/** Category of the command */
category: CommandCategory;
@@ -147,9 +149,10 @@ export interface CtxMeta {
export type DiscordAPIFile = BufferResolvable | Stream | JSONEncodable | Attachment | AttachmentBuilder | AttachmentPayload;
+export type AnyInteraction = CommandInteraction | ButtonInteraction | ModalSubmitInteraction | ContextMenuCommandInteraction;
//#MARKER utils
// The tuple type allows us to create arrays with certain lengths that are type-safe (doesn't protect from push()ing over N elements)
-type Tuple = N extends N ? number extends N ? T[] : _TupleOf : never;
+export type Tuple = N extends N ? number extends N ? T[] : _TupleOf : never;
type _TupleOf = R["length"] extends N ? R : _TupleOf;
diff --git a/src/utils/BtnMsg.ts b/src/utils/BtnMsg.ts
index 37e9658..f378da7 100644
--- a/src/utils/BtnMsg.ts
+++ b/src/utils/BtnMsg.ts
@@ -62,7 +62,7 @@ export class BtnMsg extends EmitterBase
});
const defaultOpts: BtnMsgOpts = {
- timeout: 1000 * 60 * 30,
+ timeout: 14 * 60 * 1000,
};
this.opts = { ...defaultOpts, ...options };
diff --git a/src/utils/PageEmbed.ts b/src/utils/PageEmbed.ts
index 921496e..e4490c6 100644
--- a/src/utils/PageEmbed.ts
+++ b/src/utils/PageEmbed.ts
@@ -8,7 +8,7 @@ import { Command } from "@src/Command";
import { btnListener } from "@src/registry";
import { useEmbedify } from "./embedify";
import { settings } from "@src/settings";
-import { AnyCmdInteraction, DiscordAPIFile, Tuple } from "@src/types";
+import { AnyInteraction, DiscordAPIFile, Tuple } from "@src/types";
type BtnType = "first" | "prev" | "next" | "last";
@@ -57,7 +57,7 @@ export class PageEmbed extends EmitterBase
private readonly settings: PageEmbedSettings;
private msg?: Message;
- private int?: AnyCmdInteraction;
+ private int?: AnyInteraction;
private btns: ButtonBuilder[][];
private pages: APIEmbed[] = [];
@@ -94,7 +94,7 @@ export class PageEmbed extends EmitterBase
const defSett: PageEmbedSettings = {
firstLastBtns: true,
goToPageBtn: true,
- timeout: 30 * 60 * 1000,
+ timeout: 14 * 60 * 1000,
overflow: true,
allowAllUsersTimeout: -1,
};
@@ -506,7 +506,7 @@ export class PageEmbed extends EmitterBase
}
/** Call this function once to reply to or edit an interaction with this PageEmbed. This is the interactions' equivalent of `sendIn()` */
- public async useInt(int: AnyCmdInteraction, ephemeral = false)
+ public async useInt(int: AnyInteraction, ephemeral = false)
{
if(!int.deferred)
await int.deferReply();
diff --git a/src/utils/emojis.ts b/src/utils/emojis.ts
new file mode 100644
index 0000000..7ff8288
--- /dev/null
+++ b/src/utils/emojis.ts
@@ -0,0 +1,10 @@
+export const emojis = {
+ /**  */
+ openInBrowser: "<:open_in_browser:994648843331309589>",
+ /**  */
+ qtCett: "<:qt_cett:610817939276562433>",
+ /**  */
+ mock: "<:mock:506303207400669204>",
+ /**  */
+ banHammer: "<:banhammer:1015632683096871055>",
+};
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 749e252..68d8af6 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,6 +1,7 @@
export * from "./@TryCatchMethod";
export * from "./BtnMsg";
export * from "./embedify";
+export * from "./emojis";
export * from "./misc";
export * from "./Modal";
export * from "./PageEmbed";
diff --git a/src/utils/misc.ts b/src/utils/misc.ts
index a932528..3d204e7 100644
--- a/src/utils/misc.ts
+++ b/src/utils/misc.ts
@@ -8,3 +8,15 @@ export function truncField(content: string, endStr = "...")
{
return truncStr(content, 1024 - endStr.length, endStr);
}
+
+/**
+ * Automatically appends an `s` to the passed `word`, if `num` is not equal to 1
+ * @param word A word in singular form, to auto-convert to plural
+ * @param num If this is an array, the amount of items is used
+ */
+export function autoPlural(word: string, num: number | unknown[])
+{
+ if(Array.isArray(num))
+ num = num.length;
+ return `${word}${num === 1 ? "" : "s"}`;
+}
diff --git a/src/utils/time.ts b/src/utils/time.ts
index 0211c3b..e3638c8 100644
--- a/src/utils/time.ts
+++ b/src/utils/time.ts
@@ -1,4 +1,4 @@
-import { ParseDurationResult } from "svcorelib";
+import { ParseDurationResult, Stringifiable } from "svcorelib";
export function formatSeconds(seconds: number): string {
const date = new Date(1970, 0, 1);
@@ -24,3 +24,30 @@ export function toUnix10(time: Date | number)
{
return Math.floor((typeof time === "number" ? time : time.getTime()) / 1000);
}
+
+export type TimeObj = Record<"days"|"hours"|"minutes"|"seconds"|"months"|"years", number>;
+
+/** Converts a time object into its amount of milliseconds */
+export function timeToMs(timeObj: Partial)
+{
+ return Math.floor(
+ 1000 * (timeObj?.seconds ?? 0)
+ + 1000 * 60 * (timeObj?.minutes ?? 0)
+ + 1000 * 60 * 60 * (timeObj?.hours ?? 0)
+ + 1000 * 60 * 60 * 24 * (timeObj?.days ?? 0)
+ + 1000 * 60 * 60 * 24 * 30.5 * (timeObj?.months ?? 0)
+ + 1000 * 60 * 60 * 24 * 365 * (timeObj?.years ?? 0)
+ );
+}
+
+/**
+ * Pads a passed value with leading zeroes until a length is reached. Examples:
+ * `zeroPad(9)` -> `09`
+ * `zeroPad(99)` -> `99`
+ * `zeroPad(99, 5)` -> `00099`
+ * @param value Any stringifiable value to pad with zeroes
+ * @param padLength The desired length to pad to - default is 2
+ */
+export function zeroPad(value: Stringifiable, padLength = 2) {
+ return String(value).padStart(padLength, "0");
+}