commit ac49705f3e59de97ad12ecad4d29f0cdc2ca9844 Author: March 7th <71698422+aiko-chan-ai@users.noreply.github.com> Date: Sat Mar 19 17:37:45 2022 +0700 Initial commit diff --git a/ - Copy.gitignore b/ - Copy.gitignore new file mode 100644 index 00000000..e9eb19e --- /dev/null +++ b/ - Copy.gitignore @@ -0,0 +1,82 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Packages +node_modules/ +djs/ + +# Log files +logs/ +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Env +.env +test/auth.json +test/auth.js +docs/deploy/deploy_key +docs/deploy/deploy_key.pub +deploy/deploy_key +deploy/deploy_key.pub + +# Dist +dist/ +docs/docs.json + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Env +.env +test/ +docs/deploy/deploy_key +docs/deploy/deploy_key.pub +deploy/deploy_key +deploy/deploy_key.pub + +# Miscellaneous +.tmp/ +.idea/ +.DS_Store + +# Custom +data/ \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..20b0c92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,127 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..e62ec04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 00000000..59fb366 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +
+
+

+ discord.js +

+
+

+ Discord server + npm version + npm downloads +

+
+ +## About + +dsb is a [Node.js](https://nodejs.org) module that allows user accounts to interact with the discord api + +I am in no way responsible for what happens to your account. What you do is on you! + +## Installation + +**Node.js 16.9.0 or newer is required** + +```sh-session +npm install dsb.js +``` + +## Example + +```js +const { Client } = require('dsb.js'); +const client = new Client(); // intents and partials are already set so you don't have to define them + +client.on('ready', async () => { + console.log(`${client.user.username} >> [${client.guilds.cache.size}] guilds || [${client.friends.cache.size}] friends`); +}) + +client.login('token'); +``` + +## Links +- [Documentation](https://discord.js.org/#/docs/discord.js/stable/general/welcome) +- [GitHub](https://github.com/TheDevYellowy/discordjs-selfbot) +- [Discord](https://discord.gg/3makcFd2m4) diff --git a/package.json b/package.json new file mode 100644 index 00000000..90101ce --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "discord.js-selfbot-v13", + "version": "1.1.3", + "description": "A unofficial discord.js fork for creating selfbots [Based on discord.js v13]", + "main": "./src/index.js", + "types": "./typings/index.d.ts", + "files": [ + "src", + "typings" + ], + "directories": { + "lib": "src", + "test": "test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/aiko-chan-ai/discord.js-selfbot-v13.git" + }, + "keywords": [ + "discord.js", + "selfbot", + "djs", + "api", + "bot", + "node" + ], + "author": "aiko-chan-ai", + "license": "ISC", + "bugs": { + "url": "https://github.com/aiko-chan-ai/discord.js-selfbot-v13/issues" + }, + "contributors": [ + "TheDevYellowy " + ], + "homepage": "https://github.com/aiko-chan-ai/discord.js-selfbot-v13#readme", + "dependencies": { + "@discordjs/builders": "^0.12.0", + "@discordjs/collection": "^0.5.0", + "@sapphire/async-queue": "^1.3.0", + "@sapphire/snowflake": "^3.2.0", + "@types/ws": "^8.5.2", + "crypto": "^1.0.1", + "discord-api-types": "^0.27.3", + "form-data": "^4.0.0", + "lodash.snakecase": "^4.1.1", + "node-fetch": "^3.2.2", + "undici": "^4.15.0", + "ws": "^8.5.0" + }, + "engines": { + "node": ">=16.9.0", + "npm": ">=7.0.0" + } +} \ No newline at end of file diff --git a/src/WebSocket.js b/src/WebSocket.js new file mode 100644 index 00000000..efbd6f9 --- /dev/null +++ b/src/WebSocket.js @@ -0,0 +1,39 @@ +'use strict'; + +let erlpack; +const { Buffer } = require('node:buffer'); + +try { + erlpack = require('erlpack'); + if (!erlpack.pack) erlpack = null; +} catch {} // eslint-disable-line no-empty + +exports.WebSocket = require('ws'); + +const ab = new TextDecoder(); + +exports.encoding = erlpack ? 'etf' : 'json'; + +exports.pack = erlpack ? erlpack.pack : JSON.stringify; + +exports.unpack = (data, type) => { + if (exports.encoding === 'json' || type === 'json') { + if (typeof data !== 'string') { + data = ab.decode(data); + } + return JSON.parse(data); + } + if (!Buffer.isBuffer(data)) data = Buffer.from(new Uint8Array(data)); + return erlpack.unpack(data); +}; + +exports.create = (gateway, query = {}, ...args) => { + const [g, q] = gateway.split('?'); + query.encoding = exports.encoding; + query = new URLSearchParams(query); + if (q) new URLSearchParams(q).forEach((v, k) => query.set(k, v)); + const ws = new exports.WebSocket(`${g}?${query}`, ...args); + return ws; +}; + +for (const state of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) exports[state] = exports.WebSocket[state]; diff --git a/src/client/BaseClient.js b/src/client/BaseClient.js new file mode 100644 index 00000000..cdedff8 --- /dev/null +++ b/src/client/BaseClient.js @@ -0,0 +1,78 @@ +'use strict'; + +const EventEmitter = require('node:events'); +const RESTManager = require('../rest/RESTManager'); +const { TypeError } = require('../errors'); +const Options = require('../util/Options'); +const Util = require('../util/Util'); + +/** + * The base class for all clients. + * @extends {EventEmitter} + */ +class BaseClient extends EventEmitter { + constructor(options = {}) { + super({ captureRejections: true }); + + if (typeof options !== 'object' || options === null) { + throw new TypeError('INVALID_TYPE', 'options', 'object', true); + } + + /** + * The options the client was instantiated with + * @type {ClientOptions} + */ + this.options = Util.mergeDefault(Options.createDefault(), options); + + /** + * The REST manager of the client + * @type {REST} + */ + this.rest = new RESTManager(this); + } + + get api() { + return this.rest.api; + } + + /** + * Destroys all assets used by the base client. + * @returns {void} + */ + destroy() { + if(this.rest.sweepInterval) clearInterval(this.rest.sweepInterval); + } + + /** + * Increments max listeners by one, if they are not zero. + * @private + */ + incrementMaxListeners() { + const maxListeners = this.getMaxListeners(); + if (maxListeners !== 0) { + this.setMaxListeners(maxListeners + 1); + } + } + + /** + * Decrements max listeners by one, if they are not zero. + * @private + */ + decrementMaxListeners() { + const maxListeners = this.getMaxListeners(); + if (maxListeners !== 0) { + this.setMaxListeners(maxListeners - 1); + } + } + + toJSON(...props) { + return Util.flatten(this, { domain: false }, ...props); + } +} + +module.exports = BaseClient; + +/** + * @external REST + * @see {@link https://discord.js.org/#/docs/rest/main/class/REST} + */ diff --git a/src/client/Client.js b/src/client/Client.js new file mode 100644 index 00000000..56979a5 --- /dev/null +++ b/src/client/Client.js @@ -0,0 +1,565 @@ +'use strict'; + +const process = require('node:process'); +const { Collection } = require('@discordjs/collection'); +const { OAuth2Scopes, Routes } = require('discord-api-types/v9'); +const BaseClient = require('./BaseClient'); +const ActionsManager = require('./actions/ActionsManager'); +const ClientVoiceManager = require('./voice/ClientVoiceManager'); +const WebSocketManager = require('./websocket/WebSocketManager'); +const { Error, TypeError, RangeError } = require('../errors'); +const BaseGuildEmojiManager = require('../managers/BaseGuildEmojiManager'); +const ChannelManager = require('../managers/ChannelManager'); +const GuildManager = require('../managers/GuildManager'); +const UserManager = require('../managers/UserManager'); +const FriendsManager = require('../managers/FriendsManager'); +const BlockedManager = require('../managers/BlockedManager'); +const ClientUserSettingManager = require('../managers/ClientUserSettingManager'); +const ShardClientUtil = require('../sharding/ShardClientUtil'); +const ClientPresence = require('../structures/ClientPresence'); +const GuildPreview = require('../structures/GuildPreview'); +const GuildTemplate = require('../structures/GuildTemplate'); +const Invite = require('../structures/Invite'); +const { Sticker } = require('../structures/Sticker'); +const StickerPack = require('../structures/StickerPack'); +const VoiceRegion = require('../structures/VoiceRegion'); +const Webhook = require('../structures/Webhook'); +const Widget = require('../structures/Widget'); +const DataResolver = require('../util/DataResolver'); +const Events = require('../util/Events'); +const IntentsBitField = require('../util/IntentsBitField'); +const Options = require('../util/Options'); +const PermissionsBitField = require('../util/PermissionsBitField'); +const Status = require('../util/Status'); +const Sweepers = require('../util/Sweepers'); + +/** + * The main hub for interacting with the Discord API, and the starting point for any bot. + * @extends {BaseClient} + */ +class Client extends BaseClient { + /** + * @param {ClientOptions} options Options for the client + */ + constructor(options) { + super(options); + + const data = require('node:worker_threads').workerData ?? process.env; + const defaults = Options.createDefault(); + + if (this.options.shards === defaults.shards) { + if ('SHARDS' in data) { + this.options.shards = JSON.parse(data.SHARDS); + } + } + + if (this.options.shardCount === defaults.shardCount) { + if ('SHARD_COUNT' in data) { + this.options.shardCount = Number(data.SHARD_COUNT); + } else if (Array.isArray(this.options.shards)) { + this.options.shardCount = this.options.shards.length; + } + } + + const typeofShards = typeof this.options.shards; + + if (typeofShards === 'undefined' && typeof this.options.shardCount === 'number') { + this.options.shards = Array.from({ length: this.options.shardCount }, (_, i) => i); + } + + if (typeofShards === 'number') this.options.shards = [this.options.shards]; + + if (Array.isArray(this.options.shards)) { + this.options.shards = [ + ...new Set( + this.options.shards.filter(item => !isNaN(item) && item >= 0 && item < Infinity && item === (item | 0)), + ), + ]; + } + + this._validateOptions(); + + /** + * The WebSocket manager of the client + * @type {WebSocketManager} + */ + this.ws = new WebSocketManager(this); + + /** + * The action manager of the client + * @type {ActionsManager} + * @private + */ + this.actions = new ActionsManager(this); + + /** + * The voice manager of the client + * @type {ClientVoiceManager} + */ + this.voice = new ClientVoiceManager(this); + + /** + * Shard helpers for the client (only if the process was spawned from a {@link ShardingManager}) + * @type {?ShardClientUtil} + */ + this.shard = process.env.SHARDING_MANAGER + ? ShardClientUtil.singleton(this, process.env.SHARDING_MANAGER_MODE) + : null; + + /** + * All of the {@link User} objects that have been cached at any point, mapped by their ids + * @type {UserManager} + */ + this.users = new UserManager(this); + this.friends = new FriendsManager(this); + this.blocked = new BlockedManager(this); + this.setting = new ClientUserSettingManager(this); + /** + * All of the guilds the client is currently handling, mapped by their ids - + * as long as sharding isn't being used, this will be *every* guild the bot is a member of + * @type {GuildManager} + */ + this.guilds = new GuildManager(this); + + /** + * All of the {@link Channel}s that the client is currently handling, mapped by their ids - + * as long as sharding isn't being used, this will be *every* channel in *every* guild the bot + * is a member of. Note that DM channels will not be initially cached, and thus not be present + * in the Manager without their explicit fetching or use. + * @type {ChannelManager} + */ + this.channels = new ChannelManager(this); + + /** + * The sweeping functions and their intervals used to periodically sweep caches + * @type {Sweepers} + */ + this.sweepers = new Sweepers(this, this.options.sweepers); + + /** + * The presence of the Client + * @private + * @type {ClientPresence} + */ + this.presence = new ClientPresence(this, this.options.presence); + + Object.defineProperty(this, 'token', { writable: true }); + if (!this.token && 'DISCORD_TOKEN' in process.env) { + /** + * Authorization token for the logged in bot. + * If present, this defaults to `process.env.DISCORD_TOKEN` when instantiating the client + * This should be kept private at all times. + * @type {?string} + */ + this.token = process.env.DISCORD_TOKEN; + } else { + this.token = null; + } + + /** + * used for interacitons + * @type {?String} + */ + this.session_id = null; + + /** + * User that the client is logged in as + * @type {?ClientUser} + */ + this.user = null; + + /** + * The application of this bot + * @type {?ClientApplication} + */ + this.application = null; + this.bot = null; + + /** + * Timestamp of the time the client was last `READY` at + * @type {?number} + */ + this.readyTimestamp = null; + } + + /** + * All custom emojis that the client has access to, mapped by their ids + * @type {BaseGuildEmojiManager} + * @readonly + */ + get emojis() { + const emojis = new BaseGuildEmojiManager(this); + for (const guild of this.guilds.cache.values()) { + if (guild.available) for (const emoji of guild.emojis.cache.values()) emojis.cache.set(emoji.id, emoji); + } + return emojis; + } + + /** + * Time at which the client was last regarded as being in the `READY` state + * (each time the client disconnects and successfully reconnects, this will be overwritten) + * @type {?Date} + * @readonly + */ + get readyAt() { + return this.readyTimestamp && new Date(this.readyTimestamp); + } + + /** + * How long it has been since the client last entered the `READY` state in milliseconds + * @type {?number} + * @readonly + */ + get uptime() { + return this.readyTimestamp && Date.now() - this.readyTimestamp; + } + + /** + * Logs the client in, establishing a WebSocket connection to Discord. + * @param {string} [token=this.token] Token of the account to log in with + * @param {Boolean} [bot=false] Wether the token used is a bot account or not + * @returns {Promise} Token of the account used + * @example + * client.login('my token'); + */ + async login(token = this.token, bot = false) { + if (!token || typeof token !== 'string') throw new Error('TOKEN_INVALID'); + this.token = token = token.replace(/^(Bot|Bearer)\s*/i, ''); + this.bot = bot; + this.emit( + Events.Debug, + `Provided token: ${token + .split('.') + .map((val, i) => (i > 1 ? val.replace(/./g, '*') : val)) + .join('.')}`, + ); + + if (this.options.presence) { + this.options.ws.presence = this.presence._parse(this.options.presence); + } + + this.emit(Events.Debug, 'Preparing to connect to the gateway...'); + + try { + await this.ws.connect(); + return this.token; + } catch (error) { + this.destroy(); + throw error; + } + } + + /** + * Returns whether the client has logged in, indicative of being able to access + * properties such as `user` and `application`. + * @returns {boolean} + */ + isReady() { + return this.ws.status === Status.Ready; + } + + /** + * Logs out, terminates the connection to Discord, and destroys the client. + * @returns {void} + */ + destroy() { + super.destroy(); + + this.sweepers.destroy(); + this.ws.destroy(); + this.token = null; + //this.rest.setToken(null); + } + + /** + * Options used when fetching an invite from Discord. + * @typedef {Object} ClientFetchInviteOptions + * @property {Snowflake} [guildScheduledEventId] The id of the guild scheduled event to include with + * the invite + */ + + /** + * Obtains an invite from Discord. + * @param {InviteResolvable} invite Invite code or URL + * @param {ClientFetchInviteOptions} [options] Options for fetching the invite + * @returns {Promise} + * @example + * client.fetchInvite('https://discord.gg/djs') + * .then(invite => console.log(`Obtained invite with code: ${invite.code}`)) + * .catch(console.error); + */ + async fetchInvite(invite, options) { + const code = DataResolver.resolveInviteCode(invite); + const query = new URLSearchParams({ + with_counts: true, + with_expiration: true, + }); + if (options?.guildScheduledEventId) { + query.set('guild_scheduled_event_id', options.guildScheduledEventId); + } + const data = await this.api.invites(code).get({ query }); + return new Invite(this, data); + } + + /** + * Obtains a template from Discord. + * @param {GuildTemplateResolvable} template Template code or URL + * @returns {Promise} + * @example + * client.fetchGuildTemplate('https://discord.new/FKvmczH2HyUf') + * .then(template => console.log(`Obtained template with code: ${template.code}`)) + * .catch(console.error); + */ + async fetchGuildTemplate(template) { + const code = DataResolver.resolveGuildTemplateCode(template); + const data = await this.api.guilds.templates(code).get(); + return new GuildTemplate(this, data); + } + + /** + * Obtains a webhook from Discord. + * @param {Snowflake} id The webhook's id + * @param {string} [token] Token for the webhook + * @returns {Promise} + * @example + * client.fetchWebhook('id', 'token') + * .then(webhook => console.log(`Obtained webhook with name: ${webhook.name}`)) + * .catch(console.error); + */ + async fetchWebhook(id, token) { + const data = await this.api.webhook(id, token).get(); + return new Webhook(this, { token, ...data }); + } + + /** + * Obtains the available voice regions from Discord. + * @returns {Promise>} + * @example + * client.fetchVoiceRegions() + * .then(regions => console.log(`Available regions are: ${regions.map(region => region.name).join(', ')}`)) + * .catch(console.error); + */ + async fetchVoiceRegions() { + const apiRegions = await this.api.voice.regions.get(); + const regions = new Collection(); + for (const region of apiRegions) regions.set(region.id, new VoiceRegion(region)); + return regions; + } + + /** + * Obtains a sticker from Discord. + * @param {Snowflake} id The sticker's id + * @returns {Promise} + * @example + * client.fetchSticker('id') + * .then(sticker => console.log(`Obtained sticker with name: ${sticker.name}`)) + * .catch(console.error); + */ + async fetchSticker(id) { + const data = await this.api.stickers(id).get(); + return new Sticker(this, data); + } + + /** + * Obtains the list of sticker packs available to Nitro subscribers from Discord. + * @returns {Promise>} + * @example + * client.fetchPremiumStickerPacks() + * .then(packs => console.log(`Available sticker packs are: ${packs.map(pack => pack.name).join(', ')}`)) + * .catch(console.error); + */ + async fetchPremiumStickerPacks() { + const data = await this.api('sticker-packs').get(); + return new Collection(data.sticker_packs.map(p => [p.id, new StickerPack(this, p)])); + } + + /** + * Obtains a guild preview from Discord, available for all guilds the bot is in and all Discoverable guilds. + * @param {GuildResolvable} guild The guild to fetch the preview for + * @returns {Promise} + */ + async fetchGuildPreview(guild) { + const id = this.guilds.resolveId(guild); + if (!id) throw new TypeError('INVALID_TYPE', 'guild', 'GuildResolvable'); + const data = await this.api.guilds(id).preview.get(); + return new GuildPreview(this, data); + } + + /** + * Obtains the widget data of a guild from Discord, available for guilds with the widget enabled. + * @param {GuildResolvable} guild The guild to fetch the widget data for + * @returns {Promise} + */ + async fetchGuildWidget(guild) { + const id = this.guilds.resolveId(guild); + if (!id) throw new TypeError('INVALID_TYPE', 'guild', 'GuildResolvable'); + const data = await this.api.guilds(id, 'widget.json').get(); + return new Widget(this, data); + } + + /** + * Options for {@link Client#generateInvite}. + * @typedef {Object} InviteGenerationOptions + * @property {OAuth2Scopes[]} scopes Scopes that should be requested + * @property {PermissionResolvable} [permissions] Permissions to request + * @property {GuildResolvable} [guild] Guild to preselect + * @property {boolean} [disableGuildSelect] Whether to disable the guild selection + */ + + /** + * Generates a link that can be used to invite the bot to a guild. + * @param {InviteGenerationOptions} [options={}] Options for the invite + * @returns {string} + * @example + * const link = client.generateInvite({ + * scopes: [OAuth2Scopes.ApplicationsCommands], + * }); + * console.log(`Generated application invite link: ${link}`); + * @example + * const link = client.generateInvite({ + * permissions: [ + * PermissionFlagsBits.SendMessages, + * PermissionFlagsBits.ManageGuild, + * PermissionFlagsBits.MentionEveryone, + * ], + * scopes: [OAuth2Scopes.Bot], + * }); + * console.log(`Generated bot invite link: ${link}`); + */ + generateInvite(options = {}) { + if (typeof options !== 'object') throw new TypeError('INVALID_TYPE', 'options', 'object', true); + if (!this.application) throw new Error('CLIENT_NOT_READY', 'generate an invite link'); + + const query = new URLSearchParams({ + client_id: this.application.id, + }); + + const { scopes } = options; + if (typeof scopes === 'undefined') { + throw new TypeError('INVITE_MISSING_SCOPES'); + } + if (!Array.isArray(scopes)) { + throw new TypeError('INVALID_TYPE', 'scopes', 'Array of Invite Scopes', true); + } + if (!scopes.some(scope => [OAuth2Scopes.Bot, OAuth2Scopes.ApplicationsCommands].includes(scope))) { + throw new TypeError('INVITE_MISSING_SCOPES'); + } + const validScopes = Object.values(OAuth2Scopes); + const invalidScope = scopes.find(scope => !validScopes.includes(scope)); + if (invalidScope) { + throw new TypeError('INVALID_ELEMENT', 'Array', 'scopes', invalidScope); + } + query.set('scope', scopes.join(' ')); + + if (options.permissions) { + const permissions = PermissionsBitField.resolve(options.permissions); + if (permissions) query.set('permissions', permissions); + } + + if (options.disableGuildSelect) { + query.set('disable_guild_select', true); + } + + if (options.guild) { + const guildId = this.guilds.resolveId(options.guild); + if (!guildId) throw new TypeError('INVALID_TYPE', 'options.guild', 'GuildResolvable'); + query.set('guild_id', guildId); + } + + return `${this.options.rest.api}${Routes.oauth2Authorization()}?${query}`; + } + + toJSON() { + return super.toJSON({ + readyAt: false, + }); + } + + /** + * Calls {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval} on a script + * with the client as `this`. + * @param {string} script Script to eval + * @returns {*} + * @private + */ + _eval(script) { + return eval(script); + } + + /** + * Validates the client options. + * @param {ClientOptions} [options=this.options] Options to validate + * @private + */ + _validateOptions(options = this.options) { + if (typeof options.intents === 'undefined') { + throw new TypeError('CLIENT_MISSING_INTENTS'); + } else { + options.intents = IntentsBitField.resolve(options.intents); + } + if (typeof options.shardCount !== 'number' || isNaN(options.shardCount) || options.shardCount < 1) { + throw new TypeError('CLIENT_INVALID_OPTION', 'shardCount', 'a number greater than or equal to 1'); + } + if (options.shards && !(options.shards === 'auto' || Array.isArray(options.shards))) { + throw new TypeError('CLIENT_INVALID_OPTION', 'shards', "'auto', a number or array of numbers"); + } + if (options.shards && !options.shards.length) throw new RangeError('CLIENT_INVALID_PROVIDED_SHARDS'); + if (typeof options.makeCache !== 'function') { + throw new TypeError('CLIENT_INVALID_OPTION', 'makeCache', 'a function'); + } + if (typeof options.sweepers !== 'object' || options.sweepers === null) { + throw new TypeError('CLIENT_INVALID_OPTION', 'sweepers', 'an object'); + } + if (!Array.isArray(options.partials)) { + throw new TypeError('CLIENT_INVALID_OPTION', 'partials', 'an Array'); + } + if (typeof options.waitGuildTimeout !== 'number' || isNaN(options.waitGuildTimeout)) { + throw new TypeError('CLIENT_INVALID_OPTION', 'waitGuildTimeout', 'a number'); + } + if (typeof options.failIfNotExists !== 'boolean') { + throw new TypeError('CLIENT_INVALID_OPTION', 'failIfNotExists', 'a boolean'); + } + } +} + +module.exports = Client; + +/** + * A {@link https://developer.twitter.com/en/docs/twitter-ids Twitter snowflake}, + * except the epoch is 2015-01-01T00:00:00.000Z. + * + * If we have a snowflake '266241948824764416' we can represent it as binary: + * ``` + * 64 22 17 12 0 + * 000000111011000111100001101001000101000000 00001 00000 000000000000 + * number of milliseconds since Discord epoch worker pid increment + * ``` + * @typedef {string} Snowflake + */ + +/** + * Emitted for general debugging information. + * @event Client#debug + * @param {string} info The debug information + */ + +/** + * Emitted for general warnings. + * @event Client#warn + * @param {string} info The warning + */ + +/** + * @external Collection + * @see {@link https://discord.js.org/#/docs/collection/main/class/Collection} + */ + +/** + * @external ImageURLOptions + * @see {@link https://discord.js.org/#/docs/rest/main/typedef/ImageURLOptions} + */ + +/** + * @external BaseImageURLOptions + * @see {@link https://discord.js.org/#/docs/rest/main/typedef/BaseImageURLOptions} + */ diff --git a/src/client/WebhookClient.js b/src/client/WebhookClient.js new file mode 100644 index 00000000..1c66194 --- /dev/null +++ b/src/client/WebhookClient.js @@ -0,0 +1,61 @@ +'use strict'; + +const BaseClient = require('./BaseClient'); +const { Error } = require('../errors'); +const Webhook = require('../structures/Webhook'); + +/** + * The webhook client. + * @implements {Webhook} + * @extends {BaseClient} + */ +class WebhookClient extends BaseClient { + /** + * The data for the webhook client containing either an id and token or just a URL + * @typedef {Object} WebhookClientData + * @property {Snowflake} [id] The id of the webhook + * @property {string} [token] The token of the webhook + * @property {string} [url] The full URL for the webhook client + */ + + /** + * @param {WebhookClientData} data The data of the webhook + * @param {ClientOptions} [options] Options for the client + */ + constructor(data, options) { + super(options); + Object.defineProperty(this, 'client', { value: this }); + let { id, token } = data; + + if ('url' in data) { + const url = data.url.match( + // eslint-disable-next-line no-useless-escape + /https?:\/\/(?:ptb\.|canary\.)?discord\.com\/api(?:\/v\d{1,2})?\/webhooks\/(\d{17,19})\/([\w-]{68})/i, + ); + + if (!url || url.length <= 1) throw new Error('WEBHOOK_URL_INVALID'); + + [, id, token] = url; + } + + this.id = id; + Object.defineProperty(this, 'token', { value: token, writable: true, configurable: true }); + } + + // These are here only for documentation purposes - they are implemented by Webhook + /* eslint-disable no-empty-function */ + send() {} + sendSlackMessage() {} + fetchMessage() {} + edit() {} + editMessage() {} + delete() {} + deleteMessage() {} + get createdTimestamp() {} + get createdAt() {} + get url() {} +} + +Webhook.applyToClass(WebhookClient); + +module.exports = WebhookClient; diff --git a/src/client/actions/Action.js b/src/client/actions/Action.js new file mode 100644 index 00000000..f70d3ca --- /dev/null +++ b/src/client/actions/Action.js @@ -0,0 +1,115 @@ +'use strict'; + +const Partials = require('../../util/Partials'); + +/* + +ABOUT ACTIONS + +Actions are similar to WebSocket Packet Handlers, but since introducing +the REST API methods, in order to prevent rewriting code to handle data, +"actions" have been introduced. They're basically what Packet Handlers +used to be but they're strictly for manipulating data and making sure +that WebSocket events don't clash with REST methods. + +*/ + +class GenericAction { + constructor(client) { + this.client = client; + } + + handle(data) { + return data; + } + + getPayload(data, manager, id, partialType, cache) { + const existing = manager.cache.get(id); + if (!existing && this.client.options.partials.includes(partialType)) { + return manager._add(data, cache); + } + return existing; + } + + getChannel(data) { + const id = data.channel_id ?? data.id; + return ( + data.channel ?? + this.getPayload( + { + id, + guild_id: data.guild_id, + recipients: [data.author ?? data.user ?? { id: data.user_id }], + }, + this.client.channels, + id, + Partials.Channel, + ) + ); + } + + getMessage(data, channel, cache) { + const id = data.message_id ?? data.id; + return ( + data.message ?? + this.getPayload( + { + id, + channel_id: channel.id, + guild_id: data.guild_id ?? channel.guild?.id, + }, + channel.messages, + id, + Partials.Message, + cache, + ) + ); + } + + getReaction(data, message, user) { + const id = data.emoji.id ?? decodeURIComponent(data.emoji.name); + return this.getPayload( + { + emoji: data.emoji, + count: message.partial ? null : 0, + me: user?.id === this.client.user.id, + }, + message.reactions, + id, + Partials.Reaction, + ); + } + + getMember(data, guild) { + return this.getPayload(data, guild.members, data.user.id, Partials.GuildMember); + } + + getUser(data) { + const id = data.user_id; + return data.user ?? this.getPayload({ id }, this.client.users, id, Partials.User); + } + + getUserFromMember(data) { + if (data.guild_id && data.member?.user) { + const guild = this.client.guilds.cache.get(data.guild_id); + if (guild) { + return guild.members._add(data.member).user; + } else { + return this.client.users._add(data.member.user); + } + } + return this.getUser(data); + } + + getScheduledEvent(data, guild) { + const id = data.guild_scheduled_event_id ?? data.id; + return this.getPayload( + { id, guild_id: data.guild_id ?? guild.id }, + guild.scheduledEvents, + id, + Partials.GuildScheduledEvent, + ); + } +} + +module.exports = GenericAction; diff --git a/src/client/actions/ActionsManager.js b/src/client/actions/ActionsManager.js new file mode 100644 index 00000000..5841777 --- /dev/null +++ b/src/client/actions/ActionsManager.js @@ -0,0 +1,66 @@ +'use strict'; + +class ActionsManager { + constructor(client) { + this.client = client; + + this.register(require('./ChannelCreate')); + this.register(require('./ChannelDelete')); + this.register(require('./ChannelUpdate')); + this.register(require('./GuildBanAdd')); + this.register(require('./GuildBanRemove')); + this.register(require('./GuildChannelsPositionUpdate')); + this.register(require('./GuildDelete')); + this.register(require('./GuildEmojiCreate')); + this.register(require('./GuildEmojiDelete')); + this.register(require('./GuildEmojiUpdate')); + this.register(require('./GuildEmojisUpdate')); + this.register(require('./GuildIntegrationsUpdate')); + this.register(require('./GuildMemberRemove')); + this.register(require('./GuildMemberUpdate')); + this.register(require('./GuildRoleCreate')); + this.register(require('./GuildRoleDelete')); + this.register(require('./GuildRoleUpdate')); + this.register(require('./GuildRolesPositionUpdate')); + this.register(require('./GuildScheduledEventCreate')); + this.register(require('./GuildScheduledEventDelete')); + this.register(require('./GuildScheduledEventUpdate')); + this.register(require('./GuildScheduledEventUserAdd')); + this.register(require('./GuildScheduledEventUserRemove')); + this.register(require('./GuildStickerCreate')); + this.register(require('./GuildStickerDelete')); + this.register(require('./GuildStickerUpdate')); + this.register(require('./GuildStickersUpdate')); + this.register(require('./GuildUpdate')); + this.register(require('./InteractionCreate')); + this.register(require('./InviteCreate')); + this.register(require('./InviteDelete')); + this.register(require('./MessageCreate')); + this.register(require('./MessageDelete')); + this.register(require('./MessageDeleteBulk')); + this.register(require('./MessageReactionAdd')); + this.register(require('./MessageReactionRemove')); + this.register(require('./MessageReactionRemoveAll')); + this.register(require('./MessageReactionRemoveEmoji')); + this.register(require('./MessageUpdate')); + this.register(require('./PresenceUpdate')); + this.register(require('./StageInstanceCreate')); + this.register(require('./StageInstanceDelete')); + this.register(require('./StageInstanceUpdate')); + this.register(require('./ThreadCreate')); + this.register(require('./ThreadDelete')); + this.register(require('./ThreadListSync')); + this.register(require('./ThreadMemberUpdate')); + this.register(require('./ThreadMembersUpdate')); + this.register(require('./TypingStart')); + this.register(require('./UserUpdate')); + this.register(require('./VoiceStateUpdate')); + this.register(require('./WebhooksUpdate')); + } + + register(Action) { + this[Action.name.replace(/Action$/, '')] = new Action(this.client); + } +} + +module.exports = ActionsManager; diff --git a/src/client/actions/ChannelCreate.js b/src/client/actions/ChannelCreate.js new file mode 100644 index 00000000..fdf8ddd --- /dev/null +++ b/src/client/actions/ChannelCreate.js @@ -0,0 +1,23 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class ChannelCreateAction extends Action { + handle(data) { + const client = this.client; + const existing = client.channels.cache.has(data.id); + const channel = client.channels._add(data); + if (!existing && channel) { + /** + * Emitted whenever a guild channel is created. + * @event Client#channelCreate + * @param {GuildChannel} channel The channel that was created + */ + client.emit(Events.ChannelCreate, channel); + } + return { channel }; + } +} + +module.exports = ChannelCreateAction; diff --git a/src/client/actions/ChannelDelete.js b/src/client/actions/ChannelDelete.js new file mode 100644 index 00000000..acf03d9 --- /dev/null +++ b/src/client/actions/ChannelDelete.js @@ -0,0 +1,23 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class ChannelDeleteAction extends Action { + handle(data) { + const client = this.client; + const channel = client.channels.cache.get(data.id); + + if (channel) { + client.channels._remove(channel.id); + /** + * Emitted whenever a channel is deleted. + * @event Client#channelDelete + * @param {DMChannel|GuildChannel} channel The channel that was deleted + */ + client.emit(Events.ChannelDelete, channel); + } + } +} + +module.exports = ChannelDeleteAction; diff --git a/src/client/actions/ChannelUpdate.js b/src/client/actions/ChannelUpdate.js new file mode 100644 index 00000000..88ee7f1 --- /dev/null +++ b/src/client/actions/ChannelUpdate.js @@ -0,0 +1,33 @@ +'use strict'; + +const Action = require('./Action'); +const { Channel } = require('../../structures/Channel'); + +class ChannelUpdateAction extends Action { + handle(data) { + const client = this.client; + + let channel = client.channels.cache.get(data.id); + if (channel) { + const old = channel._update(data); + + if (channel.type !== data.type) { + const newChannel = Channel.create(this.client, data, channel.guild); + for (const [id, message] of channel.messages.cache) newChannel.messages.cache.set(id, message); + channel = newChannel; + this.client.channels.cache.set(channel.id, channel); + } + + return { + old, + updated: channel, + }; + } else { + client.channels._add(data); + } + + return {}; + } +} + +module.exports = ChannelUpdateAction; diff --git a/src/client/actions/GuildBanAdd.js b/src/client/actions/GuildBanAdd.js new file mode 100644 index 00000000..2ef4b11 --- /dev/null +++ b/src/client/actions/GuildBanAdd.js @@ -0,0 +1,20 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildBanAdd extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + + /** + * Emitted whenever a member is banned from a guild. + * @event Client#guildBanAdd + * @param {GuildBan} ban The ban that occurred + */ + if (guild) client.emit(Events.GuildBanAdd, guild.bans._add(data)); + } +} + +module.exports = GuildBanAdd; diff --git a/src/client/actions/GuildBanRemove.js b/src/client/actions/GuildBanRemove.js new file mode 100644 index 00000000..8048efd --- /dev/null +++ b/src/client/actions/GuildBanRemove.js @@ -0,0 +1,25 @@ +'use strict'; + +const Action = require('./Action'); +const GuildBan = require('../../structures/GuildBan'); +const Events = require('../../util/Events'); + +class GuildBanRemove extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + + /** + * Emitted whenever a member is unbanned from a guild. + * @event Client#guildBanRemove + * @param {GuildBan} ban The ban that was removed + */ + if (guild) { + const ban = guild.bans.cache.get(data.user.id) ?? new GuildBan(client, data, guild); + guild.bans.cache.delete(ban.user.id); + client.emit(Events.GuildBanRemove, ban); + } + } +} + +module.exports = GuildBanRemove; diff --git a/src/client/actions/GuildChannelsPositionUpdate.js b/src/client/actions/GuildChannelsPositionUpdate.js new file mode 100644 index 00000000..a393167 --- /dev/null +++ b/src/client/actions/GuildChannelsPositionUpdate.js @@ -0,0 +1,21 @@ +'use strict'; + +const Action = require('./Action'); + +class GuildChannelsPositionUpdate extends Action { + handle(data) { + const client = this.client; + + const guild = client.guilds.cache.get(data.guild_id); + if (guild) { + for (const partialChannel of data.channels) { + const channel = guild.channels.cache.get(partialChannel.id); + if (channel) channel.rawPosition = partialChannel.position; + } + } + + return { guild }; + } +} + +module.exports = GuildChannelsPositionUpdate; diff --git a/src/client/actions/GuildDelete.js b/src/client/actions/GuildDelete.js new file mode 100644 index 00000000..eb0a44d --- /dev/null +++ b/src/client/actions/GuildDelete.js @@ -0,0 +1,44 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildDeleteAction extends Action { + handle(data) { + const client = this.client; + + let guild = client.guilds.cache.get(data.id); + if (guild) { + if (data.unavailable) { + // Guild is unavailable + guild.available = false; + + /** + * Emitted whenever a guild becomes unavailable, likely due to a server outage. + * @event Client#guildUnavailable + * @param {Guild} guild The guild that has become unavailable + */ + client.emit(Events.GuildUnavailable, guild); + + // Stops the GuildDelete packet thinking a guild was actually deleted, + // handles emitting of event itself + return; + } + + for (const channel of guild.channels.cache.values()) this.client.channels._remove(channel.id); + client.voice.adapters.get(data.id)?.destroy(); + + // Delete guild + client.guilds.cache.delete(guild.id); + + /** + * Emitted whenever a guild kicks the client or the guild is deleted/left. + * @event Client#guildDelete + * @param {Guild} guild The guild that was deleted + */ + client.emit(Events.GuildDelete, guild); + } + } +} + +module.exports = GuildDeleteAction; diff --git a/src/client/actions/GuildEmojiCreate.js b/src/client/actions/GuildEmojiCreate.js new file mode 100644 index 00000000..61858cf --- /dev/null +++ b/src/client/actions/GuildEmojiCreate.js @@ -0,0 +1,20 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildEmojiCreateAction extends Action { + handle(guild, createdEmoji) { + const already = guild.emojis.cache.has(createdEmoji.id); + const emoji = guild.emojis._add(createdEmoji); + /** + * Emitted whenever a custom emoji is created in a guild. + * @event Client#emojiCreate + * @param {GuildEmoji} emoji The emoji that was created + */ + if (!already) this.client.emit(Events.GuildEmojiCreate, emoji); + return { emoji }; + } +} + +module.exports = GuildEmojiCreateAction; diff --git a/src/client/actions/GuildEmojiDelete.js b/src/client/actions/GuildEmojiDelete.js new file mode 100644 index 00000000..e3373c2 --- /dev/null +++ b/src/client/actions/GuildEmojiDelete.js @@ -0,0 +1,19 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildEmojiDeleteAction extends Action { + handle(emoji) { + emoji.guild.emojis.cache.delete(emoji.id); + /** + * Emitted whenever a custom emoji is deleted in a guild. + * @event Client#emojiDelete + * @param {GuildEmoji} emoji The emoji that was deleted + */ + this.client.emit(Events.GuildEmojiDelete, emoji); + return { emoji }; + } +} + +module.exports = GuildEmojiDeleteAction; diff --git a/src/client/actions/GuildEmojiUpdate.js b/src/client/actions/GuildEmojiUpdate.js new file mode 100644 index 00000000..6bf9657 --- /dev/null +++ b/src/client/actions/GuildEmojiUpdate.js @@ -0,0 +1,20 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildEmojiUpdateAction extends Action { + handle(current, data) { + const old = current._update(data); + /** + * Emitted whenever a custom emoji is updated in a guild. + * @event Client#emojiUpdate + * @param {GuildEmoji} oldEmoji The old emoji + * @param {GuildEmoji} newEmoji The new emoji + */ + this.client.emit(Events.GuildEmojiUpdate, old, current); + return { emoji: current }; + } +} + +module.exports = GuildEmojiUpdateAction; diff --git a/src/client/actions/GuildEmojisUpdate.js b/src/client/actions/GuildEmojisUpdate.js new file mode 100644 index 00000000..7829db1 --- /dev/null +++ b/src/client/actions/GuildEmojisUpdate.js @@ -0,0 +1,34 @@ +'use strict'; + +const Action = require('./Action'); + +class GuildEmojisUpdateAction extends Action { + handle(data) { + const guild = this.client.guilds.cache.get(data.guild_id); + if (!guild?.emojis) return; + + const deletions = new Map(guild.emojis.cache); + + for (const emoji of data.emojis) { + // Determine type of emoji event + const cachedEmoji = guild.emojis.cache.get(emoji.id); + if (cachedEmoji) { + deletions.delete(emoji.id); + if (!cachedEmoji.equals(emoji)) { + // Emoji updated + this.client.actions.GuildEmojiUpdate.handle(cachedEmoji, emoji); + } + } else { + // Emoji added + this.client.actions.GuildEmojiCreate.handle(guild, emoji); + } + } + + for (const emoji of deletions.values()) { + // Emoji deleted + this.client.actions.GuildEmojiDelete.handle(emoji); + } + } +} + +module.exports = GuildEmojisUpdateAction; diff --git a/src/client/actions/GuildIntegrationsUpdate.js b/src/client/actions/GuildIntegrationsUpdate.js new file mode 100644 index 00000000..28b9bbb --- /dev/null +++ b/src/client/actions/GuildIntegrationsUpdate.js @@ -0,0 +1,19 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildIntegrationsUpdate extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + /** + * Emitted whenever a guild integration is updated + * @event Client#guildIntegrationsUpdate + * @param {Guild} guild The guild whose integrations were updated + */ + if (guild) client.emit(Events.GuildIntegrationsUpdate, guild); + } +} + +module.exports = GuildIntegrationsUpdate; diff --git a/src/client/actions/GuildMemberRemove.js b/src/client/actions/GuildMemberRemove.js new file mode 100644 index 00000000..646f4ec --- /dev/null +++ b/src/client/actions/GuildMemberRemove.js @@ -0,0 +1,30 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); +const Status = require('../../util/Status'); + +class GuildMemberRemoveAction extends Action { + handle(data, shard) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + let member = null; + if (guild) { + member = this.getMember({ user: data.user }, guild); + guild.memberCount--; + if (member) { + guild.members.cache.delete(member.id); + /** + * Emitted whenever a member leaves a guild, or is kicked. + * @event Client#guildMemberRemove + * @param {GuildMember} member The member that has left/been kicked from the guild + */ + if (shard.status === Status.Ready) client.emit(Events.GuildMemberRemove, member); + } + guild.voiceStates.cache.delete(data.user.id); + } + return { guild, member }; + } +} + +module.exports = GuildMemberRemoveAction; diff --git a/src/client/actions/GuildMemberUpdate.js b/src/client/actions/GuildMemberUpdate.js new file mode 100644 index 00000000..dc41a79 --- /dev/null +++ b/src/client/actions/GuildMemberUpdate.js @@ -0,0 +1,44 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); +const Status = require('../../util/Status'); + +class GuildMemberUpdateAction extends Action { + handle(data, shard) { + const { client } = this; + if (data.user.username) { + const user = client.users.cache.get(data.user.id); + if (!user) { + client.users._add(data.user); + } else if (!user._equals(data.user)) { + client.actions.UserUpdate.handle(data.user); + } + } + + const guild = client.guilds.cache.get(data.guild_id); + if (guild) { + const member = this.getMember({ user: data.user }, guild); + if (member) { + const old = member._update(data); + /** + * Emitted whenever a guild member changes - i.e. new role, removed role, nickname. + * @event Client#guildMemberUpdate + * @param {GuildMember} oldMember The member before the update + * @param {GuildMember} newMember The member after the update + */ + if (shard.status === Status.Ready && !member.equals(old)) client.emit(Events.GuildMemberUpdate, old, member); + } else { + const newMember = guild.members._add(data); + /** + * Emitted whenever a member becomes available in a large guild. + * @event Client#guildMemberAvailable + * @param {GuildMember} member The member that became available + */ + this.client.emit(Events.GuildMemberAvailable, newMember); + } + } + } +} + +module.exports = GuildMemberUpdateAction; diff --git a/src/client/actions/GuildRoleCreate.js b/src/client/actions/GuildRoleCreate.js new file mode 100644 index 00000000..461443b --- /dev/null +++ b/src/client/actions/GuildRoleCreate.js @@ -0,0 +1,25 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildRoleCreate extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + let role; + if (guild) { + const already = guild.roles.cache.has(data.role.id); + role = guild.roles._add(data.role); + /** + * Emitted whenever a role is created. + * @event Client#roleCreate + * @param {Role} role The role that was created + */ + if (!already) client.emit(Events.GuildRoleCreate, role); + } + return { role }; + } +} + +module.exports = GuildRoleCreate; diff --git a/src/client/actions/GuildRoleDelete.js b/src/client/actions/GuildRoleDelete.js new file mode 100644 index 00000000..e043a1a --- /dev/null +++ b/src/client/actions/GuildRoleDelete.js @@ -0,0 +1,29 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildRoleDeleteAction extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + let role; + + if (guild) { + role = guild.roles.cache.get(data.role_id); + if (role) { + guild.roles.cache.delete(data.role_id); + /** + * Emitted whenever a guild role is deleted. + * @event Client#roleDelete + * @param {Role} role The role that was deleted + */ + client.emit(Events.GuildRoleDelete, role); + } + } + + return { role }; + } +} + +module.exports = GuildRoleDeleteAction; diff --git a/src/client/actions/GuildRoleUpdate.js b/src/client/actions/GuildRoleUpdate.js new file mode 100644 index 00000000..b0632c5 --- /dev/null +++ b/src/client/actions/GuildRoleUpdate.js @@ -0,0 +1,39 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildRoleUpdateAction extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + + if (guild) { + let old = null; + + const role = guild.roles.cache.get(data.role.id); + if (role) { + old = role._update(data.role); + /** + * Emitted whenever a guild role is updated. + * @event Client#roleUpdate + * @param {Role} oldRole The role before the update + * @param {Role} newRole The role after the update + */ + client.emit(Events.GuildRoleUpdate, old, role); + } + + return { + old, + updated: role, + }; + } + + return { + old: null, + updated: null, + }; + } +} + +module.exports = GuildRoleUpdateAction; diff --git a/src/client/actions/GuildRolesPositionUpdate.js b/src/client/actions/GuildRolesPositionUpdate.js new file mode 100644 index 00000000..d7abca9 --- /dev/null +++ b/src/client/actions/GuildRolesPositionUpdate.js @@ -0,0 +1,21 @@ +'use strict'; + +const Action = require('./Action'); + +class GuildRolesPositionUpdate extends Action { + handle(data) { + const client = this.client; + + const guild = client.guilds.cache.get(data.guild_id); + if (guild) { + for (const partialRole of data.roles) { + const role = guild.roles.cache.get(partialRole.id); + if (role) role.rawPosition = partialRole.position; + } + } + + return { guild }; + } +} + +module.exports = GuildRolesPositionUpdate; diff --git a/src/client/actions/GuildScheduledEventCreate.js b/src/client/actions/GuildScheduledEventCreate.js new file mode 100644 index 00000000..0a2fb9b --- /dev/null +++ b/src/client/actions/GuildScheduledEventCreate.js @@ -0,0 +1,27 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildScheduledEventCreateAction extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + if (guild) { + const guildScheduledEvent = guild.scheduledEvents._add(data); + + /** + * Emitted whenever a guild scheduled event is created. + * @event Client#guildScheduledEventCreate + * @param {GuildScheduledEvent} guildScheduledEvent The created guild scheduled event + */ + client.emit(Events.GuildScheduledEventCreate, guildScheduledEvent); + + return { guildScheduledEvent }; + } + + return {}; + } +} + +module.exports = GuildScheduledEventCreateAction; diff --git a/src/client/actions/GuildScheduledEventDelete.js b/src/client/actions/GuildScheduledEventDelete.js new file mode 100644 index 00000000..636bfc5 --- /dev/null +++ b/src/client/actions/GuildScheduledEventDelete.js @@ -0,0 +1,31 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildScheduledEventDeleteAction extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + + if (guild) { + const guildScheduledEvent = this.getScheduledEvent(data, guild); + if (guildScheduledEvent) { + guild.scheduledEvents.cache.delete(guildScheduledEvent.id); + + /** + * Emitted whenever a guild scheduled event is deleted. + * @event Client#guildScheduledEventDelete + * @param {GuildScheduledEvent} guildScheduledEvent The deleted guild scheduled event + */ + client.emit(Events.GuildScheduledEventDelete, guildScheduledEvent); + + return { guildScheduledEvent }; + } + } + + return {}; + } +} + +module.exports = GuildScheduledEventDeleteAction; diff --git a/src/client/actions/GuildScheduledEventUpdate.js b/src/client/actions/GuildScheduledEventUpdate.js new file mode 100644 index 00000000..7cabd85 --- /dev/null +++ b/src/client/actions/GuildScheduledEventUpdate.js @@ -0,0 +1,30 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildScheduledEventUpdateAction extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + + if (guild) { + const oldGuildScheduledEvent = guild.scheduledEvents.cache.get(data.id)?._clone() ?? null; + const newGuildScheduledEvent = guild.scheduledEvents._add(data); + + /** + * Emitted whenever a guild scheduled event gets updated. + * @event Client#guildScheduledEventUpdate + * @param {?GuildScheduledEvent} oldGuildScheduledEvent The guild scheduled event object before the update + * @param {GuildScheduledEvent} newGuildScheduledEvent The guild scheduled event object after the update + */ + client.emit(Events.GuildScheduledEventUpdate, oldGuildScheduledEvent, newGuildScheduledEvent); + + return { oldGuildScheduledEvent, newGuildScheduledEvent }; + } + + return {}; + } +} + +module.exports = GuildScheduledEventUpdateAction; diff --git a/src/client/actions/GuildScheduledEventUserAdd.js b/src/client/actions/GuildScheduledEventUserAdd.js new file mode 100644 index 00000000..03520db --- /dev/null +++ b/src/client/actions/GuildScheduledEventUserAdd.js @@ -0,0 +1,32 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildScheduledEventUserAddAction extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + + if (guild) { + const guildScheduledEvent = this.getScheduledEvent(data, guild); + const user = this.getUser(data); + + if (guildScheduledEvent && user) { + /** + * Emitted whenever a user subscribes to a guild scheduled event + * @event Client#guildScheduledEventUserAdd + * @param {GuildScheduledEvent} guildScheduledEvent The guild scheduled event + * @param {User} user The user who subscribed + */ + client.emit(Events.GuildScheduledEventUserAdd, guildScheduledEvent, user); + + return { guildScheduledEvent, user }; + } + } + + return {}; + } +} + +module.exports = GuildScheduledEventUserAddAction; diff --git a/src/client/actions/GuildScheduledEventUserRemove.js b/src/client/actions/GuildScheduledEventUserRemove.js new file mode 100644 index 00000000..2a04849 --- /dev/null +++ b/src/client/actions/GuildScheduledEventUserRemove.js @@ -0,0 +1,32 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildScheduledEventUserRemoveAction extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + + if (guild) { + const guildScheduledEvent = this.getScheduledEvent(data, guild); + const user = this.getUser(data); + + if (guildScheduledEvent && user) { + /** + * Emitted whenever a user unsubscribes from a guild scheduled event + * @event Client#guildScheduledEventUserRemove + * @param {GuildScheduledEvent} guildScheduledEvent The guild scheduled event + * @param {User} user The user who unsubscribed + */ + client.emit(Events.GuildScheduledEventUserRemove, guildScheduledEvent, user); + + return { guildScheduledEvent, user }; + } + } + + return {}; + } +} + +module.exports = GuildScheduledEventUserRemoveAction; diff --git a/src/client/actions/GuildStickerCreate.js b/src/client/actions/GuildStickerCreate.js new file mode 100644 index 00000000..7d81de9 --- /dev/null +++ b/src/client/actions/GuildStickerCreate.js @@ -0,0 +1,20 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildStickerCreateAction extends Action { + handle(guild, createdSticker) { + const already = guild.stickers.cache.has(createdSticker.id); + const sticker = guild.stickers._add(createdSticker); + /** + * Emitted whenever a custom sticker is created in a guild. + * @event Client#stickerCreate + * @param {Sticker} sticker The sticker that was created + */ + if (!already) this.client.emit(Events.GuildStickerCreate, sticker); + return { sticker }; + } +} + +module.exports = GuildStickerCreateAction; diff --git a/src/client/actions/GuildStickerDelete.js b/src/client/actions/GuildStickerDelete.js new file mode 100644 index 00000000..7fd6b57 --- /dev/null +++ b/src/client/actions/GuildStickerDelete.js @@ -0,0 +1,19 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildStickerDeleteAction extends Action { + handle(sticker) { + sticker.guild.stickers.cache.delete(sticker.id); + /** + * Emitted whenever a custom sticker is deleted in a guild. + * @event Client#stickerDelete + * @param {Sticker} sticker The sticker that was deleted + */ + this.client.emit(Events.GuildStickerDelete, sticker); + return { sticker }; + } +} + +module.exports = GuildStickerDeleteAction; diff --git a/src/client/actions/GuildStickerUpdate.js b/src/client/actions/GuildStickerUpdate.js new file mode 100644 index 00000000..5561c7e --- /dev/null +++ b/src/client/actions/GuildStickerUpdate.js @@ -0,0 +1,20 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildStickerUpdateAction extends Action { + handle(current, data) { + const old = current._update(data); + /** + * Emitted whenever a custom sticker is updated in a guild. + * @event Client#stickerUpdate + * @param {Sticker} oldSticker The old sticker + * @param {Sticker} newSticker The new sticker + */ + this.client.emit(Events.GuildStickerUpdate, old, current); + return { sticker: current }; + } +} + +module.exports = GuildStickerUpdateAction; diff --git a/src/client/actions/GuildStickersUpdate.js b/src/client/actions/GuildStickersUpdate.js new file mode 100644 index 00000000..ccf1d63 --- /dev/null +++ b/src/client/actions/GuildStickersUpdate.js @@ -0,0 +1,34 @@ +'use strict'; + +const Action = require('./Action'); + +class GuildStickersUpdateAction extends Action { + handle(data) { + const guild = this.client.guilds.cache.get(data.guild_id); + if (!guild?.stickers) return; + + const deletions = new Map(guild.stickers.cache); + + for (const sticker of data.stickers) { + // Determine type of sticker event + const cachedSticker = guild.stickers.cache.get(sticker.id); + if (cachedSticker) { + deletions.delete(sticker.id); + if (!cachedSticker.equals(sticker)) { + // Sticker updated + this.client.actions.GuildStickerUpdate.handle(cachedSticker, sticker); + } + } else { + // Sticker added + this.client.actions.GuildStickerCreate.handle(guild, sticker); + } + } + + for (const sticker of deletions.values()) { + // Sticker deleted + this.client.actions.GuildStickerDelete.handle(sticker); + } + } +} + +module.exports = GuildStickersUpdateAction; diff --git a/src/client/actions/GuildUpdate.js b/src/client/actions/GuildUpdate.js new file mode 100644 index 00000000..ef1f51b --- /dev/null +++ b/src/client/actions/GuildUpdate.js @@ -0,0 +1,33 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class GuildUpdateAction extends Action { + handle(data) { + const client = this.client; + + const guild = client.guilds.cache.get(data.id); + if (guild) { + const old = guild._update(data); + /** + * Emitted whenever a guild is updated - e.g. name change. + * @event Client#guildUpdate + * @param {Guild} oldGuild The guild before the update + * @param {Guild} newGuild The guild after the update + */ + client.emit(Events.GuildUpdate, old, guild); + return { + old, + updated: guild, + }; + } + + return { + old: null, + updated: null, + }; + } +} + +module.exports = GuildUpdateAction; diff --git a/src/client/actions/InteractionCreate.js b/src/client/actions/InteractionCreate.js new file mode 100644 index 00000000..8c36ec9 --- /dev/null +++ b/src/client/actions/InteractionCreate.js @@ -0,0 +1,76 @@ +'use strict'; + +const { InteractionType, ComponentType, ApplicationCommandType } = require('discord-api-types/v9'); +const Action = require('./Action'); +const AutocompleteInteraction = require('../../structures/AutocompleteInteraction'); +const ButtonInteraction = require('../../structures/ButtonInteraction'); +const ChatInputCommandInteraction = require('../../structures/ChatInputCommandInteraction'); +const MessageContextMenuCommandInteraction = require('../../structures/MessageContextMenuCommandInteraction'); +const SelectMenuInteraction = require('../../structures/SelectMenuInteraction'); +const UserContextMenuCommandInteraction = require('../../structures/UserContextMenuCommandInteraction'); +const Events = require('../../util/Events'); + +class InteractionCreateAction extends Action { + handle(data) { + const client = this.client; + + // Resolve and cache partial channels for Interaction#channel getter + this.getChannel(data); + + let InteractionClass; + switch (data.type) { + case InteractionType.ApplicationCommand: + switch (data.data.type) { + case ApplicationCommandType.ChatInput: + InteractionClass = ChatInputCommandInteraction; + break; + case ApplicationCommandType.User: + InteractionClass = UserContextMenuCommandInteraction; + break; + case ApplicationCommandType.Message: + InteractionClass = MessageContextMenuCommandInteraction; + break; + default: + client.emit( + Events.Debug, + `[INTERACTION] Received application command interaction with unknown type: ${data.data.type}`, + ); + return; + } + break; + case InteractionType.MessageComponent: + switch (data.data.component_type) { + case ComponentType.Button: + InteractionClass = ButtonInteraction; + break; + case ComponentType.SelectMenu: + InteractionClass = SelectMenuInteraction; + break; + default: + client.emit( + Events.Debug, + `[INTERACTION] Received component interaction with unknown type: ${data.data.component_type}`, + ); + return; + } + break; + case InteractionType.ApplicationCommandAutocomplete: + InteractionClass = AutocompleteInteraction; + break; + default: + client.emit(Events.Debug, `[INTERACTION] Received interaction with unknown type: ${data.type}`); + return; + } + + const interaction = new InteractionClass(client, data); + + /** + * Emitted when an interaction is created. + * @event Client#interactionCreate + * @param {Interaction} interaction The interaction which was created + */ + client.emit(Events.InteractionCreate, interaction); + } +} + +module.exports = InteractionCreateAction; diff --git a/src/client/actions/InviteCreate.js b/src/client/actions/InviteCreate.js new file mode 100644 index 00000000..2dc3019 --- /dev/null +++ b/src/client/actions/InviteCreate.js @@ -0,0 +1,28 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class InviteCreateAction extends Action { + handle(data) { + const client = this.client; + const channel = client.channels.cache.get(data.channel_id); + const guild = client.guilds.cache.get(data.guild_id); + if (!channel) return false; + + const inviteData = Object.assign(data, { channel, guild }); + const invite = guild.invites._add(inviteData); + + /** + * Emitted when an invite is created. + * This event only triggers if the client has `MANAGE_GUILD` permissions for the guild, + * or `MANAGE_CHANNELS` permissions for the channel. + * @event Client#inviteCreate + * @param {Invite} invite The invite that was created + */ + client.emit(Events.InviteCreate, invite); + return { invite }; + } +} + +module.exports = InviteCreateAction; diff --git a/src/client/actions/InviteDelete.js b/src/client/actions/InviteDelete.js new file mode 100644 index 00000000..37b8143 --- /dev/null +++ b/src/client/actions/InviteDelete.js @@ -0,0 +1,30 @@ +'use strict'; + +const Action = require('./Action'); +const Invite = require('../../structures/Invite'); +const Events = require('../../util/Events'); + +class InviteDeleteAction extends Action { + handle(data) { + const client = this.client; + const channel = client.channels.cache.get(data.channel_id); + const guild = client.guilds.cache.get(data.guild_id); + if (!channel) return false; + + const inviteData = Object.assign(data, { channel, guild }); + const invite = new Invite(client, inviteData); + guild.invites.cache.delete(invite.code); + + /** + * Emitted when an invite is deleted. + * This event only triggers if the client has `MANAGE_GUILD` permissions for the guild, + * or `MANAGE_CHANNELS` permissions for the channel. + * @event Client#inviteDelete + * @param {Invite} invite The invite that was deleted + */ + client.emit(Events.InviteDelete, invite); + return { invite }; + } +} + +module.exports = InviteDeleteAction; diff --git a/src/client/actions/MessageCreate.js b/src/client/actions/MessageCreate.js new file mode 100644 index 00000000..9a099e2 --- /dev/null +++ b/src/client/actions/MessageCreate.js @@ -0,0 +1,32 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class MessageCreateAction extends Action { + handle(data) { + const client = this.client; + const channel = this.getChannel(data); + if (channel) { + if (!channel.isTextBased()) return {}; + + const existing = channel.messages.cache.get(data.id); + if (existing) return { message: existing }; + const message = channel.messages._add(data); + channel.lastMessageId = data.id; + + /** + * Emitted whenever a message is created. + * @event Client#messageCreate + * @param {Message} message The created message + */ + client.emit(Events.MessageCreate, message); + + return { message }; + } + + return {}; + } +} + +module.exports = MessageCreateAction; diff --git a/src/client/actions/MessageDelete.js b/src/client/actions/MessageDelete.js new file mode 100644 index 00000000..cb55c67 --- /dev/null +++ b/src/client/actions/MessageDelete.js @@ -0,0 +1,30 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class MessageDeleteAction extends Action { + handle(data) { + const client = this.client; + const channel = this.getChannel(data); + let message; + if (channel) { + if (!channel.isTextBased()) return {}; + + message = this.getMessage(data, channel); + if (message) { + channel.messages.cache.delete(message.id); + /** + * Emitted whenever a message is deleted. + * @event Client#messageDelete + * @param {Message} message The deleted message + */ + client.emit(Events.MessageDelete, message); + } + } + + return { message }; + } +} + +module.exports = MessageDeleteAction; diff --git a/src/client/actions/MessageDeleteBulk.js b/src/client/actions/MessageDeleteBulk.js new file mode 100644 index 00000000..148e665 --- /dev/null +++ b/src/client/actions/MessageDeleteBulk.js @@ -0,0 +1,44 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class MessageDeleteBulkAction extends Action { + handle(data) { + const client = this.client; + const channel = client.channels.cache.get(data.channel_id); + + if (channel) { + if (!channel.isTextBased()) return {}; + + const ids = data.ids; + const messages = new Collection(); + for (const id of ids) { + const message = this.getMessage( + { + id, + guild_id: data.guild_id, + }, + channel, + false, + ); + if (message) { + messages.set(message.id, message); + channel.messages.cache.delete(id); + } + } + + /** + * Emitted whenever messages are deleted in bulk. + * @event Client#messageDeleteBulk + * @param {Collection} messages The deleted messages, mapped by their id + */ + if (messages.size > 0) client.emit(Events.MessageBulkDelete, messages); + return { messages }; + } + return {}; + } +} + +module.exports = MessageDeleteBulkAction; diff --git a/src/client/actions/MessageReactionAdd.js b/src/client/actions/MessageReactionAdd.js new file mode 100644 index 00000000..ea97bd6 --- /dev/null +++ b/src/client/actions/MessageReactionAdd.js @@ -0,0 +1,55 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); +const Partials = require('../../util/Partials'); + +/* +{ user_id: 'id', + message_id: 'id', + emoji: { name: '�', id: null }, + channel_id: 'id', + // If originating from a guild + guild_id: 'id', + member: { ..., user: { ... } } } +*/ + +class MessageReactionAdd extends Action { + handle(data, fromStructure = false) { + if (!data.emoji) return false; + + const user = this.getUserFromMember(data); + if (!user) return false; + + // Verify channel + const channel = this.getChannel(data); + if (!channel?.isTextBased()) return false; + + // Verify message + const message = this.getMessage(data, channel); + if (!message) return false; + + // Verify reaction + const includePartial = this.client.options.partials.includes(Partials.Reaction); + if (message.partial && !includePartial) return false; + const reaction = message.reactions._add({ + emoji: data.emoji, + count: message.partial ? null : 0, + me: user.id === this.client.user.id, + }); + if (!reaction) return false; + reaction._add(user); + if (fromStructure) return { message, reaction, user }; + /** + * Emitted whenever a reaction is added to a cached message. + * @event Client#messageReactionAdd + * @param {MessageReaction} messageReaction The reaction object + * @param {User} user The user that applied the guild or reaction emoji + */ + this.client.emit(Events.MessageReactionAdd, reaction, user); + + return { message, reaction, user }; + } +} + +module.exports = MessageReactionAdd; diff --git a/src/client/actions/MessageReactionRemove.js b/src/client/actions/MessageReactionRemove.js new file mode 100644 index 00000000..9ca3a8e --- /dev/null +++ b/src/client/actions/MessageReactionRemove.js @@ -0,0 +1,45 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +/* +{ user_id: 'id', + message_id: 'id', + emoji: { name: '�', id: null }, + channel_id: 'id', + guild_id: 'id' } +*/ + +class MessageReactionRemove extends Action { + handle(data) { + if (!data.emoji) return false; + + const user = this.getUser(data); + if (!user) return false; + + // Verify channel + const channel = this.getChannel(data); + if (!channel?.isTextBased()) return false; + + // Verify message + const message = this.getMessage(data, channel); + if (!message) return false; + + // Verify reaction + const reaction = this.getReaction(data, message, user); + if (!reaction) return false; + reaction._remove(user); + /** + * Emitted whenever a reaction is removed from a cached message. + * @event Client#messageReactionRemove + * @param {MessageReaction} messageReaction The reaction object + * @param {User} user The user whose emoji or reaction emoji was removed + */ + this.client.emit(Events.MessageReactionRemove, reaction, user); + + return { message, reaction, user }; + } +} + +module.exports = MessageReactionRemove; diff --git a/src/client/actions/MessageReactionRemoveAll.js b/src/client/actions/MessageReactionRemoveAll.js new file mode 100644 index 00000000..b1c023f --- /dev/null +++ b/src/client/actions/MessageReactionRemoveAll.js @@ -0,0 +1,33 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class MessageReactionRemoveAll extends Action { + handle(data) { + // Verify channel + const channel = this.getChannel(data); + if (!channel?.isTextBased()) return false; + + // Verify message + const message = this.getMessage(data, channel); + if (!message) return false; + + // Copy removed reactions to emit for the event. + const removed = message.reactions.cache.clone(); + + message.reactions.cache.clear(); + this.client.emit(Events.MessageReactionRemoveAll, message, removed); + + return { message }; + } +} + +/** + * Emitted whenever all reactions are removed from a cached message. + * @event Client#messageReactionRemoveAll + * @param {Message} message The message the reactions were removed from + * @param {Collection} reactions The cached message reactions that were removed. + */ + +module.exports = MessageReactionRemoveAll; diff --git a/src/client/actions/MessageReactionRemoveEmoji.js b/src/client/actions/MessageReactionRemoveEmoji.js new file mode 100644 index 00000000..3290214 --- /dev/null +++ b/src/client/actions/MessageReactionRemoveEmoji.js @@ -0,0 +1,28 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class MessageReactionRemoveEmoji extends Action { + handle(data) { + const channel = this.getChannel(data); + if (!channel?.isTextBased()) return false; + + const message = this.getMessage(data, channel); + if (!message) return false; + + const reaction = this.getReaction(data, message); + if (!reaction) return false; + if (!message.partial) message.reactions.cache.delete(reaction.emoji.id ?? reaction.emoji.name); + + /** + * Emitted when a bot removes an emoji reaction from a cached message. + * @event Client#messageReactionRemoveEmoji + * @param {MessageReaction} reaction The reaction that was removed + */ + this.client.emit(Events.MessageReactionRemoveEmoji, reaction); + return { reaction }; + } +} + +module.exports = MessageReactionRemoveEmoji; diff --git a/src/client/actions/MessageUpdate.js b/src/client/actions/MessageUpdate.js new file mode 100644 index 00000000..fe757c0 --- /dev/null +++ b/src/client/actions/MessageUpdate.js @@ -0,0 +1,26 @@ +'use strict'; + +const Action = require('./Action'); + +class MessageUpdateAction extends Action { + handle(data) { + const channel = this.getChannel(data); + if (channel) { + if (!channel.isTextBased()) return {}; + + const { id, channel_id, guild_id, author, timestamp, type } = data; + const message = this.getMessage({ id, channel_id, guild_id, author, timestamp, type }, channel); + if (message) { + const old = message._update(data); + return { + old, + updated: message, + }; + } + } + + return {}; + } +} + +module.exports = MessageUpdateAction; diff --git a/src/client/actions/PresenceUpdate.js b/src/client/actions/PresenceUpdate.js new file mode 100644 index 00000000..0b4aaab --- /dev/null +++ b/src/client/actions/PresenceUpdate.js @@ -0,0 +1,42 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class PresenceUpdateAction extends Action { + handle(data) { + let user = this.client.users.cache.get(data.user.id); + if (!user && data.user.username) user = this.client.users._add(data.user); + if (!user) return; + + if (data.user.username) { + if (!user._equals(data.user)) this.client.actions.UserUpdate.handle(data.user); + } + + const guild = this.client.guilds.cache.get(data.guild_id); + if (!guild) return; + + const oldPresence = guild.presences.cache.get(user.id)?._clone() ?? null; + let member = guild.members.cache.get(user.id); + if (!member && data.status !== 'offline') { + member = guild.members._add({ + user, + deaf: false, + mute: false, + }); + this.client.emit(Events.GuildMemberAvailable, member); + } + const newPresence = guild.presences._add(Object.assign(data, { guild })); + if (this.client.listenerCount(Events.PresenceUpdate) && !newPresence.equals(oldPresence)) { + /** + * Emitted whenever a guild member's presence (e.g. status, activity) is changed. + * @event Client#presenceUpdate + * @param {?Presence} oldPresence The presence before the update, if one at all + * @param {Presence} newPresence The presence after the update + */ + this.client.emit(Events.PresenceUpdate, oldPresence, newPresence); + } + } +} + +module.exports = PresenceUpdateAction; diff --git a/src/client/actions/StageInstanceCreate.js b/src/client/actions/StageInstanceCreate.js new file mode 100644 index 00000000..4edd530 --- /dev/null +++ b/src/client/actions/StageInstanceCreate.js @@ -0,0 +1,28 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class StageInstanceCreateAction extends Action { + handle(data) { + const client = this.client; + const channel = this.getChannel(data); + + if (channel) { + const stageInstance = channel.guild.stageInstances._add(data); + + /** + * Emitted whenever a stage instance is created. + * @event Client#stageInstanceCreate + * @param {StageInstance} stageInstance The created stage instance + */ + client.emit(Events.StageInstanceCreate, stageInstance); + + return { stageInstance }; + } + + return {}; + } +} + +module.exports = StageInstanceCreateAction; diff --git a/src/client/actions/StageInstanceDelete.js b/src/client/actions/StageInstanceDelete.js new file mode 100644 index 00000000..0d5da38 --- /dev/null +++ b/src/client/actions/StageInstanceDelete.js @@ -0,0 +1,31 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class StageInstanceDeleteAction extends Action { + handle(data) { + const client = this.client; + const channel = this.getChannel(data); + + if (channel) { + const stageInstance = channel.guild.stageInstances._add(data); + if (stageInstance) { + channel.guild.stageInstances.cache.delete(stageInstance.id); + + /** + * Emitted whenever a stage instance is deleted. + * @event Client#stageInstanceDelete + * @param {StageInstance} stageInstance The deleted stage instance + */ + client.emit(Events.StageInstanceDelete, stageInstance); + + return { stageInstance }; + } + } + + return {}; + } +} + +module.exports = StageInstanceDeleteAction; diff --git a/src/client/actions/StageInstanceUpdate.js b/src/client/actions/StageInstanceUpdate.js new file mode 100644 index 00000000..008a53c --- /dev/null +++ b/src/client/actions/StageInstanceUpdate.js @@ -0,0 +1,30 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class StageInstanceUpdateAction extends Action { + handle(data) { + const client = this.client; + const channel = this.getChannel(data); + + if (channel) { + const oldStageInstance = channel.guild.stageInstances.cache.get(data.id)?._clone() ?? null; + const newStageInstance = channel.guild.stageInstances._add(data); + + /** + * Emitted whenever a stage instance gets updated - e.g. change in topic or privacy level + * @event Client#stageInstanceUpdate + * @param {?StageInstance} oldStageInstance The stage instance before the update + * @param {StageInstance} newStageInstance The stage instance after the update + */ + client.emit(Events.StageInstanceUpdate, oldStageInstance, newStageInstance); + + return { oldStageInstance, newStageInstance }; + } + + return {}; + } +} + +module.exports = StageInstanceUpdateAction; diff --git a/src/client/actions/ThreadCreate.js b/src/client/actions/ThreadCreate.js new file mode 100644 index 00000000..a8ff6c6 --- /dev/null +++ b/src/client/actions/ThreadCreate.js @@ -0,0 +1,24 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class ThreadCreateAction extends Action { + handle(data) { + const client = this.client; + const existing = client.channels.cache.has(data.id); + const thread = client.channels._add(data); + if (!existing && thread) { + /** + * Emitted whenever a thread is created or when the client user is added to a thread. + * @event Client#threadCreate + * @param {ThreadChannel} thread The thread that was created + * @param {boolean} newlyCreated Whether the thread was newly created + */ + client.emit(Events.ThreadCreate, thread, data.newly_created ?? false); + } + return { thread }; + } +} + +module.exports = ThreadCreateAction; diff --git a/src/client/actions/ThreadDelete.js b/src/client/actions/ThreadDelete.js new file mode 100644 index 00000000..3ec81a4 --- /dev/null +++ b/src/client/actions/ThreadDelete.js @@ -0,0 +1,26 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class ThreadDeleteAction extends Action { + handle(data) { + const client = this.client; + const thread = client.channels.cache.get(data.id); + + if (thread) { + client.channels._remove(thread.id); + + /** + * Emitted whenever a thread is deleted. + * @event Client#threadDelete + * @param {ThreadChannel} thread The thread that was deleted + */ + client.emit(Events.ThreadDelete, thread); + } + + return { thread }; + } +} + +module.exports = ThreadDeleteAction; diff --git a/src/client/actions/ThreadListSync.js b/src/client/actions/ThreadListSync.js new file mode 100644 index 00000000..bad1a47 --- /dev/null +++ b/src/client/actions/ThreadListSync.js @@ -0,0 +1,59 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class ThreadListSyncAction extends Action { + handle(data) { + const client = this.client; + + const guild = client.guilds.cache.get(data.guild_id); + if (!guild) return {}; + + if (data.channel_ids) { + for (const id of data.channel_ids) { + const channel = client.channels.resolve(id); + if (channel) this.removeStale(channel); + } + } else { + for (const channel of guild.channels.cache.values()) { + this.removeStale(channel); + } + } + + const syncedThreads = data.threads.reduce((coll, rawThread) => { + const thread = client.channels._add(rawThread); + return coll.set(thread.id, thread); + }, new Collection()); + + for (const rawMember of Object.values(data.members)) { + // Discord sends the thread id as id in this object + const thread = client.channels.cache.get(rawMember.id); + if (thread) { + thread.members._add(rawMember); + } + } + + /** + * Emitted whenever the client user gains access to a text or news channel that contains threads + * @event Client#threadListSync + * @param {Collection} threads The threads that were synced + */ + client.emit(Events.ThreadListSync, syncedThreads); + + return { + syncedThreads, + }; + } + + removeStale(channel) { + channel.threads?.cache.forEach(thread => { + if (!thread.archived) { + this.client.channels._remove(thread.id); + } + }); + } +} + +module.exports = ThreadListSyncAction; diff --git a/src/client/actions/ThreadMemberUpdate.js b/src/client/actions/ThreadMemberUpdate.js new file mode 100644 index 00000000..0b17f70 --- /dev/null +++ b/src/client/actions/ThreadMemberUpdate.js @@ -0,0 +1,30 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class ThreadMemberUpdateAction extends Action { + handle(data) { + const client = this.client; + // Discord sends the thread id as id in this object + const thread = client.channels.cache.get(data.id); + if (thread) { + const member = thread.members.cache.get(data.user_id); + if (!member) { + const newMember = thread.members._add(data); + return { newMember }; + } + const old = member._update(data); + /** + * Emitted whenever the client user's thread member is updated. + * @event Client#threadMemberUpdate + * @param {ThreadMember} oldMember The member before the update + * @param {ThreadMember} newMember The member after the update + */ + client.emit(Events.ThreadMemberUpdate, old, member); + } + return {}; + } +} + +module.exports = ThreadMemberUpdateAction; diff --git a/src/client/actions/ThreadMembersUpdate.js b/src/client/actions/ThreadMembersUpdate.js new file mode 100644 index 00000000..26ab70e --- /dev/null +++ b/src/client/actions/ThreadMembersUpdate.js @@ -0,0 +1,34 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class ThreadMembersUpdateAction extends Action { + handle(data) { + const client = this.client; + const thread = client.channels.cache.get(data.id); + if (thread) { + const old = thread.members.cache.clone(); + thread.memberCount = data.member_count; + + data.added_members?.forEach(rawMember => { + thread.members._add(rawMember); + }); + + data.removed_member_ids?.forEach(memberId => { + thread.members.cache.delete(memberId); + }); + + /** + * Emitted whenever members are added or removed from a thread. Requires `GUILD_MEMBERS` privileged intent + * @event Client#threadMembersUpdate + * @param {Collection} oldMembers The members before the update + * @param {Collection} newMembers The members after the update + */ + client.emit(Events.ThreadMembersUpdate, old, thread.members.cache); + } + return {}; + } +} + +module.exports = ThreadMembersUpdateAction; diff --git a/src/client/actions/TypingStart.js b/src/client/actions/TypingStart.js new file mode 100644 index 00000000..4e79920 --- /dev/null +++ b/src/client/actions/TypingStart.js @@ -0,0 +1,29 @@ +'use strict'; + +const Action = require('./Action'); +const Typing = require('../../structures/Typing'); +const Events = require('../../util/Events'); + +class TypingStart extends Action { + handle(data) { + const channel = this.getChannel(data); + if (!channel) return; + + if (!channel.isTextBased()) { + this.client.emit(Events.Warn, `Discord sent a typing packet to a ${channel.type} channel ${channel.id}`); + return; + } + + const user = this.getUserFromMember(data); + if (user) { + /** + * Emitted whenever a user starts typing in a channel. + * @event Client#typingStart + * @param {Typing} typing The typing state + */ + this.client.emit(Events.TypingStart, new Typing(channel, user, data)); + } + } +} + +module.exports = TypingStart; diff --git a/src/client/actions/UserUpdate.js b/src/client/actions/UserUpdate.js new file mode 100644 index 00000000..1bf236a --- /dev/null +++ b/src/client/actions/UserUpdate.js @@ -0,0 +1,35 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class UserUpdateAction extends Action { + handle(data) { + const client = this.client; + + const newUser = data.id === client.user.id ? client.user : client.users.cache.get(data.id); + const oldUser = newUser._update(data); + + if (!oldUser.equals(newUser)) { + /** + * Emitted whenever a user's details (e.g. username) are changed. + * Triggered by the Discord gateway events USER_UPDATE, GUILD_MEMBER_UPDATE, and PRESENCE_UPDATE. + * @event Client#userUpdate + * @param {User} oldUser The user before the update + * @param {User} newUser The user after the update + */ + client.emit(Events.UserUpdate, oldUser, newUser); + return { + old: oldUser, + updated: newUser, + }; + } + + return { + old: null, + updated: null, + }; + } +} + +module.exports = UserUpdateAction; diff --git a/src/client/actions/VoiceStateUpdate.js b/src/client/actions/VoiceStateUpdate.js new file mode 100644 index 00000000..fc7400f --- /dev/null +++ b/src/client/actions/VoiceStateUpdate.js @@ -0,0 +1,43 @@ +'use strict'; + +const Action = require('./Action'); +const VoiceState = require('../../structures/VoiceState'); +const Events = require('../../util/Events'); + +class VoiceStateUpdate extends Action { + handle(data) { + const client = this.client; + const guild = client.guilds.cache.get(data.guild_id); + if (guild) { + // Update the state + const oldState = + guild.voiceStates.cache.get(data.user_id)?._clone() ?? new VoiceState(guild, { user_id: data.user_id }); + + const newState = guild.voiceStates._add(data); + + // Get the member + let member = guild.members.cache.get(data.user_id); + if (member && data.member) { + member._patch(data.member); + } else if (data.member?.user && data.member.joined_at) { + member = guild.members._add(data.member); + } + + // Emit event + if (member?.user.id === client.user.id) { + client.emit('debug', `[VOICE] received voice state update: ${JSON.stringify(data)}`); + client.voice.onVoiceStateUpdate(data); + } + + /** + * Emitted whenever a member changes voice state - e.g. joins/leaves a channel, mutes/unmutes. + * @event Client#voiceStateUpdate + * @param {VoiceState} oldState The voice state before the update + * @param {VoiceState} newState The voice state after the update + */ + client.emit(Events.VoiceStateUpdate, oldState, newState); + } + } +} + +module.exports = VoiceStateUpdate; diff --git a/src/client/actions/WebhooksUpdate.js b/src/client/actions/WebhooksUpdate.js new file mode 100644 index 00000000..b362b3b --- /dev/null +++ b/src/client/actions/WebhooksUpdate.js @@ -0,0 +1,19 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class WebhooksUpdate extends Action { + handle(data) { + const client = this.client; + const channel = client.channels.cache.get(data.channel_id); + /** + * Emitted whenever a channel has its webhooks changed. + * @event Client#webhookUpdate + * @param {TextChannel|NewsChannel} channel The channel that had a webhook update + */ + if (channel) client.emit(Events.WebhooksUpdate, channel); + } +} + +module.exports = WebhooksUpdate; diff --git a/src/client/voice/ClientVoiceManager.js b/src/client/voice/ClientVoiceManager.js new file mode 100644 index 00000000..192e700 --- /dev/null +++ b/src/client/voice/ClientVoiceManager.js @@ -0,0 +1,44 @@ +'use strict'; + +const Events = require('../../util/Events'); + +/** + * Manages voice connections for the client + */ +class ClientVoiceManager { + constructor(client) { + /** + * The client that instantiated this voice manager + * @type {Client} + * @readonly + * @name ClientVoiceManager#client + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * Maps guild ids to voice adapters created for use with @discordjs/voice. + * @type {Map} + */ + this.adapters = new Map(); + + client.on(Events.ShardDisconnect, (_, shardId) => { + for (const [guildId, adapter] of this.adapters.entries()) { + if (client.guilds.cache.get(guildId)?.shardId === shardId) { + adapter.destroy(); + } + } + }); + } + + onVoiceServer(payload) { + this.adapters.get(payload.guild_id)?.onVoiceServerUpdate(payload); + } + + onVoiceStateUpdate(payload) { + if (payload.guild_id && payload.session_id && payload.user_id === this.client.user?.id) { + this.adapters.get(payload.guild_id)?.onVoiceStateUpdate(payload); + } + } +} + +module.exports = ClientVoiceManager; diff --git a/src/client/websocket/WebSocketManager.js b/src/client/websocket/WebSocketManager.js new file mode 100644 index 00000000..a08b88a --- /dev/null +++ b/src/client/websocket/WebSocketManager.js @@ -0,0 +1,394 @@ +'use strict'; + +const EventEmitter = require('node:events'); +const { setImmediate } = require('node:timers'); +const { setTimeout: sleep } = require('node:timers/promises'); +const { Collection } = require('@discordjs/collection'); +const { GatewayCloseCodes, GatewayDispatchEvents, Routes } = require('discord-api-types/v9'); +const WebSocketShard = require('./WebSocketShard'); +const PacketHandlers = require('./handlers'); +const { Error } = require('../../errors'); +const Events = require('../../util/Events'); +const ShardEvents = require('../../util/ShardEvents'); +const Status = require('../../util/Status'); + +const BeforeReadyWhitelist = [ + GatewayDispatchEvents.Ready, + GatewayDispatchEvents.Resumed, + GatewayDispatchEvents.GuildCreate, + GatewayDispatchEvents.GuildDelete, + GatewayDispatchEvents.GuildMembersChunk, + GatewayDispatchEvents.GuildMemberAdd, + GatewayDispatchEvents.GuildMemberRemove, +]; + +const UNRECOVERABLE_CLOSE_CODES = [ + GatewayCloseCodes.AuthenticationFailed, + GatewayCloseCodes.InvalidShard, + GatewayCloseCodes.ShardingRequired, + GatewayCloseCodes.InvalidIntents, + GatewayCloseCodes.DisallowedIntents, +]; +const UNRESUMABLE_CLOSE_CODES = [1000, GatewayCloseCodes.AlreadyAuthenticated, GatewayCloseCodes.InvalidSeq]; + +/** + * The WebSocket manager for this client. + * This class forwards raw dispatch events, + * read more about it here {@link https://discord.com/developers/docs/topics/gateway} + * @extends EventEmitter + */ +class WebSocketManager extends EventEmitter { + constructor(client) { + super(); + + /** + * The client that instantiated this WebSocketManager + * @type {Client} + * @readonly + * @name WebSocketManager#client + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * The gateway this manager uses + * @type {?string} + */ + this.gateway = null; + + /** + * The amount of shards this manager handles + * @private + * @type {number} + */ + this.totalShards = this.client.options.shards.length; + + /** + * A collection of all shards this manager handles + * @type {Collection} + */ + this.shards = new Collection(); + + /** + * An array of shards to be connected or that need to reconnect + * @type {Set} + * @private + * @name WebSocketManager#shardQueue + */ + Object.defineProperty(this, 'shardQueue', { value: new Set(), writable: true }); + + /** + * An array of queued events before this WebSocketManager became ready + * @type {Object[]} + * @private + * @name WebSocketManager#packetQueue + */ + Object.defineProperty(this, 'packetQueue', { value: [] }); + + /** + * The current status of this WebSocketManager + * @type {Status} + */ + this.status = Status.Idle; + + /** + * If this manager was destroyed. It will prevent shards from reconnecting + * @type {boolean} + * @private + */ + this.destroyed = false; + + /** + * If this manager is currently reconnecting one or multiple shards + * @type {boolean} + * @private + */ + this.reconnecting = false; + } + + /** + * The average ping of all WebSocketShards + * @type {number} + * @readonly + */ + get ping() { + const sum = this.shards.reduce((a, b) => a + b.ping, 0); + return sum / this.shards.size; + } + + /** + * Emits a debug message. + * @param {string} message The debug message + * @param {?WebSocketShard} [shard] The shard that emitted this message, if any + * @private + */ + debug(message, shard) { + this.client.emit(Events.Debug, `[WS => ${shard ? `Shard ${shard.id}` : 'Manager'}] ${message}`); + } + + /** + * Connects this manager to the gateway. + * @private + */ + async connect() { + const invalidToken = new Error(GatewayCloseCodes[GatewayCloseCodes.AuthenticationFailed]); + const { + url: gatewayURL, + shards: recommendedShards, + session_start_limit: sessionStartLimit, + } = await this.client.api.gateway.bot.get().catch(error => { + throw error.httpStatus === 401 ? invalidToken : error; + }); + + const { total, remaining } = sessionStartLimit; + + this.debug(`Fetched Gateway Information + URL: ${gatewayURL} + Recommended Shards: ${recommendedShards}`); + + this.debug(`Session Limit Information + Total: ${total} + Remaining: ${remaining}`); + + this.gateway = `${gatewayURL}/`; + + let { shards } = this.client.options; + + if (shards === 'auto') { + this.debug(`Using the recommended shard count provided by Discord: ${recommendedShards}`); + this.totalShards = this.client.options.shardCount = recommendedShards; + shards = this.client.options.shards = Array.from({ length: recommendedShards }, (_, i) => i); + } + + this.totalShards = shards.length; + this.debug(`Spawning shards: ${shards.join(', ')}`); + this.shardQueue = new Set(shards.map(id => new WebSocketShard(this, id))); + + return this.createShards(); + } + + /** + * Handles the creation of a shard. + * @returns {Promise} + * @private + */ + async createShards() { + // If we don't have any shards to handle, return + if (!this.shardQueue.size) return false; + + const [shard] = this.shardQueue; + + this.shardQueue.delete(shard); + + if (!shard.eventsAttached) { + shard.on(ShardEvents.AllReady, unavailableGuilds => { + /** + * Emitted when a shard turns ready. + * @event Client#shardReady + * @param {number} id The shard id that turned ready + * @param {?Set} unavailableGuilds Set of unavailable guild ids, if any + */ + this.client.emit(Events.ShardReady, shard.id, unavailableGuilds); + + if (!this.shardQueue.size) this.reconnecting = false; + this.checkShardsReady(); + }); + + shard.on(ShardEvents.Close, event => { + if (event.code === 1_000 ? this.destroyed : UNRECOVERABLE_CLOSE_CODES.includes(event.code)) { + /** + * Emitted when a shard's WebSocket disconnects and will no longer reconnect. + * @event Client#shardDisconnect + * @param {CloseEvent} event The WebSocket close event + * @param {number} id The shard id that disconnected + */ + this.client.emit(Events.ShardDisconnect, event, shard.id); + this.debug(GatewayCloseCodes[event.code], shard); + return; + } + + if (UNRESUMABLE_CLOSE_CODES.includes(event.code)) { + // These event codes cannot be resumed + shard.sessionId = null; + } + + /** + * Emitted when a shard is attempting to reconnect or re-identify. + * @event Client#shardReconnecting + * @param {number} id The shard id that is attempting to reconnect + */ + this.client.emit(Events.ShardReconnecting, shard.id); + + this.shardQueue.add(shard); + + if (shard.sessionId) { + this.debug(`Session id is present, attempting an immediate reconnect...`, shard); + this.reconnect(); + } else { + shard.destroy({ reset: true, emit: false, log: false }); + this.reconnect(); + } + }); + + shard.on(ShardEvents.InvalidSession, () => { + this.client.emit(Events.ShardReconnecting, shard.id); + }); + + shard.on(ShardEvents.Destroyed, () => { + this.debug('Shard was destroyed but no WebSocket connection was present! Reconnecting...', shard); + + this.client.emit(Events.ShardReconnecting, shard.id); + + this.shardQueue.add(shard); + this.reconnect(); + }); + + shard.eventsAttached = true; + } + + this.shards.set(shard.id, shard); + + try { + await shard.connect(); + } catch (error) { + if (error?.code && UNRECOVERABLE_CLOSE_CODES.includes(error.code)) { + throw new Error(GatewayCloseCodes[error.code]); + // Undefined if session is invalid, error event for regular closes + } else if (!error || error.code) { + this.debug('Failed to connect to the gateway, requeueing...', shard); + this.shardQueue.add(shard); + } else { + throw error; + } + } + // If we have more shards, add a 5s delay + if (this.shardQueue.size) { + this.debug(`Shard Queue Size: ${this.shardQueue.size}; continuing in 5 seconds...`); + await sleep(5_000); + return this.createShards(); + } + + return true; + } + + /** + * Handles reconnects for this manager. + * @private + * @returns {Promise} + */ + async reconnect() { + if (this.reconnecting || this.status !== Status.Ready) return false; + this.reconnecting = true; + try { + await this.createShards(); + } catch (error) { + this.debug(`Couldn't reconnect or fetch information about the gateway. ${error}`); + if (error.httpStatus !== 401) { + this.debug(`Possible network error occurred. Retrying in 5s...`); + await sleep(5_000); + this.reconnecting = false; + return this.reconnect(); + } + // If we get an error at this point, it means we cannot reconnect anymore + if (this.client.listenerCount(Events.Invalidated)) { + /** + * Emitted when the client's session becomes invalidated. + * You are expected to handle closing the process gracefully and preventing a boot loop + * if you are listening to this event. + * @event Client#invalidated + */ + this.client.emit(Events.Invalidated); + // Destroy just the shards. This means you have to handle the cleanup yourself + this.destroy(); + } else { + this.client.destroy(); + } + } finally { + this.reconnecting = false; + } + return true; + } + + /** + * Broadcasts a packet to every shard this manager handles. + * @param {Object} packet The packet to send + * @private + */ + broadcast(packet) { + for (const shard of this.shards.values()) shard.send(packet); + } + + /** + * Destroys this manager and all its shards. + * @private + */ + destroy() { + if (this.destroyed) return; + this.debug(`Manager was destroyed. Called by:\n${new Error('MANAGER_DESTROYED').stack}`); + this.destroyed = true; + this.shardQueue.clear(); + for (const shard of this.shards.values()) shard.destroy({ closeCode: 1_000, reset: true, emit: false, log: false }); + } + + /** + * Processes a packet and queues it if this WebSocketManager is not ready. + * @param {Object} [packet] The packet to be handled + * @param {WebSocketShard} [shard] The shard that will handle this packet + * @returns {boolean} + * @private + */ + handlePacket(packet, shard) { + if (packet && this.status !== Status.Ready) { + if (!BeforeReadyWhitelist.includes(packet.t)) { + this.packetQueue.push({ packet, shard }); + return false; + } + } + + if (this.packetQueue.length) { + const item = this.packetQueue.shift(); + setImmediate(() => { + this.handlePacket(item.packet, item.shard); + }).unref(); + } + + if (packet && PacketHandlers[packet.t]) { + PacketHandlers[packet.t](this.client, packet, shard); + } + + return true; + } + + /** + * Checks whether the client is ready to be marked as ready. + * @private + */ + checkShardsReady() { + if (this.status === Status.Ready) return; + if (this.shards.size !== this.totalShards || this.shards.some(s => s.status !== Status.Ready)) { + return; + } + + this.triggerClientReady(); + } + + /** + * Causes the client to be marked as ready and emits the ready event. + * @private + */ + triggerClientReady() { + this.status = Status.Ready; + + this.client.readyTimestamp = Date.now(); + + /** + * Emitted when the client becomes ready to start working. + * @event Client#ready + * @param {Client} client The client + */ + this.client.emit(Events.ClientReady, this.client); + + this.handlePacket(); + } +} + +module.exports = WebSocketManager; diff --git a/src/client/websocket/WebSocketShard.js b/src/client/websocket/WebSocketShard.js new file mode 100644 index 00000000..34f34f7 --- /dev/null +++ b/src/client/websocket/WebSocketShard.js @@ -0,0 +1,787 @@ +'use strict'; + +const EventEmitter = require('node:events'); +const { setTimeout, setInterval, clearTimeout, clearInterval } = require('node:timers'); +const { GatewayDispatchEvents, GatewayIntentBits, GatewayOpcodes } = require('discord-api-types/v9'); +const WebSocket = require('../../WebSocket'); +const Events = require('../../util/Events'); +const IntentsBitField = require('../../util/IntentsBitField'); +const ShardEvents = require('../../util/ShardEvents'); +const Status = require('../../util/Status'); + +const STATUS_KEYS = Object.keys(Status); +const CONNECTION_STATE = Object.keys(WebSocket.WebSocket); + +let zlib; + +try { + zlib = require('zlib-sync'); +} catch {} // eslint-disable-line no-empty + +/** + * Represents a Shard's WebSocket connection + */ +class WebSocketShard extends EventEmitter { + constructor(manager, id) { + super(); + + /** + * The WebSocketManager of the shard + * @type {WebSocketManager} + */ + this.manager = manager; + + /** + * The shard's id + * @type {number} + */ + this.id = id; + + /** + * The current status of the shard + * @type {Status} + */ + this.status = Status.Idle; + + /** + * The current sequence of the shard + * @type {number} + * @private + */ + this.sequence = -1; + + /** + * The sequence of the shard after close + * @type {number} + * @private + */ + this.closeSequence = 0; + + /** + * The current session id of the shard + * @type {?string} + * @private + */ + this.sessionId = null; + + /** + * The previous heartbeat ping of the shard + * @type {number} + */ + this.ping = -1; + + /** + * The last time a ping was sent (a timestamp) + * @type {number} + * @private + */ + this.lastPingTimestamp = -1; + + /** + * If we received a heartbeat ack back. Used to identify zombie connections + * @type {boolean} + * @private + */ + this.lastHeartbeatAcked = true; + + /** + * Contains the rate limit queue and metadata + * @name WebSocketShard#ratelimit + * @type {Object} + * @private + */ + Object.defineProperty(this, 'ratelimit', { + value: { + queue: [], + total: 120, + remaining: 120, + time: 60e3, + timer: null, + }, + }); + + /** + * The WebSocket connection for the current shard + * @name WebSocketShard#connection + * @type {?WebSocket} + * @private + */ + Object.defineProperty(this, 'connection', { value: null, writable: true }); + + /** + * @external Inflate + * @see {@link https://www.npmjs.com/package/zlib-sync} + */ + + /** + * The compression to use + * @name WebSocketShard#inflate + * @type {?Inflate} + * @private + */ + Object.defineProperty(this, 'inflate', { value: null, writable: true }); + + /** + * The HELLO timeout + * @name WebSocketShard#helloTimeout + * @type {?NodeJS.Timeout} + * @private + */ + Object.defineProperty(this, 'helloTimeout', { value: null, writable: true }); + + /** + * If the manager attached its event handlers on the shard + * @name WebSocketShard#eventsAttached + * @type {boolean} + * @private + */ + Object.defineProperty(this, 'eventsAttached', { value: false, writable: true }); + + /** + * A set of guild ids this shard expects to receive + * @name WebSocketShard#expectedGuilds + * @type {?Set} + * @private + */ + Object.defineProperty(this, 'expectedGuilds', { value: null, writable: true }); + + /** + * The ready timeout + * @name WebSocketShard#readyTimeout + * @type {?NodeJS.Timeout} + * @private + */ + Object.defineProperty(this, 'readyTimeout', { value: null, writable: true }); + + /** + * Time when the WebSocket connection was opened + * @name WebSocketShard#connectedAt + * @type {number} + * @private + */ + Object.defineProperty(this, 'connectedAt', { value: 0, writable: true }); + } + + /** + * Emits a debug event. + * @param {string} message The debug message + * @private + */ + debug(message) { + this.manager.debug(message, this); + } + + /** + * Connects the shard to the gateway. + * @private + * @returns {Promise} A promise that will resolve if the shard turns ready successfully, + * or reject if we couldn't connect + */ + connect() { + const { gateway, client } = this.manager; + + if (this.connection?.readyState === WebSocket.OPEN && this.status === Status.Ready) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const cleanup = () => { + this.removeListener(ShardEvents.Close, onClose); + this.removeListener(ShardEvents.Ready, onReady); + this.removeListener(ShardEvents.Resumed, onResumed); + this.removeListener(ShardEvents.InvalidSession, onInvalidOrDestroyed); + this.removeListener(ShardEvents.Destroyed, onInvalidOrDestroyed); + }; + + const onReady = () => { + cleanup(); + resolve(); + }; + + const onResumed = () => { + cleanup(); + resolve(); + }; + + const onClose = event => { + cleanup(); + reject(event); + }; + + const onInvalidOrDestroyed = () => { + cleanup(); + // eslint-disable-next-line prefer-promise-reject-errors + reject(); + }; + + this.once(ShardEvents.Ready, onReady); + this.once(ShardEvents.Resumed, onResumed); + this.once(ShardEvents.Close, onClose); + this.once(ShardEvents.InvalidSession, onInvalidOrDestroyed); + this.once(ShardEvents.Destroyed, onInvalidOrDestroyed); + + if (this.connection?.readyState === WebSocket.OPEN) { + this.debug('An open connection was found, attempting an immediate identify.'); + this.identify(); + return; + } + + if (this.connection) { + this.debug(`A connection object was found. Cleaning up before continuing. + State: ${CONNECTION_STATE[this.connection.readyState]}`); + this.destroy({ emit: false }); + } + + const wsQuery = { v: client.options.ws.version }; + + if (zlib) { + this.inflate = new zlib.Inflate({ + chunkSize: 65535, + flush: zlib.Z_SYNC_FLUSH, + to: WebSocket.encoding === 'json' ? 'string' : '', + }); + wsQuery.compress = 'zlib-stream'; + } + + this.debug( + `[CONNECT] + Gateway : ${gateway} + Version : ${client.options.ws.version} + Encoding : ${WebSocket.encoding} + Compression: ${zlib ? 'zlib-stream' : 'none'}`, + ); + + this.status = this.status === Status.Disconnected ? Status.Reconnecting : Status.Connecting; + this.setHelloTimeout(); + + this.connectedAt = Date.now(); + + const ws = (this.connection = WebSocket.create(gateway, wsQuery)); + ws.onopen = this.onOpen.bind(this); + ws.onmessage = this.onMessage.bind(this); + ws.onerror = this.onError.bind(this); + ws.onclose = this.onClose.bind(this); + }); + } + + /** + * Called whenever a connection is opened to the gateway. + * @private + */ + onOpen() { + this.debug(`[CONNECTED] Took ${Date.now() - this.connectedAt}ms`); + this.status = Status.Nearly; + } + + /** + * Called whenever a message is received. + * @param {MessageEvent} event Event received + * @private + */ + onMessage({ data }) { + let raw; + if (data instanceof ArrayBuffer) data = new Uint8Array(data); + if (zlib) { + const l = data.length; + const flush = + l >= 4 && data[l - 4] === 0x00 && data[l - 3] === 0x00 && data[l - 2] === 0xff && data[l - 1] === 0xff; + + this.inflate.push(data, flush && zlib.Z_SYNC_FLUSH); + if (!flush) return; + raw = this.inflate.result; + } else { + raw = data; + } + let packet; + try { + packet = WebSocket.unpack(raw); + } catch (err) { + this.manager.client.emit(Events.ShardError, err, this.id); + return; + } + this.manager.client.emit(Events.Raw, packet, this.id); + if (packet.op === GatewayOpcodes.Dispatch) this.manager.emit(packet.t, packet.d, this.id); + this.onPacket(packet); + } + + /** + * Called whenever an error occurs with the WebSocket. + * @param {ErrorEvent} event The error that occurred + * @private + */ + onError(event) { + const error = event?.error ?? event; + if (!error) return; + + /** + * Emitted whenever a shard's WebSocket encounters a connection error. + * @event Client#shardError + * @param {Error} error The encountered error + * @param {number} shardId The shard that encountered this error + */ + this.manager.client.emit(Events.ShardError, error, this.id); + } + + /** + * @external CloseEvent + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent} + */ + + /** + * @external ErrorEvent + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent} + */ + + /** + * @external MessageEvent + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent} + */ + + /** + * Called whenever a connection to the gateway is closed. + * @param {CloseEvent} event Close event that was received + * @private + */ + onClose(event) { + if (this.sequence !== -1) this.closeSequence = this.sequence; + this.sequence = -1; + + this.debug(`[CLOSE] + Event Code: ${event.code} + Clean : ${event.wasClean} + Reason : ${event.reason ?? 'No reason received'}`); + + this.setHeartbeatTimer(-1); + this.setHelloTimeout(-1); + // If we still have a connection object, clean up its listeners + if (this.connection) this._cleanupConnection(); + + this.status = Status.Disconnected; + + /** + * Emitted when a shard's WebSocket closes. + * @private + * @event WebSocketShard#close + * @param {CloseEvent} event The received event + */ + this.emit(ShardEvents.Close, event); + } + + /** + * Called whenever a packet is received. + * @param {Object} packet The received packet + * @private + */ + onPacket(packet) { + if (!packet) { + this.debug(`Received broken packet: '${packet}'.`); + return; + } + + switch (packet.t) { + case GatewayDispatchEvents.Ready: + /** + * Emitted when the shard receives the READY payload and is now waiting for guilds + * @event WebSocketShard#ready + */ + this.emit(ShardEvents.Ready); + + this.sessionId = packet.d.session_id; + this.expectedGuilds = new Set(packet.d.guilds.map(d => d.id)); + this.status = Status.WaitingForGuilds; + this.debug(`[READY] Session ${this.sessionId}.`); + this.lastHeartbeatAcked = true; + this.sendHeartbeat('ReadyHeartbeat'); + break; + case GatewayDispatchEvents.Resumed: { + /** + * Emitted when the shard resumes successfully + * @event WebSocketShard#resumed + */ + this.emit(ShardEvents.Resumed); + + this.status = Status.Ready; + const replayed = packet.s - this.closeSequence; + this.debug(`[RESUMED] Session ${this.sessionId} | Replayed ${replayed} events.`); + this.lastHeartbeatAcked = true; + this.sendHeartbeat('ResumeHeartbeat'); + break; + } + } + + if (packet.s > this.sequence) this.sequence = packet.s; + + switch (packet.op) { + case GatewayOpcodes.Hello: + this.setHelloTimeout(-1); + this.setHeartbeatTimer(packet.d.heartbeat_interval); + this.identify(); + break; + case GatewayOpcodes.Reconnect: + this.debug('[RECONNECT] Discord asked us to reconnect'); + this.destroy({ closeCode: 4_000 }); + break; + case GatewayOpcodes.InvalidSession: + this.debug(`[INVALID SESSION] Resumable: ${packet.d}.`); + // If we can resume the session, do so immediately + if (packet.d) { + this.identifyResume(); + return; + } + // Reset the sequence + this.sequence = -1; + // Reset the session id as it's invalid + this.sessionId = null; + // Set the status to reconnecting + this.status = Status.Reconnecting; + // Finally, emit the INVALID_SESSION event + this.emit(ShardEvents.InvalidSession); + break; + case GatewayOpcodes.HeartbeatAck: + this.ackHeartbeat(); + break; + case GatewayOpcodes.Heartbeat: + this.sendHeartbeat('HeartbeatRequest', true); + break; + default: + this.manager.handlePacket(packet, this); + if (this.status === Status.WaitingForGuilds && packet.t === GatewayDispatchEvents.GuildCreate) { + this.expectedGuilds.delete(packet.d.id); + this.checkReady(); + } + } + } + + /** + * Checks if the shard can be marked as ready + * @private + */ + checkReady() { + // Step 0. Clear the ready timeout, if it exists + if (this.readyTimeout) { + clearTimeout(this.readyTimeout); + this.readyTimeout = null; + } + // Step 1. If we don't have any other guilds pending, we are ready + if (!this.expectedGuilds.size) { + this.debug('Shard received all its guilds. Marking as fully ready.'); + this.status = Status.Ready; + + /** + * Emitted when the shard is fully ready. + * This event is emitted if: + * * all guilds were received by this shard + * * the ready timeout expired, and some guilds are unavailable + * @event WebSocketShard#allReady + * @param {?Set} unavailableGuilds Set of unavailable guilds, if any + */ + this.emit(ShardEvents.AllReady); + return; + } + const hasGuildsIntent = new IntentsBitField(this.manager.client.options.intents).has(GatewayIntentBits.Guilds); + // Step 2. Create a timeout that will mark the shard as ready if there are still unavailable guilds + // * The timeout is 15 seconds by default + // * This can be optionally changed in the client options via the `waitGuildTimeout` option + // * a timeout time of zero will skip this timeout, which potentially could cause the Client to miss guilds. + + const { waitGuildTimeout } = this.manager.client.options; + + this.readyTimeout = setTimeout( + () => { + this.debug( + `Shard ${hasGuildsIntent ? 'did' : 'will'} not receive any more guild packets` + + `${hasGuildsIntent ? ` in ${waitGuildTimeout} ms` : ''}.\nUnavailable guild count: ${ + this.expectedGuilds.size + }`, + ); + + this.readyTimeout = null; + + this.status = Status.Ready; + + this.emit(ShardEvents.AllReady, this.expectedGuilds); + }, + 0, + ).unref(); + } + + /** + * Sets the HELLO packet timeout. + * @param {number} [time] If set to -1, it will clear the hello timeout + * @private + */ + setHelloTimeout(time) { + if (time === -1) { + if (this.helloTimeout) { + this.debug('Clearing the HELLO timeout.'); + clearTimeout(this.helloTimeout); + this.helloTimeout = null; + } + return; + } + this.debug('Setting a HELLO timeout for 20s.'); + this.helloTimeout = setTimeout(() => { + this.debug('Did not receive HELLO in time. Destroying and connecting again.'); + this.destroy({ reset: true, closeCode: 4009 }); + }, 20_000).unref(); + } + + /** + * Sets the heartbeat timer for this shard. + * @param {number} time If -1, clears the interval, any other number sets an interval + * @private + */ + setHeartbeatTimer(time) { + if (time === -1) { + if (this.heartbeatInterval) { + this.debug('Clearing the heartbeat interval.'); + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + return; + } + this.debug(`Setting a heartbeat interval for ${time}ms.`); + // Sanity checks + if (this.heartbeatInterval) clearInterval(this.heartbeatInterval); + this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), time).unref(); + } + + /** + * Sends a heartbeat to the WebSocket. + * If this shard didn't receive a heartbeat last time, it will destroy it and reconnect + * @param {string} [tag='HeartbeatTimer'] What caused this heartbeat to be sent + * @param {boolean} [ignoreHeartbeatAck] If we should send the heartbeat forcefully. + * @private + */ + sendHeartbeat( + tag = 'HeartbeatTimer', + ignoreHeartbeatAck = [Status.WaitingForGuilds, Status.Identifying, Status.Resuming].includes(this.status), + ) { + if (ignoreHeartbeatAck && !this.lastHeartbeatAcked) { + this.debug(`[${tag}] Didn't process heartbeat ack yet but we are still connected. Sending one now.`); + } else if (!this.lastHeartbeatAcked) { + this.debug( + `[${tag}] Didn't receive a heartbeat ack last time, assuming zombie connection. Destroying and reconnecting. + Status : ${STATUS_KEYS[this.status]} + Sequence : ${this.sequence} + Connection State: ${this.connection ? CONNECTION_STATE[this.connection.readyState] : 'No Connection??'}`, + ); + + this.destroy({ closeCode: 4009, reset: true }); + return; + } + + this.debug(`[${tag}] Sending a heartbeat.`); + this.lastHeartbeatAcked = false; + this.lastPingTimestamp = Date.now(); + this.send({ op: GatewayOpcodes.Heartbeat, d: this.sequence }, true); + } + + /** + * Acknowledges a heartbeat. + * @private + */ + ackHeartbeat() { + this.lastHeartbeatAcked = true; + const latency = Date.now() - this.lastPingTimestamp; + this.debug(`Heartbeat acknowledged, latency of ${latency}ms.`); + this.ping = latency; + } + + /** + * Identifies the client on the connection. + * @private + * @returns {void} + */ + identify() { + return this.sessionId ? this.identifyResume() : this.identifyNew(); + } + + /** + * Identifies as a new connection on the gateway. + * @private + */ + identifyNew() { + const { client } = this.manager; + if (!client.token) { + this.debug('[IDENTIFY] No token available to identify a new session.'); + return; + } + + this.status = Status.Identifying; + + // Clone the identify payload and assign the token and shard info + const d = { + ...client.options.ws, + token: client.token, + }; + + this.debug(`[IDENTIFY] Shard ${this.id}/${client.options.shardCount} with intents: 32767`); + this.send({ op: GatewayOpcodes.Identify, d }, true); + } + + /** + * Resumes a session on the gateway. + * @private + */ + identifyResume() { + if (!this.sessionId) { + this.debug('[RESUME] No session id was present; identifying as a new session.'); + this.identifyNew(); + return; + } + + this.status = Status.Resuming; + + this.debug(`[RESUME] Session ${this.sessionId}, sequence ${this.closeSequence}`); + + const d = { + token: this.manager.client.token, + session_id: this.sessionId, + seq: this.closeSequence, + }; + + this.send({ op: GatewayOpcodes.Resume, d }, true); + } + + /** + * Adds a packet to the queue to be sent to the gateway. + * If you use this method, make sure you understand that you need to provide + * a full [Payload](https://discord.com/developers/docs/topics/gateway#commands-and-events-gateway-commands). + * Do not use this method if you don't know what you're doing. + * @param {Object} data The full packet to send + * @param {boolean} [important=false] If this packet should be added first in queue + */ + send(data, important = false) { + this.ratelimit.queue[important ? 'unshift' : 'push'](data); + this.processQueue(); + } + + /** + * Sends data, bypassing the queue. + * @param {Object} data Packet to send + * @returns {void} + * @private + */ + _send(data) { + if (this.connection?.readyState !== WebSocket.OPEN) { + this.debug(`Tried to send packet '${JSON.stringify(data)}' but no WebSocket is available!`); + this.destroy({ closeCode: 4_000 }); + return; + } + + this.connection.send(WebSocket.pack(data), err => { + if (err) this.manager.client.emit(Events.ShardError, err, this.id); + }); + } + + /** + * Processes the current WebSocket queue. + * @returns {void} + * @private + */ + processQueue() { + if (this.ratelimit.remaining === 0) return; + if (this.ratelimit.queue.length === 0) return; + if (this.ratelimit.remaining === this.ratelimit.total) { + this.ratelimit.timer = setTimeout(() => { + this.ratelimit.remaining = this.ratelimit.total; + this.processQueue(); + }, this.ratelimit.time).unref(); + } + while (this.ratelimit.remaining > 0) { + const item = this.ratelimit.queue.shift(); + if (!item) return; + this._send(item); + this.ratelimit.remaining--; + } + } + + /** + * Destroys this shard and closes its WebSocket connection. + * @param {Object} [options={ closeCode: 1000, reset: false, emit: true, log: true }] Options for destroying the shard + * @private + */ + destroy({ closeCode = 1_000, reset = false, emit = true, log = true } = {}) { + if (log) { + this.debug(`[DESTROY] + Close Code : ${closeCode} + Reset : ${reset} + Emit DESTROYED: ${emit}`); + } + + // Step 0: Remove all timers + this.setHeartbeatTimer(-1); + this.setHelloTimeout(-1); + + // Step 1: Close the WebSocket connection, if any, otherwise, emit DESTROYED + if (this.connection) { + // If the connection is currently opened, we will (hopefully) receive close + if (this.connection.readyState === WebSocket.OPEN) { + this.connection.close(closeCode); + } else { + // Connection is not OPEN + this.debug(`WS State: ${CONNECTION_STATE[this.connection.readyState]}`); + // Remove listeners from the connection + this._cleanupConnection(); + // Attempt to close the connection just in case + try { + this.connection.close(closeCode); + } catch { + // No-op + } + // Emit the destroyed event if needed + if (emit) this._emitDestroyed(); + } + } else if (emit) { + // We requested a destroy, but we had no connection. Emit destroyed + this._emitDestroyed(); + } + + // Step 2: Null the connection object + this.connection = null; + + // Step 3: Set the shard status to Disconnected + this.status = Status.Disconnected; + + // Step 4: Cache the old sequence (use to attempt a resume) + if (this.sequence !== -1) this.closeSequence = this.sequence; + + // Step 5: Reset the sequence and session id if requested + if (reset) { + this.sequence = -1; + this.sessionId = null; + } + + // Step 6: reset the rate limit data + this.ratelimit.remaining = this.ratelimit.total; + this.ratelimit.queue.length = 0; + if (this.ratelimit.timer) { + clearTimeout(this.ratelimit.timer); + this.ratelimit.timer = null; + } + } + + /** + * Cleans up the WebSocket connection listeners. + * @private + */ + _cleanupConnection() { + this.connection.onopen = this.connection.onclose = this.connection.onerror = this.connection.onmessage = null; + } + + /** + * Emits the DESTROYED event on the shard + * @private + */ + _emitDestroyed() { + /** + * Emitted when a shard is destroyed, but no WebSocket connection was present. + * @private + * @event WebSocketShard#destroyed + */ + this.emit(ShardEvents.Destroyed); + } +} + +module.exports = WebSocketShard; diff --git a/src/client/websocket/handlers/CHANNEL_CREATE.js b/src/client/websocket/handlers/CHANNEL_CREATE.js new file mode 100644 index 00000000..d6d560d --- /dev/null +++ b/src/client/websocket/handlers/CHANNEL_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ChannelCreate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/CHANNEL_DELETE.js b/src/client/websocket/handlers/CHANNEL_DELETE.js new file mode 100644 index 00000000..cb9f3d8 --- /dev/null +++ b/src/client/websocket/handlers/CHANNEL_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ChannelDelete.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/CHANNEL_PINS_UPDATE.js b/src/client/websocket/handlers/CHANNEL_PINS_UPDATE.js new file mode 100644 index 00000000..c46e527 --- /dev/null +++ b/src/client/websocket/handlers/CHANNEL_PINS_UPDATE.js @@ -0,0 +1,22 @@ +'use strict'; + +const Events = require('../../../util/Events'); + +module.exports = (client, { d: data }) => { + const channel = client.channels.cache.get(data.channel_id); + const time = data.last_pin_timestamp ? Date.parse(data.last_pin_timestamp) : null; + + if (channel) { + // Discord sends null for last_pin_timestamp if the last pinned message was removed + channel.lastPinTimestamp = time; + + /** + * Emitted whenever the pins of a channel are updated. Due to the nature of the WebSocket event, + * not much information can be provided easily here - you need to manually check the pins yourself. + * @event Client#channelPinsUpdate + * @param {TextBasedChannels} channel The channel that the pins update occurred in + * @param {Date} time The time of the pins update + */ + client.emit(Events.ChannelPinsUpdate, channel, time); + } +}; diff --git a/src/client/websocket/handlers/CHANNEL_UPDATE.js b/src/client/websocket/handlers/CHANNEL_UPDATE.js new file mode 100644 index 00000000..8f35121 --- /dev/null +++ b/src/client/websocket/handlers/CHANNEL_UPDATE.js @@ -0,0 +1,16 @@ +'use strict'; + +const Events = require('../../../util/Events'); + +module.exports = (client, packet) => { + const { old, updated } = client.actions.ChannelUpdate.handle(packet.d); + if (old && updated) { + /** + * Emitted whenever a channel is updated - e.g. name change, topic change, channel type change. + * @event Client#channelUpdate + * @param {DMChannel|GuildChannel} oldChannel The channel before the update + * @param {DMChannel|GuildChannel} newChannel The channel after the update + */ + client.emit(Events.ChannelUpdate, old, updated); + } +}; diff --git a/src/client/websocket/handlers/GUILD_BAN_ADD.js b/src/client/websocket/handlers/GUILD_BAN_ADD.js new file mode 100644 index 00000000..d8dc0f9 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_BAN_ADD.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildBanAdd.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_BAN_REMOVE.js b/src/client/websocket/handlers/GUILD_BAN_REMOVE.js new file mode 100644 index 00000000..8389e46 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_BAN_REMOVE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildBanRemove.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_CREATE.js b/src/client/websocket/handlers/GUILD_CREATE.js new file mode 100644 index 00000000..7202dc8 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_CREATE.js @@ -0,0 +1,26 @@ +'use strict'; + +const Events = require('../../../util/Events'); +const Status = require('../../../util/Status'); + +module.exports = (client, { d: data }, shard) => { + let guild = client.guilds.cache.get(data.id); + if (guild) { + if (!guild.available && !data.unavailable) { + // A newly available guild + guild._patch(data); + } + } else { + // A new guild + data.shardId = shard.id; + guild = client.guilds._add(data); + if (client.ws.status === Status.Ready) { + /** + * Emitted whenever the client joins a guild. + * @event Client#guildCreate + * @param {Guild} guild The created guild + */ + client.emit(Events.GuildCreate, guild); + } + } +}; diff --git a/src/client/websocket/handlers/GUILD_DELETE.js b/src/client/websocket/handlers/GUILD_DELETE.js new file mode 100644 index 00000000..27a3256 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildDelete.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_EMOJIS_UPDATE.js b/src/client/websocket/handlers/GUILD_EMOJIS_UPDATE.js new file mode 100644 index 00000000..e23b671 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_EMOJIS_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildEmojisUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_INTEGRATIONS_UPDATE.js b/src/client/websocket/handlers/GUILD_INTEGRATIONS_UPDATE.js new file mode 100644 index 00000000..e90a72c --- /dev/null +++ b/src/client/websocket/handlers/GUILD_INTEGRATIONS_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildIntegrationsUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_MEMBERS_CHUNK.js b/src/client/websocket/handlers/GUILD_MEMBERS_CHUNK.js new file mode 100644 index 00000000..6f7ca7e --- /dev/null +++ b/src/client/websocket/handlers/GUILD_MEMBERS_CHUNK.js @@ -0,0 +1,36 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Events = require('../../../util/Events'); + +module.exports = (client, { d: data }) => { + const guild = client.guilds.cache.get(data.guild_id); + if (!guild) return; + const members = new Collection(); + + for (const member of data.members) members.set(member.user.id, guild.members._add(member)); + if (data.presences) { + for (const presence of data.presences) guild.presences._add(Object.assign(presence, { guild })); + } + + /** + * Represents the properties of a guild members chunk + * @typedef {Object} GuildMembersChunk + * @property {number} index Index of the received chunk + * @property {number} count Number of chunks the client should receive + * @property {?string} nonce Nonce for this chunk + */ + + /** + * Emitted whenever a chunk of guild members is received (all members come from the same guild). + * @event Client#guildMembersChunk + * @param {Collection} members The members in the chunk + * @param {Guild} guild The guild related to the member chunk + * @param {GuildMembersChunk} chunk Properties of the received chunk + */ + client.emit(Events.GuildMembersChunk, members, guild, { + count: data.chunk_count, + index: data.chunk_index, + nonce: data.nonce, + }); +}; diff --git a/src/client/websocket/handlers/GUILD_MEMBER_ADD.js b/src/client/websocket/handlers/GUILD_MEMBER_ADD.js new file mode 100644 index 00000000..fece5d7 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_MEMBER_ADD.js @@ -0,0 +1,20 @@ +'use strict'; + +const Events = require('../../../util/Events'); +const Status = require('../../../util/Status'); + +module.exports = (client, { d: data }, shard) => { + const guild = client.guilds.cache.get(data.guild_id); + if (guild) { + guild.memberCount++; + const member = guild.members._add(data); + if (shard.status === Status.Ready) { + /** + * Emitted whenever a user joins a guild. + * @event Client#guildMemberAdd + * @param {GuildMember} member The member that has joined a guild + */ + client.emit(Events.GuildMemberAdd, member); + } + } +}; diff --git a/src/client/websocket/handlers/GUILD_MEMBER_REMOVE.js b/src/client/websocket/handlers/GUILD_MEMBER_REMOVE.js new file mode 100644 index 00000000..72432af --- /dev/null +++ b/src/client/websocket/handlers/GUILD_MEMBER_REMOVE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet, shard) => { + client.actions.GuildMemberRemove.handle(packet.d, shard); +}; diff --git a/src/client/websocket/handlers/GUILD_MEMBER_UPDATE.js b/src/client/websocket/handlers/GUILD_MEMBER_UPDATE.js new file mode 100644 index 00000000..cafc6bd --- /dev/null +++ b/src/client/websocket/handlers/GUILD_MEMBER_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet, shard) => { + client.actions.GuildMemberUpdate.handle(packet.d, shard); +}; diff --git a/src/client/websocket/handlers/GUILD_ROLE_CREATE.js b/src/client/websocket/handlers/GUILD_ROLE_CREATE.js new file mode 100644 index 00000000..da9e7bc --- /dev/null +++ b/src/client/websocket/handlers/GUILD_ROLE_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildRoleCreate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_ROLE_DELETE.js b/src/client/websocket/handlers/GUILD_ROLE_DELETE.js new file mode 100644 index 00000000..cdc6353 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_ROLE_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildRoleDelete.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_ROLE_UPDATE.js b/src/client/websocket/handlers/GUILD_ROLE_UPDATE.js new file mode 100644 index 00000000..3a9b62e --- /dev/null +++ b/src/client/websocket/handlers/GUILD_ROLE_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildRoleUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_CREATE.js b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_CREATE.js new file mode 100644 index 00000000..04ff2df --- /dev/null +++ b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildScheduledEventCreate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_DELETE.js b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_DELETE.js new file mode 100644 index 00000000..b660c09 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildScheduledEventDelete.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_UPDATE.js b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_UPDATE.js new file mode 100644 index 00000000..0064708 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildScheduledEventUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_USER_ADD.js b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_USER_ADD.js new file mode 100644 index 00000000..d5adca2 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_USER_ADD.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildScheduledEventUserAdd.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_USER_REMOVE.js b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_USER_REMOVE.js new file mode 100644 index 00000000..114df68 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_SCHEDULED_EVENT_USER_REMOVE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildScheduledEventUserRemove.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_STICKERS_UPDATE.js b/src/client/websocket/handlers/GUILD_STICKERS_UPDATE.js new file mode 100644 index 00000000..e3aba61 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_STICKERS_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildStickersUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/GUILD_UPDATE.js b/src/client/websocket/handlers/GUILD_UPDATE.js new file mode 100644 index 00000000..fd0012a --- /dev/null +++ b/src/client/websocket/handlers/GUILD_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/INTERACTION_CREATE.js b/src/client/websocket/handlers/INTERACTION_CREATE.js new file mode 100644 index 00000000..5bf30fc --- /dev/null +++ b/src/client/websocket/handlers/INTERACTION_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.InteractionCreate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/INVITE_CREATE.js b/src/client/websocket/handlers/INVITE_CREATE.js new file mode 100644 index 00000000..50a2e72 --- /dev/null +++ b/src/client/websocket/handlers/INVITE_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.InviteCreate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/INVITE_DELETE.js b/src/client/websocket/handlers/INVITE_DELETE.js new file mode 100644 index 00000000..5971852 --- /dev/null +++ b/src/client/websocket/handlers/INVITE_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.InviteDelete.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/MESSAGE_CREATE.js b/src/client/websocket/handlers/MESSAGE_CREATE.js new file mode 100644 index 00000000..c9b79a8 --- /dev/null +++ b/src/client/websocket/handlers/MESSAGE_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.MessageCreate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/MESSAGE_DELETE.js b/src/client/websocket/handlers/MESSAGE_DELETE.js new file mode 100644 index 00000000..85ae2bc --- /dev/null +++ b/src/client/websocket/handlers/MESSAGE_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.MessageDelete.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/MESSAGE_DELETE_BULK.js b/src/client/websocket/handlers/MESSAGE_DELETE_BULK.js new file mode 100644 index 00000000..fbcf80f --- /dev/null +++ b/src/client/websocket/handlers/MESSAGE_DELETE_BULK.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.MessageDeleteBulk.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/MESSAGE_REACTION_ADD.js b/src/client/websocket/handlers/MESSAGE_REACTION_ADD.js new file mode 100644 index 00000000..e219b4a --- /dev/null +++ b/src/client/websocket/handlers/MESSAGE_REACTION_ADD.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.MessageReactionAdd.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/MESSAGE_REACTION_REMOVE.js b/src/client/websocket/handlers/MESSAGE_REACTION_REMOVE.js new file mode 100644 index 00000000..2980e69 --- /dev/null +++ b/src/client/websocket/handlers/MESSAGE_REACTION_REMOVE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.MessageReactionRemove.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/MESSAGE_REACTION_REMOVE_ALL.js b/src/client/websocket/handlers/MESSAGE_REACTION_REMOVE_ALL.js new file mode 100644 index 00000000..ead80f7 --- /dev/null +++ b/src/client/websocket/handlers/MESSAGE_REACTION_REMOVE_ALL.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.MessageReactionRemoveAll.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/MESSAGE_REACTION_REMOVE_EMOJI.js b/src/client/websocket/handlers/MESSAGE_REACTION_REMOVE_EMOJI.js new file mode 100644 index 00000000..579444c --- /dev/null +++ b/src/client/websocket/handlers/MESSAGE_REACTION_REMOVE_EMOJI.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.MessageReactionRemoveEmoji.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/MESSAGE_UPDATE.js b/src/client/websocket/handlers/MESSAGE_UPDATE.js new file mode 100644 index 00000000..c2a470b --- /dev/null +++ b/src/client/websocket/handlers/MESSAGE_UPDATE.js @@ -0,0 +1,16 @@ +'use strict'; + +const Events = require('../../../util/Events'); + +module.exports = (client, packet) => { + const { old, updated } = client.actions.MessageUpdate.handle(packet.d); + if (old && updated) { + /** + * Emitted whenever a message is updated - e.g. embed or content change. + * @event Client#messageUpdate + * @param {Message} oldMessage The message before the update + * @param {Message} newMessage The message after the update + */ + client.emit(Events.MessageUpdate, old, updated); + } +}; diff --git a/src/client/websocket/handlers/PRESENCE_UPDATE.js b/src/client/websocket/handlers/PRESENCE_UPDATE.js new file mode 100644 index 00000000..bde3629 --- /dev/null +++ b/src/client/websocket/handlers/PRESENCE_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.PresenceUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/READY.js b/src/client/websocket/handlers/READY.js new file mode 100644 index 00000000..97b99ef --- /dev/null +++ b/src/client/websocket/handlers/READY.js @@ -0,0 +1,41 @@ +'use strict'; + +const ClientApplication = require('../../../structures/ClientApplication'); +const User = require('../../../structures/User'); +let ClientUser; + +module.exports = (client, { d: data }, shard) => { + //console.log(data); + + client.session_id = data.session_id; + if (client.user) { + client.user._patch(data.user); + } else { + ClientUser ??= require('../../../structures/ClientUser'); + client.user = new ClientUser(client, data.user); + client.users.cache.set(client.user.id, client.user); + } + + client.user.setAFK(true); + + for (const guild of data.guilds) { + guild.shardId = shard.id; + client.guilds._add(guild); + } + + for (const r of data.relationships) { + if(r.type == 1) { + client.friends.cache.set(r.id, new User(client, r.user)); + } else if(r.type == 2) { + client.blocked.cache.set(r.id, new User(client, r.user)); + } + } + + if (client.application) { + client.application._patch(data.application); + } else { + client.application = new ClientApplication(client, data.application); + } + + shard.checkReady(); +}; diff --git a/src/client/websocket/handlers/RESUMED.js b/src/client/websocket/handlers/RESUMED.js new file mode 100644 index 00000000..39824bc --- /dev/null +++ b/src/client/websocket/handlers/RESUMED.js @@ -0,0 +1,14 @@ +'use strict'; + +const Events = require('../../../util/Events'); + +module.exports = (client, packet, shard) => { + const replayed = shard.sequence - shard.closeSequence; + /** + * Emitted when a shard resumes successfully. + * @event Client#shardResume + * @param {number} id The shard id that resumed + * @param {number} replayedEvents The amount of replayed events + */ + client.emit(Events.ShardResume, shard.id, replayed); +}; diff --git a/src/client/websocket/handlers/STAGE_INSTANCE_CREATE.js b/src/client/websocket/handlers/STAGE_INSTANCE_CREATE.js new file mode 100644 index 00000000..77ae2ff --- /dev/null +++ b/src/client/websocket/handlers/STAGE_INSTANCE_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.StageInstanceCreate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/STAGE_INSTANCE_DELETE.js b/src/client/websocket/handlers/STAGE_INSTANCE_DELETE.js new file mode 100644 index 00000000..e2bb627 --- /dev/null +++ b/src/client/websocket/handlers/STAGE_INSTANCE_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.StageInstanceDelete.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/STAGE_INSTANCE_UPDATE.js b/src/client/websocket/handlers/STAGE_INSTANCE_UPDATE.js new file mode 100644 index 00000000..fabc84a --- /dev/null +++ b/src/client/websocket/handlers/STAGE_INSTANCE_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.StageInstanceUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_CREATE.js b/src/client/websocket/handlers/THREAD_CREATE.js new file mode 100644 index 00000000..d92cab0 --- /dev/null +++ b/src/client/websocket/handlers/THREAD_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadCreate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_DELETE.js b/src/client/websocket/handlers/THREAD_DELETE.js new file mode 100644 index 00000000..1140a08 --- /dev/null +++ b/src/client/websocket/handlers/THREAD_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadDelete.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_LIST_SYNC.js b/src/client/websocket/handlers/THREAD_LIST_SYNC.js new file mode 100644 index 00000000..17b173a --- /dev/null +++ b/src/client/websocket/handlers/THREAD_LIST_SYNC.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadListSync.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_MEMBERS_UPDATE.js b/src/client/websocket/handlers/THREAD_MEMBERS_UPDATE.js new file mode 100644 index 00000000..f3c7a73 --- /dev/null +++ b/src/client/websocket/handlers/THREAD_MEMBERS_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadMembersUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_MEMBER_UPDATE.js b/src/client/websocket/handlers/THREAD_MEMBER_UPDATE.js new file mode 100644 index 00000000..a111b0a --- /dev/null +++ b/src/client/websocket/handlers/THREAD_MEMBER_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadMemberUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_UPDATE.js b/src/client/websocket/handlers/THREAD_UPDATE.js new file mode 100644 index 00000000..481dcd4 --- /dev/null +++ b/src/client/websocket/handlers/THREAD_UPDATE.js @@ -0,0 +1,16 @@ +'use strict'; + +const Events = require('../../../util/Events'); + +module.exports = (client, packet) => { + const { old, updated } = client.actions.ChannelUpdate.handle(packet.d); + if (old && updated) { + /** + * Emitted whenever a thread is updated - e.g. name change, archive state change, locked state change. + * @event Client#threadUpdate + * @param {ThreadChannel} oldThread The thread before the update + * @param {ThreadChannel} newThread The thread after the update + */ + client.emit(Events.ThreadUpdate, old, updated); + } +}; diff --git a/src/client/websocket/handlers/TYPING_START.js b/src/client/websocket/handlers/TYPING_START.js new file mode 100644 index 00000000..9a56a54 --- /dev/null +++ b/src/client/websocket/handlers/TYPING_START.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.TypingStart.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/USER_UPDATE.js b/src/client/websocket/handlers/USER_UPDATE.js new file mode 100644 index 00000000..a02bf58 --- /dev/null +++ b/src/client/websocket/handlers/USER_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.UserUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/VOICE_SERVER_UPDATE.js b/src/client/websocket/handlers/VOICE_SERVER_UPDATE.js new file mode 100644 index 00000000..f9cf534 --- /dev/null +++ b/src/client/websocket/handlers/VOICE_SERVER_UPDATE.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = (client, packet) => { + client.emit('debug', `[VOICE] received voice server: ${JSON.stringify(packet)}`); + client.voice.onVoiceServer(packet.d); +}; diff --git a/src/client/websocket/handlers/VOICE_STATE_UPDATE.js b/src/client/websocket/handlers/VOICE_STATE_UPDATE.js new file mode 100644 index 00000000..dbff6ea --- /dev/null +++ b/src/client/websocket/handlers/VOICE_STATE_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.VoiceStateUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/WEBHOOKS_UPDATE.js b/src/client/websocket/handlers/WEBHOOKS_UPDATE.js new file mode 100644 index 00000000..46cacee --- /dev/null +++ b/src/client/websocket/handlers/WEBHOOKS_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.WebhooksUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/index.js b/src/client/websocket/handlers/index.js new file mode 100644 index 00000000..d7739c1 --- /dev/null +++ b/src/client/websocket/handlers/index.js @@ -0,0 +1,58 @@ +'use strict'; + +const handlers = Object.fromEntries([ + ['READY', require('./READY')], + ['RESUMED', require('./RESUMED')], + ['GUILD_CREATE', require('./GUILD_CREATE')], + ['GUILD_DELETE', require('./GUILD_DELETE')], + ['GUILD_UPDATE', require('./GUILD_UPDATE')], + ['INVITE_CREATE', require('./INVITE_CREATE')], + ['INVITE_DELETE', require('./INVITE_DELETE')], + ['GUILD_MEMBER_ADD', require('./GUILD_MEMBER_ADD')], + ['GUILD_MEMBER_REMOVE', require('./GUILD_MEMBER_REMOVE')], + ['GUILD_MEMBER_UPDATE', require('./GUILD_MEMBER_UPDATE')], + ['GUILD_MEMBERS_CHUNK', require('./GUILD_MEMBERS_CHUNK')], + ['GUILD_INTEGRATIONS_UPDATE', require('./GUILD_INTEGRATIONS_UPDATE')], + ['GUILD_ROLE_CREATE', require('./GUILD_ROLE_CREATE')], + ['GUILD_ROLE_DELETE', require('./GUILD_ROLE_DELETE')], + ['GUILD_ROLE_UPDATE', require('./GUILD_ROLE_UPDATE')], + ['GUILD_BAN_ADD', require('./GUILD_BAN_ADD')], + ['GUILD_BAN_REMOVE', require('./GUILD_BAN_REMOVE')], + ['GUILD_EMOJIS_UPDATE', require('./GUILD_EMOJIS_UPDATE')], + ['CHANNEL_CREATE', require('./CHANNEL_CREATE')], + ['CHANNEL_DELETE', require('./CHANNEL_DELETE')], + ['CHANNEL_UPDATE', require('./CHANNEL_UPDATE')], + ['CHANNEL_PINS_UPDATE', require('./CHANNEL_PINS_UPDATE')], + ['MESSAGE_CREATE', require('./MESSAGE_CREATE')], + ['MESSAGE_DELETE', require('./MESSAGE_DELETE')], + ['MESSAGE_UPDATE', require('./MESSAGE_UPDATE')], + ['MESSAGE_DELETE_BULK', require('./MESSAGE_DELETE_BULK')], + ['MESSAGE_REACTION_ADD', require('./MESSAGE_REACTION_ADD')], + ['MESSAGE_REACTION_REMOVE', require('./MESSAGE_REACTION_REMOVE')], + ['MESSAGE_REACTION_REMOVE_ALL', require('./MESSAGE_REACTION_REMOVE_ALL')], + ['MESSAGE_REACTION_REMOVE_EMOJI', require('./MESSAGE_REACTION_REMOVE_EMOJI')], + ['THREAD_CREATE', require('./THREAD_CREATE')], + ['THREAD_UPDATE', require('./THREAD_UPDATE')], + ['THREAD_DELETE', require('./THREAD_DELETE')], + ['THREAD_LIST_SYNC', require('./THREAD_LIST_SYNC')], + ['THREAD_MEMBER_UPDATE', require('./THREAD_MEMBER_UPDATE')], + ['THREAD_MEMBERS_UPDATE', require('./THREAD_MEMBERS_UPDATE')], + ['USER_UPDATE', require('./USER_UPDATE')], + ['PRESENCE_UPDATE', require('./PRESENCE_UPDATE')], + ['TYPING_START', require('./TYPING_START')], + ['VOICE_STATE_UPDATE', require('./VOICE_STATE_UPDATE')], + ['VOICE_SERVER_UPDATE', require('./VOICE_SERVER_UPDATE')], + ['WEBHOOKS_UPDATE', require('./WEBHOOKS_UPDATE')], + ['INTERACTION_CREATE', require('./INTERACTION_CREATE')], + ['STAGE_INSTANCE_CREATE', require('./STAGE_INSTANCE_CREATE')], + ['STAGE_INSTANCE_UPDATE', require('./STAGE_INSTANCE_UPDATE')], + ['STAGE_INSTANCE_DELETE', require('./STAGE_INSTANCE_DELETE')], + ['GUILD_STICKERS_UPDATE', require('./GUILD_STICKERS_UPDATE')], + ['GUILD_SCHEDULED_EVENT_CREATE', require('./GUILD_SCHEDULED_EVENT_CREATE')], + ['GUILD_SCHEDULED_EVENT_UPDATE', require('./GUILD_SCHEDULED_EVENT_UPDATE')], + ['GUILD_SCHEDULED_EVENT_DELETE', require('./GUILD_SCHEDULED_EVENT_DELETE')], + ['GUILD_SCHEDULED_EVENT_USER_ADD', require('./GUILD_SCHEDULED_EVENT_USER_ADD')], + ['GUILD_SCHEDULED_EVENT_USER_REMOVE', require('./GUILD_SCHEDULED_EVENT_USER_REMOVE')], +]); + +module.exports = handlers; diff --git a/src/errors/DJSError.js b/src/errors/DJSError.js new file mode 100644 index 00000000..af2ff09 --- /dev/null +++ b/src/errors/DJSError.js @@ -0,0 +1,61 @@ +'use strict'; + +// Heavily inspired by node's `internal/errors` module + +const kCode = Symbol('code'); +const messages = new Map(); + +/** + * Extend an error of some sort into a DiscordjsError. + * @param {Error} Base Base error to extend + * @returns {DiscordjsError} + */ +function makeDiscordjsError(Base) { + return class DiscordjsError extends Base { + constructor(key, ...args) { + super(message(key, args)); + this[kCode] = key; + if (Error.captureStackTrace) Error.captureStackTrace(this, DiscordjsError); + } + + get name() { + return `${super.name} [${this[kCode]}]`; + } + + get code() { + return this[kCode]; + } + }; +} + +/** + * Format the message for an error. + * @param {string} key Error key + * @param {Array<*>} args Arguments to pass for util format or as function args + * @returns {string} Formatted string + */ +function message(key, args) { + if (typeof key !== 'string') throw new Error('Error message key must be a string'); + const msg = messages.get(key); + if (!msg) throw new Error(`An invalid error message key was used: ${key}.`); + if (typeof msg === 'function') return msg(...args); + if (!args?.length) return msg; + args.unshift(msg); + return String(...args); +} + +/** + * Register an error code and message. + * @param {string} sym Unique name for the error + * @param {*} val Value of the error + */ +function register(sym, val) { + messages.set(sym, typeof val === 'function' ? val : String(val)); +} + +module.exports = { + register, + Error: makeDiscordjsError(Error), + TypeError: makeDiscordjsError(TypeError), + RangeError: makeDiscordjsError(RangeError), +}; diff --git a/src/errors/Messages.js b/src/errors/Messages.js new file mode 100644 index 00000000..ae526a6 --- /dev/null +++ b/src/errors/Messages.js @@ -0,0 +1,197 @@ +'use strict'; + +const { register } = require('./DJSError'); + +const Messages = { + CLIENT_INVALID_OPTION: (prop, must) => `The ${prop} option must be ${must}`, + CLIENT_INVALID_PROVIDED_SHARDS: 'None of the provided shards were valid.', + CLIENT_MISSING_INTENTS: 'Valid intents must be provided for the Client.', + CLIENT_NOT_READY: (action) => + `The client needs to be logged in to ${action}.`, + + TOKEN_INVALID: 'An invalid token was provided.', + TOKEN_MISSING: + 'Request to use token, but token was unavailable to the client.', + + WS_CLOSE_REQUESTED: 'WebSocket closed due to user request.', + WS_CONNECTION_EXISTS: 'There is already an existing WebSocket connection.', + WS_NOT_OPEN: (data = 'data') => `WebSocket not open to send ${data}`, + MANAGER_DESTROYED: 'Manager was destroyed.', + + BITFIELD_INVALID: (bit) => `Invalid bitfield flag or number: ${bit}.`, + + SHARDING_INVALID: 'Invalid shard settings were provided.', + SHARDING_REQUIRED: + 'This session would have handled too many guilds - Sharding is required.', + INVALID_INTENTS: 'Invalid intent provided for WebSocket intents.', + DISALLOWED_INTENTS: + 'Privileged intent provided is not enabled or whitelisted.', + SHARDING_NO_SHARDS: 'No shards have been spawned.', + SHARDING_IN_PROCESS: 'Shards are still being spawned.', + SHARDING_INVALID_EVAL_BROADCAST: 'Script to evaluate must be a function', + SHARDING_SHARD_NOT_FOUND: (id) => `Shard ${id} could not be found.`, + SHARDING_ALREADY_SPAWNED: (count) => `Already spawned ${count} shards.`, + SHARDING_PROCESS_EXISTS: (id) => `Shard ${id} already has an active process.`, + SHARDING_WORKER_EXISTS: (id) => `Shard ${id} already has an active worker.`, + SHARDING_READY_TIMEOUT: (id) => + `Shard ${id}'s Client took too long to become ready.`, + SHARDING_READY_DISCONNECTED: (id) => + `Shard ${id}'s Client disconnected before becoming ready.`, + SHARDING_READY_DIED: (id) => + `Shard ${id}'s process exited before its Client became ready.`, + SHARDING_NO_CHILD_EXISTS: (id) => + `Shard ${id} has no active process or worker.`, + SHARDING_SHARD_MISCALCULATION: (shard, guild, count) => + `Calculated invalid shard ${shard} for guild ${guild} with ${count} shards.`, + + COLOR_RANGE: 'Color must be within the range 0 - 16777215 (0xFFFFFF).', + COLOR_CONVERT: 'Unable to convert color to a number.', + + INVITE_OPTIONS_MISSING_CHANNEL: + 'A valid guild channel must be provided when GuildScheduledEvent is EXTERNAL.', + + BUTTON_LABEL: 'MessageButton label must be a string', + BUTTON_URL: 'MessageButton URL must be a string', + BUTTON_CUSTOM_ID: 'MessageButton customId must be a string', + + SELECT_MENU_CUSTOM_ID: 'MessageSelectMenu customId must be a string', + SELECT_MENU_PLACEHOLDER: 'MessageSelectMenu placeholder must be a string', + SELECT_OPTION_LABEL: 'MessageSelectOption label must be a string', + SELECT_OPTION_VALUE: 'MessageSelectOption value must be a string', + SELECT_OPTION_DESCRIPTION: 'MessageSelectOption description must be a string', + + INTERACTION_COLLECTOR_ERROR: (reason) => + `Collector received no interactions before ending with reason: ${reason}`, + + FILE_NOT_FOUND: (file) => `File could not be found: ${file}`, + + USER_BANNER_NOT_FETCHED: + "You must fetch this user's banner before trying to generate its URL!", + USER_NO_DM_CHANNEL: 'No DM Channel exists!', + + VOICE_NOT_STAGE_CHANNEL: 'You are only allowed to do this in stage channels.', + + VOICE_STATE_NOT_OWN: + 'You cannot self-deafen/mute/request to speak on VoiceStates that do not belong to the ClientUser.', + VOICE_STATE_INVALID_TYPE: (name) => `${name} must be a boolean.`, + + REQ_RESOURCE_TYPE: + 'The resource must be a string, Buffer or a valid file stream.', + + IMAGE_FORMAT: (format) => `Invalid image format: ${format}`, + IMAGE_SIZE: (size) => `Invalid image size: ${size}`, + + MESSAGE_BULK_DELETE_TYPE: + 'The messages must be an Array, Collection, or number.', + MESSAGE_NONCE_TYPE: 'Message nonce must be an integer or a string.', + MESSAGE_CONTENT_TYPE: 'Message content must be a non-empty string.', + + SPLIT_MAX_LEN: + 'Chunk exceeds the max length and contains no split characters.', + + BAN_RESOLVE_ID: (ban = false) => + `Couldn't resolve the user id to ${ban ? 'ban' : 'unban'}.`, + FETCH_BAN_RESOLVE_ID: "Couldn't resolve the user id to fetch the ban.", + + PRUNE_DAYS_TYPE: 'Days must be a number', + + GUILD_CHANNEL_RESOLVE: 'Could not resolve channel to a guild channel.', + GUILD_VOICE_CHANNEL_RESOLVE: + 'Could not resolve channel to a guild voice channel.', + GUILD_CHANNEL_ORPHAN: 'Could not find a parent to this guild channel.', + GUILD_CHANNEL_UNOWNED: + "The fetched channel does not belong to this manager's guild.", + GUILD_OWNED: 'Guild is owned by the client.', + GUILD_MEMBERS_TIMEOUT: "Members didn't arrive in time.", + GUILD_UNCACHED_ME: 'The client user as a member of this guild is uncached.', + CHANNEL_NOT_CACHED: + 'Could not find the channel where this message came from in the cache!', + STAGE_CHANNEL_RESOLVE: 'Could not resolve channel to a stage channel.', + GUILD_SCHEDULED_EVENT_RESOLVE: 'Could not resolve the guild scheduled event.', + + INVALID_TYPE: (name, expected, an = false) => + `Supplied ${name} is not a${an ? 'n' : ''} ${expected}.`, + INVALID_ELEMENT: (type, name, elem) => + `Supplied ${type} ${name} includes an invalid element: ${elem}`, + + MESSAGE_THREAD_PARENT: + 'The message was not sent in a guild text or news channel', + MESSAGE_EXISTING_THREAD: 'The message already has a thread', + THREAD_INVITABLE_TYPE: (type) => `Invitable cannot be edited on ${type}`, + + WEBHOOK_MESSAGE: 'The message was not sent by a webhook.', + WEBHOOK_TOKEN_UNAVAILABLE: + 'This action requires a webhook token, but none is available.', + WEBHOOK_URL_INVALID: 'The provided webhook URL is not valid.', + WEBHOOK_APPLICATION: + 'This message webhook belongs to an application and cannot be fetched.', + MESSAGE_REFERENCE_MISSING: 'The message does not reference another message', + + EMOJI_TYPE: 'Emoji must be a string or GuildEmoji/ReactionEmoji', + EMOJI_MANAGED: 'Emoji is managed and has no Author.', + MISSING_MANAGE_EMOJIS_AND_STICKERS_PERMISSION: (guild) => + `Client must have Manage Emojis and Stickers permission in guild ${guild} to see emoji authors.`, + NOT_GUILD_STICKER: + 'Sticker is a standard (non-guild) sticker and has no author.', + + REACTION_RESOLVE_USER: + "Couldn't resolve the user id to remove from the reaction.", + + VANITY_URL: 'This guild does not have the VANITY_URL feature enabled.', + + INVITE_RESOLVE_CODE: 'Could not resolve the code to fetch the invite.', + + INVITE_NOT_FOUND: 'Could not find the requested invite.', + + DELETE_GROUP_DM_CHANNEL: + "Bots don't have access to Group DM Channels and cannot delete them", + FETCH_GROUP_DM_CHANNEL: + "Bots don't have access to Group DM Channels and cannot fetch them", + + MEMBER_FETCH_NONCE_LENGTH: 'Nonce length must not exceed 32 characters.', + + GLOBAL_COMMAND_PERMISSIONS: + 'Permissions for global commands may only be fetched or modified by providing a GuildResolvable ' + + "or from a guild's application command manager.", + GUILD_UNCACHED_ROLE_RESOLVE: + 'Cannot resolve roles from an arbitrary guild, provide an id instead', + + INTERACTION_ALREADY_REPLIED: + 'The reply to this interaction has already been sent or deferred.', + INTERACTION_NOT_REPLIED: + 'The reply to this interaction has not been sent or deferred.', + INTERACTION_EPHEMERAL_REPLIED: 'Ephemeral responses cannot be deleted.', + + COMMAND_INTERACTION_OPTION_NOT_FOUND: (name) => + `Required option "${name}" not found.`, + COMMAND_INTERACTION_OPTION_TYPE: (name, type, expected) => + `Option "${name}" is of type: ${type}; expected ${expected}.`, + COMMAND_INTERACTION_OPTION_EMPTY: (name, type) => + `Required option "${name}" is of type: ${type}; expected a non-empty value.`, + COMMAND_INTERACTION_OPTION_NO_SUB_COMMAND: + 'No subcommand specified for interaction.', + COMMAND_INTERACTION_OPTION_NO_SUB_COMMAND_GROUP: + 'No subcommand group specified for interaction.', + AUTOCOMPLETE_INTERACTION_OPTION_NO_FOCUSED_OPTION: + 'No focused option for autocomplete interaction.', + + INVITE_MISSING_SCOPES: + 'At least one valid scope must be provided for the invite', + + NOT_IMPLEMENTED: (what, name) => `Method ${what} not implemented on ${name}.`, + + SWEEP_FILTER_RETURN: + 'The return value of the sweepFilter function was not false or a Function', + + INVALID_BOT_METHOD: `Bot accounts cannot use this method`, + INVALID_USER_METHOD: `User accounts cannot use this method`, + INVALID_LOCALE: 'Unable to select this location', +}; + +Messages.AuthenticationFailed = Messages.TOKEN_INVALID; +Messages.InvalidShard = Messages.SHARDING_INVALID; +Messages.ShardingRequired = Messages.SHARDING_REQUIRED; +Messages.InvalidIntents = Messages.INVALID_INTENTS; +Messages.DisallowedIntents = Messages.DISALLOWED_INTENTS; + +for (const [name, message] of Object.entries(Messages)) register(name, message); diff --git a/src/errors/index.js b/src/errors/index.js new file mode 100644 index 00000000..c94ddc7 --- /dev/null +++ b/src/errors/index.js @@ -0,0 +1,4 @@ +'use strict'; + +module.exports = require('./DJSError'); +module.exports.Messages = require('./Messages'); diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..2539b92 --- /dev/null +++ b/src/index.js @@ -0,0 +1,204 @@ +'use strict'; + +// "Root" classes (starting points) +exports.BaseClient = require('./client/BaseClient'); +exports.Client = require('./client/Client'); +exports.Shard = require('./sharding/Shard'); +exports.ShardClientUtil = require('./sharding/ShardClientUtil'); +exports.ShardingManager = require('./sharding/ShardingManager'); +exports.WebhookClient = require('./client/WebhookClient'); + +// Utilities +exports.ActivityFlagsBitField = require('./util/ActivityFlagsBitField'); +exports.ApplicationFlagsBitField = require('./util/ApplicationFlagsBitField'); +exports.BaseManager = require('./managers/BaseManager'); +exports.BitField = require('./util/BitField'); +exports.Collection = require('@discordjs/collection').Collection; +exports.Constants = require('./util/Constants'); +exports.Colors = require('./util/Colors'); +exports.DataResolver = require('./util/DataResolver'); +exports.EnumResolvers = require('./util/EnumResolvers'); +exports.Events = require('./util/Events'); +exports.Formatters = require('./util/Formatters'); +exports.IntentsBitField = require('./util/IntentsBitField'); +exports.LimitedCollection = require('./util/LimitedCollection'); +exports.MessageFlagsBitField = require('./util/MessageFlagsBitField'); +exports.Options = require('./util/Options'); +exports.Partials = require('./util/Partials'); +exports.PermissionsBitField = require('./util/PermissionsBitField'); +exports.ShardEvents = require('./util/ShardEvents'); +exports.Status = require('./util/Status'); +exports.SnowflakeUtil = require('@sapphire/snowflake').DiscordSnowflake; +exports.Sweepers = require('./util/Sweepers'); +exports.SystemChannelFlagsBitField = require('./util/SystemChannelFlagsBitField'); +exports.ThreadMemberFlagsBitField = require('./util/ThreadMemberFlagsBitField'); +exports.UserFlagsBitField = require('./util/UserFlagsBitField'); +exports.Util = require('./util/Util'); +exports.version = require('../package.json').version; + +// Managers +exports.ApplicationCommandManager = require('./managers/ApplicationCommandManager'); +exports.ApplicationCommandPermissionsManager = require('./managers/ApplicationCommandPermissionsManager'); +exports.BaseGuildEmojiManager = require('./managers/BaseGuildEmojiManager'); +exports.CachedManager = require('./managers/CachedManager'); +exports.ChannelManager = require('./managers/ChannelManager'); +exports.ClientVoiceManager = require('./client/voice/ClientVoiceManager'); +exports.DataManager = require('./managers/DataManager'); +exports.GuildApplicationCommandManager = require('./managers/GuildApplicationCommandManager'); +exports.GuildBanManager = require('./managers/GuildBanManager'); +exports.GuildChannelManager = require('./managers/GuildChannelManager'); +exports.GuildEmojiManager = require('./managers/GuildEmojiManager'); +exports.GuildEmojiRoleManager = require('./managers/GuildEmojiRoleManager'); +exports.GuildInviteManager = require('./managers/GuildInviteManager'); +exports.GuildManager = require('./managers/GuildManager'); +exports.GuildMemberManager = require('./managers/GuildMemberManager'); +exports.GuildMemberRoleManager = require('./managers/GuildMemberRoleManager'); +exports.GuildScheduledEventManager = require('./managers/GuildScheduledEventManager'); +exports.GuildStickerManager = require('./managers/GuildStickerManager'); +exports.MessageManager = require('./managers/MessageManager'); +exports.PermissionOverwriteManager = require('./managers/PermissionOverwriteManager'); +exports.PresenceManager = require('./managers/PresenceManager'); +exports.ReactionManager = require('./managers/ReactionManager'); +exports.ReactionUserManager = require('./managers/ReactionUserManager'); +exports.RoleManager = require('./managers/RoleManager'); +exports.StageInstanceManager = require('./managers/StageInstanceManager'); +exports.ThreadManager = require('./managers/ThreadManager'); +exports.ThreadMemberManager = require('./managers/ThreadMemberManager'); +exports.UserManager = require('./managers/UserManager'); +exports.VoiceStateManager = require('./managers/VoiceStateManager'); +exports.WebSocketManager = require('./client/websocket/WebSocketManager'); +exports.WebSocketShard = require('./client/websocket/WebSocketShard'); + +// Structures +exports.ActionRow = require('./structures/ActionRow'); +exports.Activity = require('./structures/Presence').Activity; +exports.AnonymousGuild = require('./structures/AnonymousGuild'); +exports.Application = require('./structures/interfaces/Application'); +exports.ApplicationCommand = require('./structures/ApplicationCommand'); +exports.AutocompleteInteraction = require('./structures/AutocompleteInteraction'); +exports.Base = require('./structures/Base'); +exports.BaseGuild = require('./structures/BaseGuild'); +exports.BaseGuildEmoji = require('./structures/BaseGuildEmoji'); +exports.BaseGuildTextChannel = require('./structures/BaseGuildTextChannel'); +exports.BaseGuildVoiceChannel = require('./structures/BaseGuildVoiceChannel'); +exports.ButtonComponent = require('./structures/ButtonComponent'); +exports.ButtonInteraction = require('./structures/ButtonInteraction'); +exports.CategoryChannel = require('./structures/CategoryChannel'); +exports.Channel = require('./structures/Channel').Channel; +exports.ChatInputCommandInteraction = require('./structures/ChatInputCommandInteraction'); +exports.ClientApplication = require('./structures/ClientApplication'); +exports.ClientPresence = require('./structures/ClientPresence'); +exports.ClientUser = require('./structures/ClientUser'); +exports.CommandInteraction = require('./structures/CommandInteraction'); +exports.Collector = require('./structures/interfaces/Collector'); +exports.CommandInteractionOptionResolver = require('./structures/CommandInteractionOptionResolver'); +exports.ContextMenuCommandInteraction = require('./structures/ContextMenuCommandInteraction'); +exports.DMChannel = require('./structures/DMChannel'); +exports.Embed = require('./structures/Embed'); +exports.UnsafeEmbed = require('@discordjs/builders').UnsafeEmbed; +exports.Emoji = require('./structures/Emoji').Emoji; +exports.Guild = require('./structures/Guild').Guild; +exports.GuildAuditLogs = require('./structures/GuildAuditLogs'); +exports.GuildAuditLogsEntry = require('./structures/GuildAuditLogs').Entry; +exports.GuildBan = require('./structures/GuildBan'); +exports.GuildChannel = require('./structures/GuildChannel'); +exports.GuildEmoji = require('./structures/GuildEmoji'); +exports.GuildMember = require('./structures/GuildMember').GuildMember; +exports.GuildPreview = require('./structures/GuildPreview'); +exports.GuildPreviewEmoji = require('./structures/GuildPreviewEmoji'); +exports.GuildScheduledEvent = require('./structures/GuildScheduledEvent').GuildScheduledEvent; +exports.GuildTemplate = require('./structures/GuildTemplate'); +exports.Integration = require('./structures/Integration'); +exports.IntegrationApplication = require('./structures/IntegrationApplication'); +exports.Interaction = require('./structures/Interaction'); +exports.InteractionCollector = require('./structures/InteractionCollector'); +exports.InteractionWebhook = require('./structures/InteractionWebhook'); +exports.Invite = require('./structures/Invite'); +exports.InviteStageInstance = require('./structures/InviteStageInstance'); +exports.InviteGuild = require('./structures/InviteGuild'); +exports.Message = require('./structures/Message').Message; +exports.MessageAttachment = require('./structures/MessageAttachment'); +exports.MessageCollector = require('./structures/MessageCollector'); +exports.MessageComponentInteraction = require('./structures/MessageComponentInteraction'); +exports.MessageContextMenuCommandInteraction = require('./structures/MessageContextMenuCommandInteraction'); +exports.MessageMentions = require('./structures/MessageMentions'); +exports.MessagePayload = require('./structures/MessagePayload'); +exports.MessageReaction = require('./structures/MessageReaction'); +exports.NewsChannel = require('./structures/NewsChannel'); +exports.OAuth2Guild = require('./structures/OAuth2Guild'); +exports.PartialGroupDMChannel = require('./structures/PartialGroupDMChannel'); +exports.PermissionOverwrites = require('./structures/PermissionOverwrites'); +exports.Presence = require('./structures/Presence').Presence; +exports.ReactionCollector = require('./structures/ReactionCollector'); +exports.ReactionEmoji = require('./structures/ReactionEmoji'); +exports.RichPresenceAssets = require('./structures/Presence').RichPresenceAssets; +exports.Role = require('./structures/Role').Role; +exports.SelectMenuComponent = require('./structures/SelectMenuComponent'); +exports.SelectMenuInteraction = require('./structures/SelectMenuInteraction'); +exports.StageChannel = require('./structures/StageChannel'); +exports.StageInstance = require('./structures/StageInstance').StageInstance; +exports.Sticker = require('./structures/Sticker').Sticker; +exports.StickerPack = require('./structures/StickerPack'); +exports.StoreChannel = require('./structures/StoreChannel'); +exports.Team = require('./structures/Team'); +exports.TeamMember = require('./structures/TeamMember'); +exports.TextChannel = require('./structures/TextChannel'); +exports.ThreadChannel = require('./structures/ThreadChannel'); +exports.ThreadMember = require('./structures/ThreadMember'); +exports.Typing = require('./structures/Typing'); +exports.User = require('./structures/User'); +exports.UserContextMenuCommandInteraction = require('./structures/UserContextMenuCommandInteraction'); +exports.VoiceChannel = require('./structures/VoiceChannel'); +exports.VoiceRegion = require('./structures/VoiceRegion'); +exports.VoiceState = require('./structures/VoiceState'); +exports.Webhook = require('./structures/Webhook'); +exports.Widget = require('./structures/Widget'); +exports.WidgetMember = require('./structures/WidgetMember'); +exports.WelcomeChannel = require('./structures/WelcomeChannel'); +exports.WelcomeScreen = require('./structures/WelcomeScreen'); + +exports.WebSocket = require('./WebSocket'); + +// External +exports.ActivityType = require('discord-api-types/v9').ActivityType; +exports.ApplicationCommandType = require('discord-api-types/v9').ApplicationCommandType; +exports.ApplicationCommandOptionType = require('discord-api-types/v9').ApplicationCommandOptionType; +exports.ApplicationCommandPermissionType = require('discord-api-types/v9').ApplicationCommandPermissionType; +exports.AuditLogEvent = require('discord-api-types/v9').AuditLogEvent; +exports.ButtonStyle = require('discord-api-types/v9').ButtonStyle; +exports.ChannelType = require('discord-api-types/v9').ChannelType; +exports.ComponentType = require('discord-api-types/v9').ComponentType; +exports.GatewayCloseCodes = require('discord-api-types/v9').GatewayCloseCodes; +exports.GatewayDispatchEvents = require('discord-api-types/v9').GatewayDispatchEvents; +exports.GatewayIntentBits = require('discord-api-types/v9').GatewayIntentBits; +exports.GatewayOpcodes = require('discord-api-types/v9').GatewayOpcodes; +exports.GuildFeature = require('discord-api-types/v9').GuildFeature; +exports.GuildMFALevel = require('discord-api-types/v9').GuildMFALevel; +exports.GuildNSFWLevel = require('discord-api-types/v9').GuildNSFWLevel; +exports.GuildPremiumTier = require('discord-api-types/v9').GuildPremiumTier; +exports.GuildScheduledEventEntityType = require('discord-api-types/v9').GuildScheduledEventEntityType; +exports.GuildScheduledEventPrivacyLevel = require('discord-api-types/v9').GuildScheduledEventPrivacyLevel; +exports.GuildScheduledEventStatus = require('discord-api-types/v9').GuildScheduledEventStatus; +exports.GuildSystemChannelFlags = require('discord-api-types/v9').GuildSystemChannelFlags; +exports.GuildVerificationLevel = require('discord-api-types/v9').GuildVerificationLevel; +exports.InteractionType = require('discord-api-types/v9').InteractionType; +exports.InteractionResponseType = require('discord-api-types/v9').InteractionResponseType; +exports.InviteTargetType = require('discord-api-types/v9').InviteTargetType; +exports.Locale = require('discord-api-types/v9').Locale; +exports.MessageType = require('discord-api-types/v9').MessageType; +exports.MessageFlags = require('discord-api-types/v9').MessageFlags; +exports.OAuth2Scopes = require('discord-api-types/v9').OAuth2Scopes; +exports.PermissionFlagsBits = require('discord-api-types/v9').PermissionFlagsBits; +exports.RESTJSONErrorCodes = require('discord-api-types/v9').RESTJSONErrorCodes; +exports.StageInstancePrivacyLevel = require('discord-api-types/v9').StageInstancePrivacyLevel; +exports.StickerType = require('discord-api-types/v9').StickerType; +exports.StickerFormatType = require('discord-api-types/v9').StickerFormatType; +exports.UserFlags = require('discord-api-types/v9').UserFlags; +exports.WebhookType = require('discord-api-types/v9').WebhookType; +exports.UnsafeButtonComponent = require('@discordjs/builders').UnsafeButtonComponent; +exports.UnsafeSelectMenuComponent = require('@discordjs/builders').UnsafeSelectMenuComponent; +exports.SelectMenuOption = require('@discordjs/builders').SelectMenuOption; +exports.UnsafeSelectMenuOption = require('@discordjs/builders').UnsafeSelectMenuOption; +exports.DiscordAPIError = require('./rest/DiscordAPIError'); +exports.HTTPError = require('./rest/HTTPError'); +exports.RateLimitError = require('./rest/RateLimitError'); diff --git a/src/managers/ApplicationCommandManager.js b/src/managers/ApplicationCommandManager.js new file mode 100644 index 00000000..b04ecb2 --- /dev/null +++ b/src/managers/ApplicationCommandManager.js @@ -0,0 +1,217 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const ApplicationCommandPermissionsManager = require('./ApplicationCommandPermissionsManager'); +const CachedManager = require('./CachedManager'); +const { TypeError } = require('../errors'); +const ApplicationCommand = require('../structures/ApplicationCommand'); + +/** + * Manages API methods for application commands and stores their cache. + * @extends {CachedManager} + */ +class ApplicationCommandManager extends CachedManager { + constructor(client, iterable) { + super(client, ApplicationCommand, iterable); + + /** + * The manager for permissions of arbitrary commands on arbitrary guilds + * @type {ApplicationCommandPermissionsManager} + */ + this.permissions = new ApplicationCommandPermissionsManager(this); + } + + /** + * The cache of this manager + * @type {Collection} + * @name ApplicationCommandManager#cache + */ + + _add(data, cache, guildId) { + return super._add(data, cache, { extras: [this.guild, guildId] }); + } + + /** + * The APIRouter path to the commands + * @param {Snowflake} [options.id] The application command's id + * @param {Snowflake} [options.guildId] The guild's id to use in the path, + * ignored when using a {@link GuildApplicationCommandManager} + * @returns {string} + * @private + */ + commandPath({ id, guildId } = {}) { + let path = this.client.api.applications(this.client.application.id); + if(this.guild ?? guildId) path = path.guilds(this.guild?.id ?? guildId); + return id ? path.commands(id) : path.commands; + } + + /** + * Data that resolves to give an ApplicationCommand object. This can be: + * * An ApplicationCommand object + * * A Snowflake + * @typedef {ApplicationCommand|Snowflake} ApplicationCommandResolvable + */ + + /** + * Options used to fetch data from Discord + * @typedef {Object} BaseFetchOptions + * @property {boolean} [cache=true] Whether to cache the fetched data if it wasn't already + * @property {boolean} [force=false] Whether to skip the cache check and request the API + */ + + /** + * Options used to fetch Application Commands from Discord + * @typedef {BaseFetchOptions} FetchApplicationCommandOptions + * @property {Snowflake} [guildId] The guild's id to fetch commands for, for when the guild is not cached + */ + + /** + * Obtains one or multiple application commands from Discord, or the cache if it's already available. + * @param {Snowflake} [id] The application command's id + * @param {FetchApplicationCommandOptions} [options] Additional options for this fetch + * @returns {Promise>} + * @example + * // Fetch a single command + * client.application.commands.fetch('123456789012345678') + * .then(command => console.log(`Fetched command ${command.name}`)) + * .catch(console.error); + * @example + * // Fetch all commands + * guild.commands.fetch() + * .then(commands => console.log(`Fetched ${commands.size} commands`)) + * .catch(console.error); + */ + async fetch(id, { guildId, cache = true, force = false } = {}) { + if (typeof id === 'object') { + ({ guildId, cache = true } = id); + } else if (id) { + if (!force) { + const existing = this.cache.get(id); + if (existing) return existing; + } + const command = await this.commandPath({ id, guildId }).get(); + return this._add(command, cache); + } + + const data = await this.commandPath({ guildId }).get(); + return data.reduce((coll, command) => coll.set(command.id, this._add(command, cache, guildId)), new Collection()); + } + + /** + * Creates an application command. + * @param {ApplicationCommandData|APIApplicationCommand} command The command + * @param {Snowflake} [guildId] The guild's id to create this command in, + * ignored when using a {@link GuildApplicationCommandManager} + * @returns {Promise} + * @example + * // Create a new command + * client.application.commands.create({ + * name: 'test', + * description: 'A test command', + * }) + * .then(console.log) + * .catch(console.error); + */ + async create(command, guildId) { + const data = await this.commandPath({ guildId }).post({ + data: this.constructor.transformCommand(command), + }); + return this._add(data, true, guildId); + } + + /** + * Sets all the commands for this application or guild. + * @param {ApplicationCommandData[]|APIApplicationCommand[]} commands The commands + * @param {Snowflake} [guildId] The guild's id to create the commands in, + * ignored when using a {@link GuildApplicationCommandManager} + * @returns {Promise>} + * @example + * // Set all commands to just this one + * client.application.commands.set([ + * { + * name: 'test', + * description: 'A test command', + * }, + * ]) + * .then(console.log) + * .catch(console.error); + * @example + * // Remove all commands + * guild.commands.set([]) + * .then(console.log) + * .catch(console.error); + */ + async set(commands, guildId) { + const data = await this.commandPath({ guildId }).put({ + data: commands.map(c => this.constructor.transformCommand(c)), + }); + return data.reduce((coll, command) => coll.set(command.id, this._add(command, true, guildId)), new Collection()); + } + + /** + * Edits an application command. + * @param {ApplicationCommandResolvable} command The command to edit + * @param {ApplicationCommandData|APIApplicationCommand} data The data to update the command with + * @param {Snowflake} [guildId] The guild's id where the command registered, + * ignored when using a {@link GuildApplicationCommandManager} + * @returns {Promise} + * @example + * // Edit an existing command + * client.application.commands.edit('123456789012345678', { + * description: 'New description', + * }) + * .then(console.log) + * .catch(console.error); + */ + async edit(command, data, guildId) { + const id = this.resolveId(command); + if (!id) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable'); + + const patched = await this.commandPath({ id, guildId }).patch({ + body: this.constructor.transformCommand(data), + }); + return this._add(patched, true, guildId); + } + + /** + * Deletes an application command. + * @param {ApplicationCommandResolvable} command The command to delete + * @param {Snowflake} [guildId] The guild's id where the command is registered, + * ignored when using a {@link GuildApplicationCommandManager} + * @returns {Promise} + * @example + * // Delete a command + * guild.commands.delete('123456789012345678') + * .then(console.log) + * .catch(console.error); + */ + async delete(command, guildId) { + const id = this.resolveId(command); + if (!id) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable'); + + await this.commandPath({ id, guildId }).delete(); + + const cached = this.cache.get(id); + this.cache.delete(id); + return cached ?? null; + } + + /** + * Transforms an {@link ApplicationCommandData} object into something that can be used with the API. + * @param {ApplicationCommandData|APIApplicationCommand} command The command to transform + * @returns {APIApplicationCommand} + * @private + */ + static transformCommand(command) { + return { + name: command.name, + description: command.description, + type: command.type, + options: command.options?.map(o => ApplicationCommand.transformOption(o)), + default_permission: command.defaultPermission ?? command.default_permission, + }; + } +} + +module.exports = ApplicationCommandManager; diff --git a/src/managers/ApplicationCommandPermissionsManager.js b/src/managers/ApplicationCommandPermissionsManager.js new file mode 100644 index 00000000..b944e1d --- /dev/null +++ b/src/managers/ApplicationCommandPermissionsManager.js @@ -0,0 +1,399 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { RESTJSONErrorCodes, Routes } = require('discord-api-types/v9'); +const BaseManager = require('./BaseManager'); +const { Error, TypeError } = require('../errors'); + +/** + * Manages API methods for permissions of Application Commands. + * @extends {BaseManager} + */ +class ApplicationCommandPermissionsManager extends BaseManager { + constructor(manager) { + super(manager.client); + + /** + * The manager or command that this manager belongs to + * @type {ApplicationCommandManager|ApplicationCommand} + * @private + */ + this.manager = manager; + + /** + * The guild that this manager acts on + * @type {?Guild} + */ + this.guild = manager.guild ?? null; + + /** + * The id of the guild that this manager acts on + * @type {?Snowflake} + */ + this.guildId = manager.guildId ?? manager.guild?.id ?? null; + + /** + * The id of the command this manager acts on + * @type {?Snowflake} + */ + this.commandId = manager.id ?? null; + } + + /** + * The APIRouter path to the commands + * @param {Snowflake} guildId The guild's id to use in the path, + * @param {Snowflake} [commandId] The application command's id + * @returns {string} + * @private + */ + permissionsPath(guildId, commandId) { + return this.client.api.applications(this.client.application.id).guilds(guildId).commands(commandId).permissions; + } + + /** + * Data for setting the permissions of an application command. + * @typedef {Object} ApplicationCommandPermissionData + * @property {Snowflake} id The role or user's id + * @property {ApplicationCommandPermissionType|number} type Whether this permission is for a role or a user + * @property {boolean} permission Whether the role or user has the permission to use this command + */ + + /** + * The object returned when fetching permissions for an application command. + * @typedef {Object} ApplicationCommandPermissions + * @property {Snowflake} id The role or user's id + * @property {ApplicationCommandPermissionType} type Whether this permission is for a role or a user + * @property {boolean} permission Whether the role or user has the permission to use this command + */ + + /** + * Options for managing permissions for one or more Application Commands + * When passing these options to a manager where `guildId` is `null`, + * `guild` is a required parameter + * @typedef {Object} BaseApplicationCommandPermissionsOptions + * @property {GuildResolvable} [guild] The guild to modify / check permissions for + * Ignored when the manager has a non-null `guildId` property + * @property {ApplicationCommandResolvable} [command] The command to modify / check permissions for + * Ignored when the manager has a non-null `commandId` property + */ + + /** + * Fetches the permissions for one or multiple commands. + * @param {BaseApplicationCommandPermissionsOptions} [options] Options used to fetch permissions + * @returns {Promise>} + * @example + * // Fetch permissions for one command + * guild.commands.permissions.fetch({ command: '123456789012345678' }) + * .then(perms => console.log(`Fetched permissions for ${perms.length} users`)) + * .catch(console.error); + * @example + * // Fetch permissions for all commands in a guild + * client.application.commands.permissions.fetch({ guild: '123456789012345678' }) + * .then(perms => console.log(`Fetched permissions for ${perms.size} commands`)) + * .catch(console.error); + */ + async fetch({ guild, command } = {}) { + const { guildId, commandId } = this._validateOptions(guild, command); + if (commandId) { + const data = await this.permissionsPath(guildId, commandId).get(); + return data.permissions.map(perm => this.constructor.transformPermissions(perm, true)); + } + + const data = await this.permissionsPath(guildId).get(); + return data.reduce( + (coll, perm) => + coll.set( + perm.id, + perm.permissions.map(p => this.constructor.transformPermissions(p, true)), + ), + new Collection(), + ); + } + + /** + * Data used for overwriting the permissions for all application commands in a guild. + * @typedef {Object} GuildApplicationCommandPermissionData + * @property {Snowflake} id The command's id + * @property {ApplicationCommandPermissionData[]} permissions The permissions for this command + */ + + /** + * Options used to set permissions for one or more Application Commands in a guild + * One of `command` AND `permissions`, OR `fullPermissions` is required. + * `fullPermissions` is not a valid option when passing to a manager where `commandId` is non-null + * @typedef {BaseApplicationCommandPermissionsOptions} SetApplicationCommandPermissionsOptions + * @property {ApplicationCommandPermissionData[]} [permissions] The new permissions for the command + * @property {GuildApplicationCommandPermissionData[]} [fullPermissions] The new permissions for all commands + * in a guild When this parameter is set, `permissions` and `command` are ignored + */ + + /** + * Sets the permissions for one or more commands. + * @param {SetApplicationCommandPermissionsOptions} options Options used to set permissions + * @returns {Promise>} + * @example + * // Set the permissions for one command + * client.application.commands.permissions.set({ guild: '892455839386304532', command: '123456789012345678', + * permissions: [ + * { + * id: '876543210987654321', + * type: ApplicationCommandOptionType.User, + * permission: false, + * }, + * ]}) + * .then(console.log) + * .catch(console.error); + * @example + * // Set the permissions for all commands + * guild.commands.permissions.set({ fullPermissions: [ + * { + * id: '123456789012345678', + * permissions: [{ + * id: '876543210987654321', + * type: ApplicationCommandOptionType.User, + * permission: false, + * }], + * }, + * ]}) + * .then(console.log) + * .catch(console.error); + */ + async set({ guild, command, permissions, fullPermissions } = {}) { + const { guildId, commandId } = this._validateOptions(guild, command); + + if (commandId) { + if (!Array.isArray(permissions)) { + throw new TypeError('INVALID_TYPE', 'permissions', 'Array of ApplicationCommandPermissionData', true); + } + const data = await this.permissionsPath(guildId, commandId).put({ + data: { permissions: permissions.map(perm => this.constructor.transformPermissions(perm)) }, + }); + return data.permissions.map(perm => this.constructor.transformPermissions(perm, true)); + } + + if (!Array.isArray(fullPermissions)) { + throw new TypeError('INVALID_TYPE', 'fullPermissions', 'Array of GuildApplicationCommandPermissionData', true); + } + + const APIPermissions = []; + for (const perm of fullPermissions) { + if (!Array.isArray(perm.permissions)) throw new TypeError('INVALID_ELEMENT', 'Array', 'fullPermissions', perm); + APIPermissions.push({ + id: perm.id, + permissions: perm.permissions.map(p => this.constructor.transformPermissions(p)), + }); + } + const data = await this.permissionsPath(guildId).put({ + data: APIPermissions, + }); + return data.reduce( + (coll, perm) => + coll.set( + perm.id, + perm.permissions.map(p => this.constructor.transformPermissions(p, true)), + ), + new Collection(), + ); + } + + /** + * Options used to add permissions to a command + * The `command` parameter is not optional when the managers `commandId` is `null` + * @typedef {BaseApplicationCommandPermissionsOptions} AddApplicationCommandPermissionsOptions + * @property {ApplicationCommandPermissionData[]} permissions The permissions to add to the command + */ + + /** + * Add permissions to a command. + * @param {AddApplicationCommandPermissionsOptions} options Options used to add permissions + * @returns {Promise} + * @example + * // Block a role from the command permissions + * guild.commands.permissions.add({ command: '123456789012345678', permissions: [ + * { + * id: '876543211234567890', + * type: ApplicationCommandPermissionType.Role, + * permission: false + * }, + * ]}) + * .then(console.log) + * .catch(console.error); + */ + async add({ guild, command, permissions }) { + const { guildId, commandId } = this._validateOptions(guild, command); + if (!commandId) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable'); + if (!Array.isArray(permissions)) { + throw new TypeError('INVALID_TYPE', 'permissions', 'Array of ApplicationCommandPermissionData', true); + } + + let existing = []; + try { + existing = await this.fetch({ guild: guildId, command: commandId }); + } catch (error) { + if (error.code !== RESTJSONErrorCodes.UnknownApplicationCommandPermissions) throw error; + } + + const newPermissions = permissions.slice(); + for (const perm of existing) { + if (!newPermissions.some(x => x.id === perm.id)) { + newPermissions.push(perm); + } + } + + return this.set({ guild: guildId, command: commandId, permissions: newPermissions }); + } + + /** + * Options used to remove permissions from a command + * The `command` parameter is not optional when the managers `commandId` is `null` + * @typedef {BaseApplicationCommandPermissionsOptions} RemoveApplicationCommandPermissionsOptions + * @property {UserResolvable|UserResolvable[]} [users] The user(s) to remove from the command permissions + * One of `users` or `roles` is required + * @property {RoleResolvable|RoleResolvable[]} [roles] The role(s) to remove from the command permissions + * One of `users` or `roles` is required + */ + + /** + * Remove permissions from a command. + * @param {RemoveApplicationCommandPermissionsOptions} options Options used to remove permissions + * @returns {Promise} + * @example + * // Remove a user permission from this command + * guild.commands.permissions.remove({ command: '123456789012345678', users: '876543210123456789' }) + * .then(console.log) + * .catch(console.error); + * @example + * // Remove multiple roles from this command + * guild.commands.permissions.remove({ + * command: '123456789012345678', roles: ['876543210123456789', '765432101234567890'] + * }) + * .then(console.log) + * .catch(console.error); + */ + async remove({ guild, command, users, roles }) { + const { guildId, commandId } = this._validateOptions(guild, command); + if (!commandId) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable'); + + if (!users && !roles) throw new TypeError('INVALID_TYPE', 'users OR roles', 'Array or Resolvable', true); + + let resolvedIds = []; + if (Array.isArray(users)) { + users.forEach(user => { + const userId = this.client.users.resolveId(user); + if (!userId) throw new TypeError('INVALID_ELEMENT', 'Array', 'users', user); + resolvedIds.push(userId); + }); + } else if (users) { + const userId = this.client.users.resolveId(users); + if (!userId) { + throw new TypeError('INVALID_TYPE', 'users', 'Array or UserResolvable'); + } + resolvedIds.push(userId); + } + + if (Array.isArray(roles)) { + roles.forEach(role => { + if (typeof role === 'string') { + resolvedIds.push(role); + return; + } + if (!this.guild) throw new Error('GUILD_UNCACHED_ROLE_RESOLVE'); + const roleId = this.guild.roles.resolveId(role); + if (!roleId) throw new TypeError('INVALID_ELEMENT', 'Array', 'users', role); + resolvedIds.push(roleId); + }); + } else if (roles) { + if (typeof roles === 'string') { + resolvedIds.push(roles); + } else { + if (!this.guild) throw new Error('GUILD_UNCACHED_ROLE_RESOLVE'); + const roleId = this.guild.roles.resolveId(roles); + if (!roleId) { + throw new TypeError('INVALID_TYPE', 'users', 'Array or RoleResolvable'); + } + resolvedIds.push(roleId); + } + } + + let existing = []; + try { + existing = await this.fetch({ guild: guildId, command: commandId }); + } catch (error) { + if (error.code !== RESTJSONErrorCodes.UnknownApplicationCommandPermissions) throw error; + } + + const permissions = existing.filter(perm => !resolvedIds.includes(perm.id)); + + return this.set({ guild: guildId, command: commandId, permissions }); + } + + /** + * Options used to check the existence of permissions on a command + * The `command` parameter is not optional when the managers `commandId` is `null` + * @typedef {BaseApplicationCommandPermissionsOptions} HasApplicationCommandPermissionsOptions + * @property {UserResolvable|RoleResolvable} permissionId The user or role to check if a permission exists for + * on this command. + */ + + /** + * Check whether a permission exists for a user or role + * @param {AddApplicationCommandPermissionsOptions} options Options used to check permissions + * @returns {Promise} + * @example + * // Check whether a user has permission to use a command + * guild.commands.permissions.has({ command: '123456789012345678', permissionId: '876543210123456789' }) + * .then(console.log) + * .catch(console.error); + */ + async has({ guild, command, permissionId }) { + const { guildId, commandId } = this._validateOptions(guild, command); + if (!commandId) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable'); + + if (!permissionId) throw new TypeError('INVALID_TYPE', 'permissionId', 'UserResolvable or RoleResolvable'); + let resolvedId = permissionId; + if (typeof permissionId !== 'string') { + resolvedId = this.client.users.resolveId(permissionId); + if (!resolvedId) { + if (!this.guild) throw new Error('GUILD_UNCACHED_ROLE_RESOLVE'); + resolvedId = this.guild.roles.resolveId(permissionId); + } + if (!resolvedId) { + throw new TypeError('INVALID_TYPE', 'permissionId', 'UserResolvable or RoleResolvable'); + } + } + + let existing = []; + try { + existing = await this.fetch({ guild: guildId, command: commandId }); + } catch (error) { + if (error.code !== RESTJSONErrorCodes.UnknownApplicationCommandPermissions) throw error; + } + + return existing.some(perm => perm.id === resolvedId); + } + + _validateOptions(guild, command) { + const guildId = this.guildId ?? this.client.guilds.resolveId(guild); + if (!guildId) throw new Error('GLOBAL_COMMAND_PERMISSIONS'); + let commandId = this.commandId; + if (command && !commandId) { + commandId = this.manager.resolveId?.(command); + if (!commandId && this.guild) { + commandId = this.guild.commands.resolveId(command); + } + commandId ??= this.client.application?.commands.resolveId(command); + if (!commandId) { + throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable', true); + } + } + return { guildId, commandId }; + } +} + +module.exports = ApplicationCommandPermissionsManager; + +/* eslint-disable max-len */ +/** + * @external APIApplicationCommandPermissions + * @see {@link https://discord.com/developers/docs/interactions/application-commands#application-command-permissions-object-application-command-permissions-structure} + */ diff --git a/src/managers/BaseGuildEmojiManager.js b/src/managers/BaseGuildEmojiManager.js new file mode 100644 index 00000000..a9bbbff --- /dev/null +++ b/src/managers/BaseGuildEmojiManager.js @@ -0,0 +1,80 @@ +'use strict'; + +const CachedManager = require('./CachedManager'); +const GuildEmoji = require('../structures/GuildEmoji'); +const ReactionEmoji = require('../structures/ReactionEmoji'); +const { parseEmoji } = require('../util/Util'); + +/** + * Holds methods to resolve GuildEmojis and stores their cache. + * @extends {CachedManager} + */ +class BaseGuildEmojiManager extends CachedManager { + constructor(client, iterable) { + super(client, GuildEmoji, iterable); + } + + /** + * The cache of GuildEmojis + * @type {Collection} + * @name BaseGuildEmojiManager#cache + */ + + /** + * Data that can be resolved into a GuildEmoji object. This can be: + * * A Snowflake + * * A GuildEmoji object + * * A ReactionEmoji object + * @typedef {Snowflake|GuildEmoji|ReactionEmoji} EmojiResolvable + */ + + /** + * Resolves an EmojiResolvable to an Emoji object. + * @param {EmojiResolvable} emoji The Emoji resolvable to identify + * @returns {?GuildEmoji} + */ + resolve(emoji) { + if (emoji instanceof ReactionEmoji) return super.resolve(emoji.id); + return super.resolve(emoji); + } + + /** + * Resolves an EmojiResolvable to an Emoji id string. + * @param {EmojiResolvable} emoji The Emoji resolvable to identify + * @returns {?Snowflake} + */ + resolveId(emoji) { + if (emoji instanceof ReactionEmoji) return emoji.id; + return super.resolveId(emoji); + } + + /** + * Data that can be resolved to give an emoji identifier. This can be: + * * The unicode representation of an emoji + * * The ``, `<:name:id>`, `a:name:id` or `name:id` emoji identifier string of an emoji + * * An EmojiResolvable + * @typedef {string|EmojiResolvable} EmojiIdentifierResolvable + */ + + /** + * Resolves an EmojiResolvable to an emoji identifier. + * @param {EmojiIdentifierResolvable} emoji The emoji resolvable to resolve + * @returns {?string} + */ + resolveIdentifier(emoji) { + const emojiResolvable = this.resolve(emoji); + if (emojiResolvable) return emojiResolvable.identifier; + if (emoji instanceof ReactionEmoji) return emoji.identifier; + if (typeof emoji === 'string') { + const res = parseEmoji(emoji); + if (res?.name.length) { + emoji = `${res.animated ? 'a:' : ''}${res.name}${res.id ? `:${res.id}` : ''}`; + } + if (!emoji.includes('%')) return encodeURIComponent(emoji); + return emoji; + } + return null; + } +} + +module.exports = BaseGuildEmojiManager; diff --git a/src/managers/BaseManager.js b/src/managers/BaseManager.js new file mode 100644 index 00000000..0651401 --- /dev/null +++ b/src/managers/BaseManager.js @@ -0,0 +1,19 @@ +'use strict'; + +/** + * Manages the API methods of a data model. + * @abstract + */ +class BaseManager { + constructor(client) { + /** + * The client that instantiated this Manager + * @name BaseManager#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + } +} + +module.exports = BaseManager; diff --git a/src/managers/BlockedManager.js b/src/managers/BlockedManager.js new file mode 100644 index 00000000..fac1b7a --- /dev/null +++ b/src/managers/BlockedManager.js @@ -0,0 +1,75 @@ +'use strict'; + +const CachedManager = require('./CachedManager'); +const GuildMember = require('../structures/GuildMember'); +const Message = require('../structures/Message'); +const ThreadMember = require('../structures/ThreadMember'); +const User = require('../structures/User'); + +/** + * Manages API methods for users and stores their cache. + * @extends {CachedManager} + */ +class BlockedManager extends CachedManager { + constructor(client, iterable) { + super(client, User, iterable); + } + + /** + * The cache of this manager + * @type {Collection} + * @name BlockedManager#cache + */ + + /** + * Data that resolves to give a User object. This can be: + * * A User object + * * A Snowflake + * * A Message object (resolves to the message author) + * * A GuildMember object + * * A ThreadMember object + * @typedef {User|Snowflake|Message|GuildMember|ThreadMember} UserResolvable + */ + + /** + * Resolves a {@link UserResolvable} to a {@link User} object. + * @param {UserResolvable} user The UserResolvable to identify + * @returns {?User} + */ + resolve(user) { + if (user instanceof GuildMember || user instanceof ThreadMember) return user.user; + if (user instanceof Message) return user.author; + return super.resolve(user); + } + + /** + * Resolves a {@link UserResolvable} to a {@link User} id. + * @param {UserResolvable} user The UserResolvable to identify + * @returns {?Snowflake} + */ + resolveId(user) { + if (user instanceof ThreadMember) return user.id; + if (user instanceof GuildMember) return user.user.id; + if (user instanceof Message) return user.author.id; + return super.resolveId(user); + } + + /** + * Obtains a user from Discord, or the user cache if it's already available. + * @param {UserResolvable} user The user to fetch + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise} + */ + async fetch(user, { cache = true, force = false } = {}) { + const id = this.resolveId(user); + if (!force) { + const existing = this.cache.get(id); + if (existing && !existing.partial) return existing; + } + + const data = await this.client.api.users(id).get(); + return this._add(data, cache); + } +} + +module.exports = BlockedManager; diff --git a/src/managers/CachedManager.js b/src/managers/CachedManager.js new file mode 100644 index 00000000..1058285 --- /dev/null +++ b/src/managers/CachedManager.js @@ -0,0 +1,50 @@ +'use strict'; + +const DataManager = require('./DataManager'); + +/** + * Manages the API methods of a data model with a mutable cache of instances. + * @extends {DataManager} + * @abstract + */ +class CachedManager extends DataManager { + constructor(client, holds, iterable) { + super(client, holds); + + Object.defineProperty(this, '_cache', { value: this.client.options.makeCache(this.constructor, this.holds) }); + + if (iterable) { + for (const item of iterable) { + this._add(item); + } + } + } + + /** + * The cache of items for this manager. + * @type {Collection} + * @abstract + */ + get cache() { + return this._cache; + } + + _add(data, cache = true, { id, extras = [] } = {}) { + const existing = this.cache.get(id ?? data.id); + if (existing) { + if (cache) { + existing._patch(data); + return existing; + } + const clone = existing._clone(); + clone._patch(data); + return clone; + } + + const entry = this.holds ? new this.holds(this.client, data, ...extras) : data; + if (cache) this.cache.set(id ?? entry.id, entry); + return entry; + } +} + +module.exports = CachedManager; diff --git a/src/managers/CategoryChannelChildManager.js b/src/managers/CategoryChannelChildManager.js new file mode 100644 index 00000000..0280251 --- /dev/null +++ b/src/managers/CategoryChannelChildManager.js @@ -0,0 +1,69 @@ +'use strict'; + +const DataManager = require('./DataManager'); +const GuildChannel = require('../structures/GuildChannel'); + +/** + * Manages API methods for CategoryChannels' children. + * @extends {DataManager} + */ +class CategoryChannelChildManager extends DataManager { + constructor(channel) { + super(channel.client, GuildChannel); + /** + * The category channel this manager belongs to + * @type {CategoryChannel} + */ + this.channel = channel; + } + + /** + * The channels that are a part of this category + * @type {Collection} + * @readonly + */ + get cache() { + return this.guild.channels.cache.filter(c => c.parentId === this.channel.id); + } + + /** + * The guild this manager belongs to + * @type {Guild} + * @readonly + */ + get guild() { + return this.channel.guild; + } + + /** + * Options for creating a channel using {@link CategoryChannel#createChannel}. + * @typedef {Object} CategoryCreateChannelOptions + * @property {ChannelType} [type=ChannelType.GuildText] The type of the new channel. + * @property {string} [topic] The topic for the new channel + * @property {boolean} [nsfw] Whether the new channel is NSFW + * @property {number} [bitrate] Bitrate of the new channel in bits (only voice) + * @property {number} [userLimit] Maximum amount of users allowed in the new channel (only voice) + * @property {OverwriteResolvable[]|Collection} [permissionOverwrites] + * Permission overwrites of the new channel + * @property {number} [position] Position of the new channel + * @property {number} [rateLimitPerUser] The rate limit per user (slowmode) for the new channel in seconds + * @property {string} [rtcRegion] The specific region of the new channel. + * @property {string} [reason] Reason for creating the new channel + */ + + /** + * Creates a new channel within this category. + * You cannot create a channel of type {@link ChannelType.GuildCategory} inside a CategoryChannel. + * @param {string} name The name of the new channel + * @param {CategoryCreateChannelOptions} options Options for creating the new channel + * @returns {Promise} + */ + create(name, options) { + return this.guild.channels.create(name, { + ...options, + parent: this.channel.id, + }); + } +} + +module.exports = CategoryChannelChildManager; diff --git a/src/managers/ChannelManager.js b/src/managers/ChannelManager.js new file mode 100644 index 00000000..9142702 --- /dev/null +++ b/src/managers/ChannelManager.js @@ -0,0 +1,122 @@ +'use strict'; + +const process = require('node:process'); +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { Channel } = require('../structures/Channel'); +const { ThreadChannelTypes } = require('../util/Constants'); +const Events = require('../util/Events'); + +let cacheWarningEmitted = false; + +/** + * A manager of channels belonging to a client + * @extends {CachedManager} + */ +class ChannelManager extends CachedManager { + constructor(client, iterable) { + super(client, Channel, iterable); + const defaultCaching = + this._cache.constructor.name === 'Collection' || + this._cache.maxSize === undefined || + this._cache.maxSize === Infinity; + if (!cacheWarningEmitted && !defaultCaching) { + cacheWarningEmitted = true; + process.emitWarning( + `Overriding the cache handling for ${this.constructor.name} is unsupported and breaks functionality.`, + 'UnsupportedCacheOverwriteWarning', + ); + } + } + + /** + * The cache of Channels + * @type {Collection} + * @name ChannelManager#cache + */ + + _add(data, guild, { cache = true, allowUnknownGuild = false, fromInteraction = false } = {}) { + const existing = this.cache.get(data.id); + if (existing) { + if (cache) existing._patch(data, fromInteraction); + guild?.channels?._add(existing); + if (ThreadChannelTypes.includes(existing.type)) { + existing.parent?.threads?._add(existing); + } + return existing; + } + + const channel = Channel.create(this.client, data, guild, { allowUnknownGuild, fromInteraction }); + + if (!channel) { + this.client.emit(Events.Debug, `Failed to find guild, or unknown type for channel ${data.id} ${data.type}`); + return null; + } + + if (cache && !allowUnknownGuild) this.cache.set(channel.id, channel); + + return channel; + } + + _remove(id) { + const channel = this.cache.get(id); + channel?.guild?.channels.cache.delete(id); + channel?.parent?.threads?.cache.delete(id); + this.cache.delete(id); + } + + /** + * Data that can be resolved to give a Channel object. This can be: + * * A Channel object + * * A Snowflake + * @typedef {Channel|Snowflake} ChannelResolvable + */ + + /** + * Resolves a ChannelResolvable to a Channel object. + * @method resolve + * @memberof ChannelManager + * @instance + * @param {ChannelResolvable} channel The channel resolvable to resolve + * @returns {?Channel} + */ + + /** + * Resolves a ChannelResolvable to a channel id string. + * @method resolveId + * @memberof ChannelManager + * @instance + * @param {ChannelResolvable} channel The channel resolvable to resolve + * @returns {?Snowflake} + */ + + /** + * Options for fetching a channel from Discord + * @typedef {BaseFetchOptions} FetchChannelOptions + * @property {boolean} [allowUnknownGuild=false] Allows the channel to be returned even if the guild is not in cache, + * it will not be cached. Many of the properties and methods on the returned channel will throw errors + */ + + /** + * Obtains a channel from Discord, or the channel cache if it's already available. + * @param {Snowflake} id The channel's id + * @param {FetchChannelOptions} [options] Additional options for this fetch + * @returns {Promise} + * @example + * // Fetch a channel by its id + * client.channels.fetch('222109930545610754') + * .then(channel => console.log(channel.name)) + * .catch(console.error); + */ + async fetch(id, { allowUnknownGuild = false, cache = true, force = false } = {}) { + if (!force) { + const existing = this.cache.get(id); + if (existing && !existing.partial) return existing; + } + + const data = await this.client.api.channels(id).get(); + return this._add(data, null, { cache, allowUnknownGuild }); + } +} + +module.exports = ChannelManager; diff --git a/src/managers/ClientUserSettingManager.js b/src/managers/ClientUserSettingManager.js new file mode 100644 index 00000000..5ef0214 --- /dev/null +++ b/src/managers/ClientUserSettingManager.js @@ -0,0 +1,228 @@ +'use strict'; + +const CachedManager = require('./CachedManager'); +const { default: Collection } = require('@discordjs/collection'); +const { Error } = require('../errors/DJSError'); +/** + * Manages API methods for users and stores their cache. + * @extends {CachedManager} + */ +const localeObject = { + DANISH: 'da', + GERMAN: 'de', + ENGLISH_UK: 'en-GB', + ENGLISH_US: 'en-US', + SPANISH: 'es-ES', + FRENCH: 'fr', + CROATIAN: 'hr', + ITALIAN: 'it', + LITHUANIAN: 'lt', + HUNGARIAN: 'hu', + DUTCH: 'nl', + NORWEGIAN: 'no', + POLISH: 'pl', + BRAZILIAN_PORTUGUESE: 'pt-BR', + ROMANIA_ROMANIAN: 'ro', + FINNISH: 'fi', + SWEDISH: 'sv-SE', + VIETNAMESE: 'vi', + TURKISH: 'tr', + CZECH: 'cs', + GREEK: 'el', + BULGARIAN: 'bg', + RUSSIAN: 'ru', + UKRAINIAN: 'uk', + HINDI: 'hi', + THAI: 'th', + CHINA_CHINESE: 'zh-CN', + JAPANESE: 'ja', + TAIWAN_CHINESE: 'zh-TW', + KOREAN: 'ko', +}; +class ClientUserSettingManager extends CachedManager { + constructor(client, iterable) { + super(client); + // Raw data + this.rawSetting = {}; + // Language + this.locale = null; + // Setting => ACTIVITY SETTINGS => Activity Status => Display current activity as a status message + this.showCurrentGame = null; + // Setting => APP SETTINGS => Accessibility => Automatically play GIFs when Discord is focused. + this.autoplayGIF = null; + // Setting => APP SETTINGS => Appearance => Message Display => Compact Mode [OK] + this.compactMode = null; + // Setting => APP SETTINGS => Text & Images => Emoji => Convert Emoticons + this.convertEmoticons = null; + // Setting => APP SETTINGS => Accessibility => Text-to-speech => Allow playback + this.allowTTS = null; + // Setting => APP SETTINGS => Appearance => Theme [OK] + this.theme = ''; + // Setting => APP SETTINGS => Accessibility => Play Animated Emojis + this.animatedEmojis = null; + // Setting => APP SETTINGS => Text & Images => Emoji => Show emoji reactions + this.showEmojiReactions = null; + // Custom Stauts [It's not working now] + this.customStatus = null; + // Guild folder and position + this.guildMetadata = new Collection(); + } + _patch(data) { + this.rawSetting = data; + if ('locale' in data) { + this.locale = data.locale; + } + if ('show_current_game' in data) { + this.showCurrentGame = data.show_current_game; + } + if ('gif_auto_play' in data) { + this.autoplayGIF = data.gif_auto_play; + } + if ('message_display_compact' in data) { + this.compactMode = data.message_display_compact; + } + if ('convert_emoticons' in data) { + this.convertEmoticons = data.convert_emoticons; + } + if ('enable_tts_command' in data) { + this.allowTTS = data.enable_tts_command; + } + if ('theme' in data) { + this.theme = data.theme; + } + if ('animate_emoji' in data) { + this.animatedEmojis = data.animate_emoji; + } + if ('render_reactions' in data) { + this.showEmojiReactions = data.render_reactions; + } + if ('custom_status' in data) { + this.customStatus = data.custom_status; + this.customStatus.status = data.status; + } + if ('guild_folders' in data) { + const data_ = data.guild_positions.map((guildId, i) => { + // Find folder + const folderIndex = data.guild_folders.findIndex((obj) => + obj.guild_ids.includes(guildId), + ); + const metadata = { + guildId: guildId, + guildIndex: i, + folderId: data.guild_folders[folderIndex]?.id, + folderIndex, + folderName: data.guild_folders[folderIndex]?.name, + folderColor: data.guild_folders[folderIndex]?.color, + folderGuilds: data.guild_folders[folderIndex]?.guild_ids, + }; + return [guildId, metadata]; + }); + this.guildMetadata = new Collection(data_); + } + } + async fetch() { + if (this.client.bot) throw new Error('INVALID_BOT_METHOD'); + try { + const data = await this.client.api.users('@me').settings.get(); + this._patch(data); + return this; + } catch (e) { + throw e; + } + } + /** + * Edit data + * @param {Object} data Data to edit + * @private + */ + async edit(data) { + if (this.client.bot) throw new Error('INVALID_BOT_METHOD'); + try { + const res = await this.client.api.users('@me').settings.patch({ data }); + this._patch(res); + return this; + } catch (e) { + throw e; + } + } + /** + * Set compact mode + * @param {Boolean | null} value Compact mode enable or disable + * @returns {Boolean} + */ + async setDisplayCompactMode(value) { + if (this.client.bot) throw new Error('INVALID_BOT_METHOD'); + if (!value) value = !this.compactMode; + if (value !== this.compactMode) { + await this.edit({ message_display_compact: value }); + } + return this.compactMode; + } + /** + * Discord Theme + * @param {null |dark |light} value Theme to set + * @returns {theme} + */ + async setTheme(value) { + if (this.client.bot) throw new Error('INVALID_BOT_METHOD'); + const validValues = ['dark', 'light']; + if (!validValues.includes(value)) { + value == validValues[0] + ? (value = validValues[1]) + : (value = validValues[0]); + } + if (value !== this.theme) { + await this.edit({ theme: value }); + } + return this.theme; + } + /** + * Locale Setting, must be one of: + * * `DANISH` + * * `GERMAN` + * * `ENGLISH_UK` + * * `ENGLISH_US` + * * `SPANISH` + * * `FRENCH` + * * `CROATIAN` + * * `ITALIAN` + * * `LITHUANIAN` + * * `HUNGARIAN` + * * `DUTCH` + * * `NORWEGIAN` + * * `POLISH` + * * `BRAZILIAN_PORTUGUESE` + * * `ROMANIA_ROMANIAN` + * * `FINNISH` + * * `SWEDISH` + * * `VIETNAMESE` + * * `TURKISH` + * * `CZECH` + * * `GREEK` + * * `BULGARIAN` + * * `RUSSIAN` + * * `UKRAINIAN` + * * `HINDI` + * * `THAI` + * * `CHINA_CHINESE` + * * `JAPANESE` + * * `TAIWAN_CHINESE` + * * `KOREAN` + * @typedef {string} LocaleStrings + */ + /** + * + * @param {LocaleStrings} value + * @returns {locale} + */ + async setLocale(value) { + if (this.client.bot) throw new Error('INVALID_BOT_METHOD'); + if (!localeObject[value]) throw new Error('INVALID_LOCALE'); + if (localeObject[value] !== this.locale) { + await this.edit({ locale: localeObject[value] }); + } + return this.locale; + } +} + +module.exports = ClientUserSettingManager; diff --git a/src/managers/DataManager.js b/src/managers/DataManager.js new file mode 100644 index 00000000..6a5c14c --- /dev/null +++ b/src/managers/DataManager.js @@ -0,0 +1,61 @@ +'use strict'; + +const BaseManager = require('./BaseManager'); +const { Error } = require('../errors'); + +/** + * Manages the API methods of a data model along with a collection of instances. + * @extends {BaseManager} + * @abstract + */ +class DataManager extends BaseManager { + constructor(client, holds) { + super(client); + + /** + * The data structure belonging to this manager. + * @name DataManager#holds + * @type {Function} + * @private + * @readonly + */ + Object.defineProperty(this, 'holds', { value: holds }); + } + + /** + * The cache of items for this manager. + * @type {Collection} + * @abstract + */ + get cache() { + throw new Error('NOT_IMPLEMENTED', 'get cache', this.constructor.name); + } + + /** + * Resolves a data entry to a data Object. + * @param {string|Object} idOrInstance The id or instance of something in this Manager + * @returns {?Object} An instance from this Manager + */ + resolve(idOrInstance) { + if (idOrInstance instanceof this.holds) return idOrInstance; + if (typeof idOrInstance === 'string') return this.cache.get(idOrInstance) ?? null; + return null; + } + + /** + * Resolves a data entry to an instance id. + * @param {string|Object} idOrInstance The id or instance of something in this Manager + * @returns {?Snowflake} + */ + resolveId(idOrInstance) { + if (idOrInstance instanceof this.holds) return idOrInstance.id; + if (typeof idOrInstance === 'string') return idOrInstance; + return null; + } + + valueOf() { + return this.cache; + } +} + +module.exports = DataManager; diff --git a/src/managers/FriendsManager.js b/src/managers/FriendsManager.js new file mode 100644 index 00000000..1d45dcb --- /dev/null +++ b/src/managers/FriendsManager.js @@ -0,0 +1,75 @@ +'use strict'; + +const CachedManager = require('./CachedManager'); +const GuildMember = require('../structures/GuildMember'); +const Message = require('../structures/Message'); +const ThreadMember = require('../structures/ThreadMember'); +const User = require('../structures/User'); + +/** + * Manages API methods for users and stores their cache. + * @extends {CachedManager} + */ +class FriendsManager extends CachedManager { + constructor(client, iterable) { + super(client, User, iterable); + } + + /** + * The cache of this manager + * @type {Collection} + * @name FriendsManager#cache + */ + + /** + * Data that resolves to give a User object. This can be: + * * A User object + * * A Snowflake + * * A Message object (resolves to the message author) + * * A GuildMember object + * * A ThreadMember object + * @typedef {User|Snowflake|Message|GuildMember|ThreadMember} UserResolvable + */ + + /** + * Resolves a {@link UserResolvable} to a {@link User} object. + * @param {UserResolvable} user The UserResolvable to identify + * @returns {?User} + */ + resolve(user) { + if (user instanceof GuildMember || user instanceof ThreadMember) return user.user; + if (user instanceof Message) return user.author; + return super.resolve(user); + } + + /** + * Resolves a {@link UserResolvable} to a {@link User} id. + * @param {UserResolvable} user The UserResolvable to identify + * @returns {?Snowflake} + */ + resolveId(user) { + if (user instanceof ThreadMember) return user.id; + if (user instanceof GuildMember) return user.user.id; + if (user instanceof Message) return user.author.id; + return super.resolveId(user); + } + + /** + * Obtains a user from Discord, or the user cache if it's already available. + * @param {UserResolvable} user The user to fetch + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise} + */ + async fetch(user, { cache = true, force = false } = {}) { + const id = this.resolveId(user); + if (!force) { + const existing = this.cache.get(id); + if (existing && !existing.partial) return existing; + } + + const data = await this.client.api.users(id).get(); + return this._add(data, cache); + } +} + +module.exports = FriendsManager; diff --git a/src/managers/GuildApplicationCommandManager.js b/src/managers/GuildApplicationCommandManager.js new file mode 100644 index 00000000..97fea5e --- /dev/null +++ b/src/managers/GuildApplicationCommandManager.js @@ -0,0 +1,28 @@ +'use strict'; + +const ApplicationCommandManager = require('./ApplicationCommandManager'); +const ApplicationCommandPermissionsManager = require('./ApplicationCommandPermissionsManager'); + +/** + * An extension for guild-specific application commands. + * @extends {ApplicationCommandManager} + */ +class GuildApplicationCommandManager extends ApplicationCommandManager { + constructor(guild, iterable) { + super(guild.client, iterable); + + /** + * The guild that this manager belongs to + * @type {Guild} + */ + this.guild = guild; + + /** + * The manager for permissions of arbitrary commands on this guild + * @type {ApplicationCommandPermissionsManager} + */ + this.permissions = new ApplicationCommandPermissionsManager(this); + } +} + +module.exports = GuildApplicationCommandManager; diff --git a/src/managers/GuildBanManager.js b/src/managers/GuildBanManager.js new file mode 100644 index 00000000..e882a56 --- /dev/null +++ b/src/managers/GuildBanManager.js @@ -0,0 +1,175 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { TypeError, Error } = require('../errors'); +const GuildBan = require('../structures/GuildBan'); +const { GuildMember } = require('../structures/GuildMember'); + +/** + * Manages API methods for GuildBans and stores their cache. + * @extends {CachedManager} + */ +class GuildBanManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, GuildBan, iterable); + + /** + * The guild this Manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The cache of this Manager + * @type {Collection} + * @name GuildBanManager#cache + */ + + _add(data, cache) { + return super._add(data, cache, { id: data.user.id, extras: [this.guild] }); + } + + /** + * Data that resolves to give a GuildBan object. This can be: + * * A GuildBan object + * * A User resolvable + * @typedef {GuildBan|UserResolvable} GuildBanResolvable + */ + + /** + * Resolves a GuildBanResolvable to a GuildBan object. + * @param {GuildBanResolvable} ban The ban that is in the guild + * @returns {?GuildBan} + */ + resolve(ban) { + return super.resolve(ban) ?? super.resolve(this.client.users.resolveId(ban)); + } + + /** + * Options used to fetch a single ban from a guild. + * @typedef {BaseFetchOptions} FetchBanOptions + * @property {UserResolvable} user The ban to fetch + */ + + /** + * Options used to fetch all bans from a guild. + * @typedef {Object} FetchBansOptions + * @property {boolean} cache Whether or not to cache the fetched bans + */ + + /** + * Fetches ban(s) from Discord. + * @param {UserResolvable|FetchBanOptions|FetchBansOptions} [options] Options for fetching guild ban(s) + * @returns {Promise>} + * @example + * // Fetch all bans from a guild + * guild.bans.fetch() + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch all bans from a guild without caching + * guild.bans.fetch({ cache: false }) + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch a single ban + * guild.bans.fetch('351871113346809860') + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch a single ban without checking cache + * guild.bans.fetch({ user, force: true }) + * .then(console.log) + * .catch(console.error) + * @example + * // Fetch a single ban without caching + * guild.bans.fetch({ user, cache: false }) + * .then(console.log) + * .catch(console.error); + */ + fetch(options) { + if (!options) return this._fetchMany(); + const user = this.client.users.resolveId(options); + if (user) return this._fetchSingle({ user, cache: true }); + options.user &&= this.client.users.resolveId(options.user); + if (!options.user) { + if ('cache' in options) return this._fetchMany(options.cache); + return Promise.reject(new Error('FETCH_BAN_RESOLVE_ID')); + } + return this._fetchSingle(options); + } + + async _fetchSingle({ user, cache, force = false }) { + if (!force) { + const existing = this.cache.get(user); + if (existing && !existing.partial) return existing; + } + + const data = await this.client.api.guilds(this.guild.id).bans(user).get(); + return this._add(data, cache); + } + + async _fetchMany(cache) { + const data = await this.client.api.guilds(this.guild.id).bans.get(); + return data.reduce((col, ban) => col.set(ban.user.id, this._add(ban, cache)), new Collection()); + } + + /** + * Options used to ban a user from a guild. + * @typedef {Object} BanOptions + * @property {number} [deleteMessageDays] Number of days of messages to delete, must be between 0 and 7, inclusive + * @property {string} [reason] The reason for the ban + */ + + /** + * Bans a user from the guild. + * @param {UserResolvable} user The user to ban + * @param {BanOptions} [options] Options for the ban + * @returns {Promise} Result object will be resolved as specifically as possible. + * If the GuildMember cannot be resolved, the User will instead be attempted to be resolved. If that also cannot + * be resolved, the user id will be the result. + * @example + * // Ban a user by id (or with a user/guild member object) + * guild.bans.create('84484653687267328') + * .then(banInfo => console.log(`Banned ${banInfo.user?.tag ?? banInfo.tag ?? banInfo}`)) + * .catch(console.error); + */ + async create(user, options = {}) { + if (typeof options !== 'object') throw new TypeError('INVALID_TYPE', 'options', 'object', true); + const id = this.client.users.resolveId(user); + if (!id) throw new Error('BAN_RESOLVE_ID', true); + await this.client.api.guilds(this.guild.id).bans(id).put({ + body: { delete_message_days: options.deleteMessageDays }, + reason: options.reason, + }); + if (user instanceof GuildMember) return user; + const _user = this.client.users.resolve(id); + if (_user) { + return this.guild.members.resolve(_user) ?? _user; + } + return id; + } + + /** + * Unbans a user from the guild. + * @param {UserResolvable} user The user to unban + * @param {string} [reason] Reason for unbanning user + * @returns {Promise} + * @example + * // Unban a user by id (or with a user/guild member object) + * guild.bans.remove('84484653687267328') + * .then(user => console.log(`Unbanned ${user.username} from ${guild.name}`)) + * .catch(console.error); + */ + async remove(user, reason) { + const id = this.client.users.resolveId(user); + if (!id) throw new Error('BAN_RESOLVE_ID'); + await this.client.api.guilds(this.guild.id).bans(id).delete({ reason }); + return this.client.users.resolve(user); + } +} + +module.exports = GuildBanManager; diff --git a/src/managers/GuildChannelManager.js b/src/managers/GuildChannelManager.js new file mode 100644 index 00000000..09a4f39 --- /dev/null +++ b/src/managers/GuildChannelManager.js @@ -0,0 +1,443 @@ +'use strict'; + +const process = require('node:process'); +const { Collection } = require('@discordjs/collection'); +const { ChannelType, Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const ThreadManager = require('./ThreadManager'); +const { Error, TypeError } = require('../errors'); +const GuildChannel = require('../structures/GuildChannel'); +const PermissionOverwrites = require('../structures/PermissionOverwrites'); +const ThreadChannel = require('../structures/ThreadChannel'); +const Webhook = require('../structures/Webhook'); +const { ThreadChannelTypes } = require('../util/Constants'); +const DataResolver = require('../util/DataResolver'); +const Util = require('../util/Util'); + +let cacheWarningEmitted = false; +let storeChannelDeprecationEmitted = false; + +/** + * Manages API methods for GuildChannels and stores their cache. + * @extends {CachedManager} + */ +class GuildChannelManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, GuildChannel, iterable); + const defaultCaching = + this._cache.constructor.name === 'Collection' || + this._cache.maxSize === undefined || + this._cache.maxSize === Infinity; + if (!cacheWarningEmitted && !defaultCaching) { + cacheWarningEmitted = true; + process.emitWarning( + `Overriding the cache handling for ${this.constructor.name} is unsupported and breaks functionality.`, + 'UnsupportedCacheOverwriteWarning', + ); + } + + /** + * The guild this Manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The number of channels in this managers cache excluding thread channels + * that do not count towards a guild's maximum channels restriction. + * @type {number} + * @readonly + */ + get channelCountWithoutThreads() { + return this.cache.reduce((acc, channel) => { + if (ThreadChannelTypes.includes(channel.type)) return acc; + return ++acc; + }, 0); + } + + /** + * The cache of this Manager + * @type {Collection} + * @name GuildChannelManager#cache + */ + + _add(channel) { + const existing = this.cache.get(channel.id); + if (existing) return existing; + this.cache.set(channel.id, channel); + return channel; + } + + /** + * Data that can be resolved to give a Guild Channel object. This can be: + * * A GuildChannel object + * * A ThreadChannel object + * * A Snowflake + * @typedef {GuildChannel|ThreadChannel|Snowflake} GuildChannelResolvable + */ + + /** + * Resolves a GuildChannelResolvable to a Channel object. + * @param {GuildChannelResolvable} channel The GuildChannel resolvable to resolve + * @returns {?(GuildChannel|ThreadChannel)} + */ + resolve(channel) { + if (channel instanceof ThreadChannel) return super.resolve(channel.id); + return super.resolve(channel); + } + + /** + * Resolves a GuildChannelResolvable to a channel id. + * @param {GuildChannelResolvable} channel The GuildChannel resolvable to resolve + * @returns {?Snowflake} + */ + resolveId(channel) { + if (channel instanceof ThreadChannel) return super.resolveId(channel.id); + return super.resolveId(channel); + } + + /** + * Options used to create a new channel in a guild. + * @typedef {CategoryCreateChannelOptions} GuildChannelCreateOptions + * @property {CategoryChannelResolvable} [parent] Parent of the new channel + */ + + /** + * Creates a new channel in the guild. + * @param {string} name The name of the new channel + * @param {GuildChannelCreateOptions} [options={}] Options for creating the new channel + * @returns {Promise} + * @example + * // Create a new text channel + * guild.channels.create('new-general', { reason: 'Needed a cool new channel' }) + * .then(console.log) + * .catch(console.error); + * @example + * // Create a new channel with permission overwrites + * guild.channels.create('new-voice', { + * type: ChannelType.GuildVoice, + * permissionOverwrites: [ + * { + * id: message.author.id, + * deny: [PermissionFlagsBits.ViewChannel], + * }, + * ], + * }) + */ + async create( + name, + { + type, + topic, + nsfw, + bitrate, + userLimit, + parent, + permissionOverwrites, + position, + rateLimitPerUser, + rtcRegion, + reason, + } = {}, + ) { + parent &&= this.client.channels.resolveId(parent); + permissionOverwrites &&= permissionOverwrites.map(o => PermissionOverwrites.resolve(o, this.guild)); + + if (type === ChannelType.GuildStore && !storeChannelDeprecationEmitted) { + storeChannelDeprecationEmitted = true; + process.emitWarning( + // eslint-disable-next-line max-len + 'Creating store channels is deprecated by Discord and will stop working in March 2022. Check the docs for more info.', + 'DeprecationWarning', + ); + } + + const data = await this.client.api.guilds(this.guild.id).channels.post({ + body: { + name, + topic, + type, + nsfw, + bitrate, + user_limit: userLimit, + parent_id: parent, + position, + permission_overwrites: permissionOverwrites, + rate_limit_per_user: rateLimitPerUser, + rtc_region: rtcRegion, + }, + reason, + }); + return this.client.actions.ChannelCreate.handle(data).channel; + } + + /** + * Creates a webhook for the channel. + * @param {GuildChannelResolvable} channel The channel to create the webhook for + * @param {string} name The name of the webhook + * @param {ChannelWebhookCreateOptions} [options] Options for creating the webhook + * @returns {Promise} Returns the created Webhook + * @example + * // Create a webhook for the current channel + * guild.channels.createWebhook('222197033908436994', 'Snek', { + * avatar: 'https://i.imgur.com/mI8XcpG.jpg', + * reason: 'Needed a cool new Webhook' + * }) + * .then(console.log) + * .catch(console.error) + */ + async createWebhook(channel, name, { avatar, reason } = {}) { + const id = this.resolveId(channel); + if (!id) throw new TypeError('INVALID_TYPE', 'channel', 'GuildChannelResolvable'); + if (typeof avatar === 'string' && !avatar.startsWith('data:')) { + avatar = await DataResolver.resolveImage(avatar); + } + const data = await this.client.api.channels(id).webhooks.post({ + body: { + name, + avatar, + }, + reason, + }); + return new Webhook(this.client, data); + } + + /** + * The data for a guild channel. + * @typedef {Object} ChannelData + * @property {string} [name] The name of the channel + * @property {ChannelType} [type] The type of the channel (only conversion between text and news is supported) + * @property {number} [position] The position of the channel + * @property {string} [topic] The topic of the text channel + * @property {boolean} [nsfw] Whether the channel is NSFW + * @property {number} [bitrate] The bitrate of the voice channel + * @property {number} [userLimit] The user limit of the voice channel + * @property {?CategoryChannelResolvable} [parent] The parent of the channel + * @property {boolean} [lockPermissions] + * Lock the permissions of the channel to what the parent's permissions are + * @property {OverwriteResolvable[]|Collection} [permissionOverwrites] + * Permission overwrites for the channel + * @property {number} [rateLimitPerUser] The rate limit per user (slowmode) for the channel in seconds + * @property {ThreadAutoArchiveDuration} [defaultAutoArchiveDuration] + * The default auto archive duration for all new threads in this channel + * @property {?string} [rtcRegion] The RTC region of the channel + */ + + /** + * Edits the channel. + * @param {GuildChannelResolvable} channel The channel to edit + * @param {ChannelData} data The new data for the channel + * @param {string} [reason] Reason for editing this channel + * @returns {Promise} + * @example + * // Edit a channel + * guild.channels.edit('222197033908436994', { name: 'new-channel' }) + * .then(console.log) + * .catch(console.error); + */ + async edit(channel, data, reason) { + channel = this.resolve(channel); + if (!channel) throw new TypeError('INVALID_TYPE', 'channel', 'GuildChannelResolvable'); + + const parent = data.parent && this.client.channels.resolveId(data.parent); + + if (typeof data.position !== 'undefined') await this.setPosition(channel, data.position, { reason }); + + let permission_overwrites = data.permissionOverwrites?.map(o => PermissionOverwrites.resolve(o, this.guild)); + + if (data.lockPermissions) { + if (parent) { + const newParent = this.guild.channels.resolve(parent); + if (newParent?.type === ChannelType.GuildCategory) { + permission_overwrites = newParent.permissionOverwrites.cache.map(o => + PermissionOverwrites.resolve(o, this.guild), + ); + } + } else if (channel.parent) { + permission_overwrites = this.parent.permissionOverwrites.cache.map(o => + PermissionOverwrites.resolve(o, this.guild), + ); + } + } + + const newData = await this.client.api.channels(channel.id).patch({ + body: { + name: (data.name ?? channel.name).trim(), + type: data.type, + topic: data.topic, + nsfw: data.nsfw, + bitrate: data.bitrate ?? channel.bitrate, + user_limit: data.userLimit ?? channel.userLimit, + rtc_region: data.rtcRegion ?? channel.rtcRegion, + parent_id: parent, + lock_permissions: data.lockPermissions, + rate_limit_per_user: data.rateLimitPerUser, + default_auto_archive_duration: data.defaultAutoArchiveDuration, + permission_overwrites, + }, + reason, + }) + + return this.client.actions.ChannelUpdate.handle(newData).updated; + } + + /** + * Sets a new position for the guild channel. + * @param {GuildChannelResolvable} channel The channel to set the position for + * @param {number} position The new position for the guild channel + * @param {SetChannelPositionOptions} [options] Options for setting position + * @returns {Promise} + * @example + * // Set a new channel position + * guild.channels.setPosition('222078374472843266', 2) + * .then(newChannel => console.log(`Channel's new position is ${newChannel.position}`)) + * .catch(console.error); + */ + async setPosition(channel, position, { relative, reason } = {}) { + channel = this.resolve(channel); + if (!channel) throw new TypeError('INVALID_TYPE', 'channel', 'GuildChannelResolvable'); + const updatedChannels = await Util.setPosition( + channel, + position, + relative, + this.guild._sortedChannels(channel), + this.client, + Routes.guildChannels(this.guild.id), + reason, + ); + + this.client.actions.GuildChannelsPositionUpdate.handle({ + guild_id: this.guild.id, + channels: updatedChannels, + }); + return channel; + } + + /** + * Obtains one or more guild channels from Discord, or the channel cache if they're already available. + * @param {Snowflake} [id] The channel's id + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise>} + * @example + * // Fetch all channels from the guild (excluding threads) + * message.guild.channels.fetch() + * .then(channels => console.log(`There are ${channels.size} channels.`)) + * .catch(console.error); + * @example + * // Fetch a single channel + * message.guild.channels.fetch('222197033908436994') + * .then(channel => console.log(`The channel name is: ${channel.name}`)) + * .catch(console.error); + */ + async fetch(id, { cache = true, force = false } = {}) { + if (id && !force) { + const existing = this.cache.get(id); + if (existing) return existing; + } + + if (id) { + const data = await this.client.api.channels(id).get(); + // Since this is the guild manager, throw if on a different guild + if (this.guild.id !== data.guild_id) throw new Error('GUILD_CHANNEL_UNOWNED'); + return this.client.channels._add(data, this.guild, { cache }); + } + + const data = await this.client.api.guilds(this.guild.id).channels.get(); + const channels = new Collection(); + for (const channel of data) channels.set(channel.id, this.client.channels._add(channel, this.guild, { cache })); + return channels; + } + + /** + * Fetches all webhooks for the channel. + * @param {GuildChannelResolvable} channel The channel to fetch webhooks for + * @returns {Promise>} + * @example + * // Fetch webhooks + * guild.channels.fetchWebhooks('769862166131245066') + * .then(hooks => console.log(`This channel has ${hooks.size} hooks`)) + * .catch(console.error); + */ + async fetchWebhooks(channel) { + const id = this.resolveId(channel); + if (!id) throw new TypeError('INVALID_TYPE', 'channel', 'GuildChannelResolvable'); + const data = await this.client.api.channels(id).webhooks.get(); + return data.reduce((hooks, hook) => hooks.set(hook.id, new Webhook(this.client, hook)), new Collection()); + } + + /** + * Data that can be resolved to give a Category Channel object. This can be: + * * A CategoryChannel object + * * A Snowflake + * @typedef {CategoryChannel|Snowflake} CategoryChannelResolvable + */ + + /** + * The data needed for updating a channel's position. + * @typedef {Object} ChannelPosition + * @property {GuildChannel|Snowflake} channel Channel to update + * @property {number} [position] New position for the channel + * @property {CategoryChannelResolvable} [parent] Parent channel for this channel + * @property {boolean} [lockPermissions] If the overwrites should be locked to the parents overwrites + */ + + /** + * Batch-updates the guild's channels' positions. + * Only one channel's parent can be changed at a time + * @param {ChannelPosition[]} channelPositions Channel positions to update + * @returns {Promise} + * @example + * guild.channels.setPositions([{ channel: channelId, position: newChannelIndex }]) + * .then(guild => console.log(`Updated channel positions for ${guild}`)) + * .catch(console.error); + */ + async setPositions(channelPositions) { + channelPositions = channelPositions.map(r => ({ + id: this.client.channels.resolveId(r.channel), + position: r.position, + lock_permissions: r.lockPermissions, + parent_id: typeof r.parent !== 'undefined' ? this.channels.resolveId(r.parent) : undefined, + })); + + await this.client.api.guilds(this.guild.id).channels.post({ body: channelPositions }); + return this.client.actions.GuildChannelsPositionUpdate.handle({ + guild_id: this.guild.id, + channels: channelPositions, + }).guild; + } + + /** + * Obtains all active thread channels in the guild from Discord + * @param {boolean} [cache=true] Whether to cache the fetched data + * @returns {Promise} + * @example + * // Fetch all threads from the guild + * message.guild.channels.fetchActiveThreads() + * .then(fetched => console.log(`There are ${fetched.threads.size} threads.`)) + * .catch(console.error); + */ + async fetchActiveThreads(cache = true) { + const raw = await this.client.api.guilds(this.guild.id).threads.active.get(); + return ThreadManager._mapThreads(raw, this.client, { guild: this.guild, cache }); + } + + /** + * Deletes the channel. + * @param {GuildChannelResolvable} channel The channel to delete + * @param {string} [reason] Reason for deleting this channel + * @returns {Promise} + * @example + * // Delete the channel + * guild.channels.delete('858850993013260338', 'making room for new channels') + * .then(console.log) + * .catch(console.error); + */ + async delete(channel, reason) { + const id = this.resolveId(channel); + if (!id) throw new TypeError('INVALID_TYPE', 'channel', 'GuildChannelResolvable'); + await this.client.api.channels(id).delete({ reason }); + } +} + +module.exports = GuildChannelManager; diff --git a/src/managers/GuildEmojiManager.js b/src/managers/GuildEmojiManager.js new file mode 100644 index 00000000..1935d03 --- /dev/null +++ b/src/managers/GuildEmojiManager.js @@ -0,0 +1,168 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes, PermissionFlagsBits } = require('discord-api-types/v9'); +const BaseGuildEmojiManager = require('./BaseGuildEmojiManager'); +const { Error, TypeError } = require('../errors'); +const DataResolver = require('../util/DataResolver'); + +/** + * Manages API methods for GuildEmojis and stores their cache. + * @extends {BaseGuildEmojiManager} + */ +class GuildEmojiManager extends BaseGuildEmojiManager { + constructor(guild, iterable) { + super(guild.client, iterable); + + /** + * The guild this manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + _add(data, cache) { + return super._add(data, cache, { extras: [this.guild] }); + } + + /** + * Options used for creating an emoji in a guild. + * @typedef {Object} GuildEmojiCreateOptions + * @property {Collection|RoleResolvable[]} [roles] The roles to limit the emoji to + * @property {string} [reason] The reason for creating the emoji + */ + + /** + * Creates a new custom emoji in the guild. + * @param {BufferResolvable|Base64Resolvable} attachment The image for the emoji + * @param {string} name The name for the emoji + * @param {GuildEmojiCreateOptions} [options] Options for creating the emoji + * @returns {Promise} The created emoji + * @example + * // Create a new emoji from a URL + * guild.emojis.create('https://i.imgur.com/w3duR07.png', 'rip') + * .then(emoji => console.log(`Created new emoji with name ${emoji.name}!`)) + * .catch(console.error); + * @example + * // Create a new emoji from a file on your computer + * guild.emojis.create('./memes/banana.png', 'banana') + * .then(emoji => console.log(`Created new emoji with name ${emoji.name}!`)) + * .catch(console.error); + */ + async create(attachment, name, { roles, reason } = {}) { + attachment = await DataResolver.resolveImage(attachment); + if (!attachment) throw new TypeError('REQ_RESOURCE_TYPE'); + + const body = { image: attachment, name }; + if (roles) { + if (!Array.isArray(roles) && !(roles instanceof Collection)) { + throw new TypeError('INVALID_TYPE', 'options.roles', 'Array or Collection of Roles or Snowflakes', true); + } + body.roles = []; + for (const role of roles.values()) { + const resolvedRole = this.guild.roles.resolveId(role); + if (!resolvedRole) throw new TypeError('INVALID_ELEMENT', 'Array or Collection', 'options.roles', role); + body.roles.push(resolvedRole); + } + } + + const emoji = await this.client.api.guilds(this.guild.id).emojis.post({ body, reason }); + return this.client.actions.GuildEmojiCreate.handle(this.guild, emoji).emoji; + } + + /** + * Obtains one or more emojis from Discord, or the emoji cache if they're already available. + * @param {Snowflake} [id] The emoji's id + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise>} + * @example + * // Fetch all emojis from the guild + * message.guild.emojis.fetch() + * .then(emojis => console.log(`There are ${emojis.size} emojis.`)) + * .catch(console.error); + * @example + * // Fetch a single emoji + * message.guild.emojis.fetch('222078108977594368') + * .then(emoji => console.log(`The emoji name is: ${emoji.name}`)) + * .catch(console.error); + */ + async fetch(id, { cache = true, force = false } = {}) { + if (id) { + if (!force) { + const existing = this.cache.get(id); + if (existing) return existing; + } + const emoji = await this.client.api.guilds(this.guild.id).emojis(id).get(); + return this._add(emoji, cache); + } + + const data = await this.client.api.guilds(this.guild.id).emojis.get(); + const emojis = new Collection(); + for (const emoji of data) emojis.set(emoji.id, this._add(emoji, cache)); + return emojis; + } + + /** + * Deletes an emoji. + * @param {EmojiResolvable} emoji The Emoji resolvable to delete + * @param {string} [reason] Reason for deleting the emoji + * @returns {Promise} + */ + async delete(emoji, reason) { + const id = this.resolveId(emoji); + if (!id) throw new TypeError('INVALID_TYPE', 'emoji', 'EmojiResolvable', true); + await this.client.api.guilds(this.guild.id).emojis(id).delete({ reason }); + } + + /** + * Edits an emoji. + * @param {EmojiResolvable} emoji The Emoji resolvable to edit + * @param {GuildEmojiEditData} data The new data for the emoji + * @param {string} [reason] Reason for editing this emoji + * @returns {Promise} + */ + async edit(emoji, data, reason) { + const id = this.resolveId(emoji); + if (!id) throw new TypeError('INVALID_TYPE', 'emoji', 'EmojiResolvable', true); + const roles = data.roles?.map(r => this.guild.roles.resolveId(r)); + const newData = await this.client.api.guilds(this.guild.id).emojis(id).patch({ + body: { + name: data.name, + roles, + }, + reason, + }) + const existing = this.cache.get(id); + if (existing) { + const clone = existing._clone(); + clone._patch(newData); + return clone; + } + return this._add(newData); + } + + /** + * Fetches the author for this emoji + * @param {EmojiResolvable} emoji The emoji to fetch the author of + * @returns {Promise} + */ + async fetchAuthor(emoji) { + emoji = this.resolve(emoji); + if (!emoji) throw new TypeError('INVALID_TYPE', 'emoji', 'EmojiResolvable', true); + if (emoji.managed) { + throw new Error('EMOJI_MANAGED'); + } + + const { me } = this.guild; + if (!me) throw new Error('GUILD_UNCACHED_ME'); + if (!me.permissions.has(PermissionFlagsBits.ManageEmojisAndStickers)) { + throw new Error('MISSING_MANAGE_EMOJIS_AND_STICKERS_PERMISSION', this.guild); + } + + const data = await this.client.api.guilds(this.guild.id).emojis(emoji.id).get(); + emoji._patch(data); + return emoji.author; + } +} + +module.exports = GuildEmojiManager; diff --git a/src/managers/GuildEmojiRoleManager.js b/src/managers/GuildEmojiRoleManager.js new file mode 100644 index 00000000..f8da58d --- /dev/null +++ b/src/managers/GuildEmojiRoleManager.js @@ -0,0 +1,118 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const DataManager = require('./DataManager'); +const { TypeError } = require('../errors'); +const { Role } = require('../structures/Role'); + +/** + * Manages API methods for roles belonging to emojis and stores their cache. + * @extends {DataManager} + */ +class GuildEmojiRoleManager extends DataManager { + constructor(emoji) { + super(emoji.client, Role); + + /** + * The emoji belonging to this manager + * @type {GuildEmoji} + */ + this.emoji = emoji; + /** + * The guild belonging to this manager + * @type {Guild} + */ + this.guild = emoji.guild; + } + + /** + * The cache of roles belonging to this emoji + * @type {Collection} + * @readonly + */ + get cache() { + return this.guild.roles.cache.filter(role => this.emoji._roles.includes(role.id)); + } + + /** + * Adds a role (or multiple roles) to the list of roles that can use this emoji. + * @param {RoleResolvable|RoleResolvable[]|Collection} roleOrRoles The role or roles to add + * @returns {Promise} + */ + add(roleOrRoles) { + if (!Array.isArray(roleOrRoles) && !(roleOrRoles instanceof Collection)) roleOrRoles = [roleOrRoles]; + + const resolvedRoles = []; + for (const role of roleOrRoles.values()) { + const resolvedRole = this.guild.roles.resolveId(role); + if (!resolvedRole) { + return Promise.reject(new TypeError('INVALID_ELEMENT', 'Array or Collection', 'roles', role)); + } + resolvedRoles.push(resolvedRole); + } + + const newRoles = [...new Set(resolvedRoles.concat(...this.cache.keys()))]; + return this.set(newRoles); + } + + /** + * Removes a role (or multiple roles) from the list of roles that can use this emoji. + * @param {RoleResolvable|RoleResolvable[]|Collection} roleOrRoles The role or roles to remove + * @returns {Promise} + */ + remove(roleOrRoles) { + if (!Array.isArray(roleOrRoles) && !(roleOrRoles instanceof Collection)) roleOrRoles = [roleOrRoles]; + + const resolvedRoleIds = []; + for (const role of roleOrRoles.values()) { + const roleId = this.guild.roles.resolveId(role); + if (!roleId) { + return Promise.reject(new TypeError('INVALID_ELEMENT', 'Array or Collection', 'roles', role)); + } + resolvedRoleIds.push(roleId); + } + + const newRoles = [...this.cache.keys()].filter(id => !resolvedRoleIds.includes(id)); + return this.set(newRoles); + } + + /** + * Sets the role(s) that can use this emoji. + * @param {Collection|RoleResolvable[]} roles The roles or role ids to apply + * @returns {Promise} + * @example + * // Set the emoji's roles to a single role + * guildEmoji.roles.set(['391156570408615936']) + * .then(console.log) + * .catch(console.error); + * @example + * // Remove all roles from an emoji + * guildEmoji.roles.set([]) + * .then(console.log) + * .catch(console.error); + */ + set(roles) { + return this.emoji.edit({ roles }); + } + + clone() { + const clone = new this.constructor(this.emoji); + clone._patch([...this.cache.keys()]); + return clone; + } + + /** + * Patches the roles for this manager's cache + * @param {Snowflake[]} roles The new roles + * @private + */ + _patch(roles) { + this.emoji._roles = roles; + } + + valueOf() { + return this.cache; + } +} + +module.exports = GuildEmojiRoleManager; diff --git a/src/managers/GuildInviteManager.js b/src/managers/GuildInviteManager.js new file mode 100644 index 00000000..435d08e --- /dev/null +++ b/src/managers/GuildInviteManager.js @@ -0,0 +1,214 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { Error } = require('../errors'); +const Invite = require('../structures/Invite'); +const DataResolver = require('../util/DataResolver'); + +/** + * Manages API methods for GuildInvites and stores their cache. + * @extends {CachedManager} + */ +class GuildInviteManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, Invite, iterable); + + /** + * The guild this Manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The cache of this Manager + * @type {Collection} + * @name GuildInviteManager#cache + */ + + _add(data, cache) { + return super._add(data, cache, { id: data.code, extras: [this.guild] }); + } + + /** + * Data that resolves to give an Invite object. This can be: + * * An invite code + * * An invite URL + * @typedef {string} InviteResolvable + */ + + /** + * Data that can be resolved to a channel that an invite can be created on. This can be: + * * TextChannel + * * VoiceChannel + * * NewsChannel + * * StoreChannel + * * StageChannel + * * Snowflake + * @typedef {TextChannel|VoiceChannel|NewsChannel|StoreChannel|StageChannel|Snowflake} + * GuildInvitableChannelResolvable + */ + + /** + * Resolves an InviteResolvable to an Invite object. + * @method resolve + * @memberof GuildInviteManager + * @instance + * @param {InviteResolvable} invite The invite resolvable to resolve + * @returns {?Invite} + */ + + /** + * Resolves an InviteResolvable to an invite code string. + * @method resolveId + * @memberof GuildInviteManager + * @instance + * @param {InviteResolvable} invite The invite resolvable to resolve + * @returns {?string} + */ + + /** + * Options used to fetch a single invite from a guild. + * @typedef {Object} FetchInviteOptions + * @property {InviteResolvable} code The invite to fetch + * @property {boolean} [cache=true] Whether or not to cache the fetched invite + * @property {boolean} [force=false] Whether to skip the cache check and request the API + */ + + /** + * Options used to fetch all invites from a guild. + * @typedef {Object} FetchInvitesOptions + * @property {GuildInvitableChannelResolvable} [channelId] + * The channel to fetch all invites from + * @property {boolean} [cache=true] Whether or not to cache the fetched invites + */ + + /** + * Fetches invite(s) from Discord. + * @param {InviteResolvable|FetchInviteOptions|FetchInvitesOptions} [options] Options for fetching guild invite(s) + * @returns {Promise>} + * @example + * // Fetch all invites from a guild + * guild.invites.fetch() + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch all invites from a guild without caching + * guild.invites.fetch({ cache: false }) + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch all invites from a channel + * guild.invites.fetch({ channelId: '222197033908436994' }) + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch a single invite + * guild.invites.fetch('bRCvFy9') + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch a single invite without checking cache + * guild.invites.fetch({ code: 'bRCvFy9', force: true }) + * .then(console.log) + * .catch(console.error) + * @example + * // Fetch a single invite without caching + * guild.invites.fetch({ code: 'bRCvFy9', cache: false }) + * .then(console.log) + * .catch(console.error); + */ + fetch(options) { + if (!options) return this._fetchMany(); + if (typeof options === 'string') { + const code = DataResolver.resolveInviteCode(options); + if (!code) return Promise.reject(new Error('INVITE_RESOLVE_CODE')); + return this._fetchSingle({ code, cache: true }); + } + if (!options.code) { + if (options.channelId) { + const id = this.guild.channels.resolveId(options.channelId); + if (!id) return Promise.reject(new Error('GUILD_CHANNEL_RESOLVE')); + return this._fetchChannelMany(id, options.cache); + } + + if ('cache' in options) return this._fetchMany(options.cache); + return Promise.reject(new Error('INVITE_RESOLVE_CODE')); + } + return this._fetchSingle({ + ...options, + code: DataResolver.resolveInviteCode(options.code), + }); + } + + async _fetchSingle({ code, cache, force = false }) { + if (!force) { + const existing = this.cache.get(code); + if (existing) return existing; + } + + const invites = await this._fetchMany(cache); + const invite = invites.get(code); + if (!invite) throw new Error('INVITE_NOT_FOUND'); + return invite; + } + + async _fetchMany(cache) { + const data = await this.client.api.guilds(this.guild.id).invites.get(); + return data.reduce((col, invite) => col.set(invite.code, this._add(invite, cache)), new Collection()); + } + + async _fetchChannelMany(channelId, cache) { + const data = await this.client.api.channels(channelId).invites.get(); + return data.reduce((col, invite) => col.set(invite.code, this._add(invite, cache)), new Collection()); + } + + /** + * Create an invite to the guild from the provided channel. + * @param {GuildInvitableChannelResolvable} channel The options for creating the invite from a channel. + * @param {CreateInviteOptions} [options={}] The options for creating the invite from a channel. + * @returns {Promise} + * @example + * // Create an invite to a selected channel + * guild.invites.create('599942732013764608') + * .then(console.log) + * .catch(console.error); + */ + async create( + channel, + { temporary, maxAge, maxUses, unique, targetUser, targetApplication, targetType, reason } = {}, + ) { + const id = this.guild.channels.resolveId(channel); + if (!id) throw new Error('GUILD_CHANNEL_RESOLVE'); + + const invite = await this.client.api.channels(id).invites.post({ + body: { + temporary, + max_age: maxAge, + max_uses: maxUses, + unique, + target_user_id: this.client.users.resolveId(targetUser), + target_application_id: targetApplication?.id ?? targetApplication?.applicationId ?? targetApplication, + target_type: targetType, + }, + reason, + }); + return new Invite(this.client, invite); + } + + /** + * Deletes an invite. + * @param {InviteResolvable} invite The invite to delete + * @param {string} [reason] Reason for deleting the invite + * @returns {Promise} + */ + async delete(invite, reason) { + const code = DataResolver.resolveInviteCode(invite); + + await this.client.api.invites(code).delete({ reason }); + } +} + +module.exports = GuildInviteManager; diff --git a/src/managers/GuildManager.js b/src/managers/GuildManager.js new file mode 100644 index 00000000..d76f9f7 --- /dev/null +++ b/src/managers/GuildManager.js @@ -0,0 +1,281 @@ +'use strict'; + +const process = require('node:process'); +const { setTimeout, clearTimeout } = require('node:timers'); +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { Guild } = require('../structures/Guild'); +const GuildChannel = require('../structures/GuildChannel'); +const GuildEmoji = require('../structures/GuildEmoji'); +const { GuildMember } = require('../structures/GuildMember'); +const Invite = require('../structures/Invite'); +const OAuth2Guild = require('../structures/OAuth2Guild'); +const { Role } = require('../structures/Role'); +const DataResolver = require('../util/DataResolver'); +const Events = require('../util/Events'); +const PermissionsBitField = require('../util/PermissionsBitField'); +const SystemChannelFlagsBitField = require('../util/SystemChannelFlagsBitField'); +const { resolveColor } = require('../util/Util'); + +let cacheWarningEmitted = false; + +/** + * Manages API methods for Guilds and stores their cache. + * @extends {CachedManager} + */ +class GuildManager extends CachedManager { + constructor(client, iterable) { + super(client, Guild, iterable); + if (!cacheWarningEmitted && this._cache.constructor.name !== 'Collection') { + cacheWarningEmitted = true; + process.emitWarning( + `Overriding the cache handling for ${this.constructor.name} is unsupported and breaks functionality.`, + 'UnsupportedCacheOverwriteWarning', + ); + } + } + + /** + * The cache of this Manager + * @type {Collection} + * @name GuildManager#cache + */ + + /** + * Data that resolves to give a Guild object. This can be: + * * A Guild object + * * A GuildChannel object + * * A GuildEmoji object + * * A Role object + * * A Snowflake + * * An Invite object + * @typedef {Guild|GuildChannel|GuildMember|GuildEmoji|Role|Snowflake|Invite} GuildResolvable + */ + + /** + * Partial data for a Role. + * @typedef {Object} PartialRoleData + * @property {Snowflake|number} [id] The role's id, used to set channel overrides, + * this is a placeholder and will be replaced by the API after consumption + * @property {string} [name] The name of the role + * @property {ColorResolvable} [color] The color of the role, either a hex string or a base 10 number + * @property {boolean} [hoist] Whether or not the role should be hoisted + * @property {number} [position] The position of the role + * @property {PermissionResolvable} [permissions] The permissions of the role + * @property {boolean} [mentionable] Whether or not the role should be mentionable + */ + + /** + * Partial overwrite data. + * @typedef {Object} PartialOverwriteData + * @property {Snowflake|number} id The id of the {@link Role} or {@link User} this overwrite belongs to + * @property {OverwriteType} [type] The type of this overwrite + * @property {PermissionResolvable} [allow] The permissions to allow + * @property {PermissionResolvable} [deny] The permissions to deny + */ + + /** + * Partial data for a Channel. + * @typedef {Object} PartialChannelData + * @property {Snowflake|number} [id] The channel's id, used to set its parent, + * this is a placeholder and will be replaced by the API after consumption + * @property {Snowflake|number} [parentId] The parent id for this channel + * @property {ChannelType|number} [type] The type of the channel + * @property {string} name The name of the channel + * @property {string} [topic] The topic of the text channel + * @property {boolean} [nsfw] Whether the channel is NSFW + * @property {number} [bitrate] The bitrate of the voice channel + * @property {number} [userLimit] The user limit of the channel + * @property {?string} [rtcRegion] The RTC region of the channel + * @property {PartialOverwriteData[]} [permissionOverwrites] + * Overwrites of the channel + * @property {number} [rateLimitPerUser] The rate limit per user (slowmode) of the channel in seconds + */ + + /** + * Resolves a GuildResolvable to a Guild object. + * @method resolve + * @memberof GuildManager + * @instance + * @param {GuildResolvable} guild The guild resolvable to identify + * @returns {?Guild} + */ + resolve(guild) { + if ( + guild instanceof GuildChannel || + guild instanceof GuildMember || + guild instanceof GuildEmoji || + guild instanceof Role || + (guild instanceof Invite && guild.guild) + ) { + return super.resolve(guild.guild); + } + return super.resolve(guild); + } + + /** + * Resolves a {@link GuildResolvable} to a {@link Guild} id string. + * @method resolveId + * @memberof GuildManager + * @instance + * @param {GuildResolvable} guild The guild resolvable to identify + * @returns {?Snowflake} + */ + resolveId(guild) { + if ( + guild instanceof GuildChannel || + guild instanceof GuildMember || + guild instanceof GuildEmoji || + guild instanceof Role || + (guild instanceof Invite && guild.guild) + ) { + return super.resolveId(guild.guild.id); + } + return super.resolveId(guild); + } + + /** + * Options used to create a guild. + * @typedef {Object} GuildCreateOptions + * @property {Snowflake|number} [afkChannelId] The AFK channel's id + * @property {number} [afkTimeout] The AFK timeout in seconds + * @property {PartialChannelData[]} [channels=[]] The channels for this guild + * @property {DefaultMessageNotificationLevel|number} [defaultMessageNotifications] The default message notifications + * for the guild + * @property {ExplicitContentFilterLevel} [explicitContentFilter] The explicit content filter level for the guild + * @property {?(BufferResolvable|Base64Resolvable)} [icon=null] The icon for the guild + * @property {PartialRoleData[]} [roles=[]] The roles for this guild, + * the first element of this array is used to change properties of the guild's everyone role. + * @property {Snowflake|number} [systemChannelId] The system channel's id + * @property {SystemChannelFlagsResolvable} [systemChannelFlags] The flags of the system channel + * @property {VerificationLevel} [verificationLevel] The verification level for the guild + */ + + /** + * Creates a guild. + * This is only available to bots in fewer than 10 guilds. + * @param {string} name The name of the guild + * @param {GuildCreateOptions} [options] Options for creating the guild + * @returns {Promise} The guild that was created + */ + async create( + name, + { + afkChannelId, + afkTimeout, + channels = [], + defaultMessageNotifications, + explicitContentFilter, + icon = null, + roles = [], + systemChannelId, + systemChannelFlags, + verificationLevel, + } = {}, + ) { + icon = await DataResolver.resolveImage(icon); + + for (const channel of channels) { + channel.parent_id = channel.parentId; + delete channel.parentId; + channel.user_limit = channel.userLimit; + delete channel.userLimit; + channel.rate_limit_per_user = channel.rateLimitPerUser; + delete channel.rateLimitPerUser; + channel.rtc_region = channel.rtcRegion; + delete channel.rtcRegion; + + if (!channel.permissionOverwrites) continue; + for (const overwrite of channel.permissionOverwrites) { + overwrite.allow &&= PermissionsBitField.resolve(overwrite.allow).toString(); + overwrite.deny &&= PermissionsBitField.resolve(overwrite.deny).toString(); + } + channel.permission_overwrites = channel.permissionOverwrites; + delete channel.permissionOverwrites; + } + for (const role of roles) { + role.color &&= resolveColor(role.color); + role.permissions &&= PermissionsBitField.resolve(role.permissions).toString(); + } + systemChannelFlags &&= SystemChannelFlagsBitField.resolve(systemChannelFlags); + + const data = await this.client.api.guilds.post({ + body: { + name, + icon, + verification_level: verificationLevel, + default_message_notifications: defaultMessageNotifications, + explicit_content_filter: explicitContentFilter, + roles, + channels, + afk_channel_id: afkChannelId, + afk_timeout: afkTimeout, + system_channel_id: systemChannelId, + system_channel_flags: systemChannelFlags, + }, + }); + + if (this.client.guilds.cache.has(data.id)) return this.client.guilds.cache.get(data.id); + + return new Promise(resolve => { + const handleGuild = guild => { + if (guild.id === data.id) { + clearTimeout(timeout); + this.client.removeListener(Events.GuildCreate, handleGuild); + this.client.decrementMaxListeners(); + resolve(guild); + } + }; + this.client.incrementMaxListeners(); + this.client.on(Events.GuildCreate, handleGuild); + + const timeout = setTimeout(() => { + this.client.removeListener(Events.GuildCreate, handleGuild); + this.client.decrementMaxListeners(); + resolve(this.client.guilds._add(data)); + }, 10_000).unref(); + }); + } + + /** + * Options used to fetch a single guild. + * @typedef {BaseFetchOptions} FetchGuildOptions + * @property {GuildResolvable} guild The guild to fetch + * @property {boolean} [withCounts=true] Whether the approximate member and presence counts should be returned + */ + + /** + * Options used to fetch multiple guilds. + * @typedef {Object} FetchGuildsOptions + * @property {Snowflake} [before] Get guilds before this guild id + * @property {Snowflake} [after] Get guilds after this guild id + * @property {number} [limit] Maximum number of guilds to request (1-200) + */ + + /** + * Obtains one or multiple guilds from Discord, or the guild cache if it's already available. + * @param {GuildResolvable|FetchGuildOptions|FetchGuildsOptions} [options] The guild's id or options + * @returns {Promise>} + */ + async fetch(options = {}) { + const id = this.resolveId(options) ?? this.resolveId(options.guild); + + if (id) { + if (!options.force) { + const existing = this.cache.get(id); + if (existing) return existing; + } + + const data = await this.client.api.guilds(id).get({ + query: new URLSearchParams({ with_counts: options.withCounts ?? true }), + }) + return this._add(data, options.cache); + } + + const data = await this.client.api.users('@me').guilds.get({ query: new URLSearchParams(options) }); + return data.reduce((coll, guild) => coll.set(guild.id, new OAuth2Guild(this.client, guild)), new Collection()); + } +} + +module.exports = GuildManager; diff --git a/src/managers/GuildMemberManager.js b/src/managers/GuildMemberManager.js new file mode 100644 index 00000000..857b7b4 --- /dev/null +++ b/src/managers/GuildMemberManager.js @@ -0,0 +1,472 @@ +'use strict'; + +const { Buffer } = require('node:buffer'); +const { setTimeout, clearTimeout } = require('node:timers'); +const { Collection } = require('@discordjs/collection'); +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { Routes, GatewayOpcodes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { Error, TypeError, RangeError } = require('../errors'); +const BaseGuildVoiceChannel = require('../structures/BaseGuildVoiceChannel'); +const { GuildMember } = require('../structures/GuildMember'); +const { Role } = require('../structures/Role'); +const Events = require('../util/Events'); + +/** + * Manages API methods for GuildMembers and stores their cache. + * @extends {CachedManager} + */ +class GuildMemberManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, GuildMember, iterable); + + /** + * The guild this manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The cache of this Manager + * @type {Collection} + * @name GuildMemberManager#cache + */ + + _add(data, cache = true) { + return super._add(data, cache, { id: data.user.id, extras: [this.guild] }); + } + + /** + * Data that resolves to give a GuildMember object. This can be: + * * A GuildMember object + * * A User resolvable + * @typedef {GuildMember|UserResolvable} GuildMemberResolvable + */ + + /** + * Resolves a {@link GuildMemberResolvable} to a {@link GuildMember} object. + * @param {GuildMemberResolvable} member The user that is part of the guild + * @returns {?GuildMember} + */ + resolve(member) { + const memberResolvable = super.resolve(member); + if (memberResolvable) return memberResolvable; + const userResolvable = this.client.users.resolveId(member); + if (userResolvable) return super.resolve(userResolvable); + return null; + } + + /** + * Resolves a {@link GuildMemberResolvable} to a member id. + * @param {GuildMemberResolvable} member The user that is part of the guild + * @returns {?Snowflake} + */ + resolveId(member) { + const memberResolvable = super.resolveId(member); + if (memberResolvable) return memberResolvable; + const userResolvable = this.client.users.resolveId(member); + return this.cache.has(userResolvable) ? userResolvable : null; + } + + /** + * Options used to add a user to a guild using OAuth2. + * @typedef {Object} AddGuildMemberOptions + * @property {string} accessToken An OAuth2 access token for the user with the `guilds.join` scope granted to the + * bot's application + * @property {string} [nick] The nickname to give to the member (requires `MANAGE_NICKNAMES`) + * @property {Collection|RoleResolvable[]} [roles] The roles to add to the member + * (requires `MANAGE_ROLES`) + * @property {boolean} [mute] Whether the member should be muted (requires `MUTE_MEMBERS`) + * @property {boolean} [deaf] Whether the member should be deafened (requires `DEAFEN_MEMBERS`) + * @property {boolean} [force] Whether to skip the cache check and call the API directly + * @property {boolean} [fetchWhenExisting=true] Whether to fetch the user if not cached and already a member + */ + + /** + * Adds a user to the guild using OAuth2. Requires the `CREATE_INSTANT_INVITE` permission. + * @param {UserResolvable} user The user to add to the guild + * @param {AddGuildMemberOptions} options Options for adding the user to the guild + * @returns {Promise} + */ + async add(user, options) { + const userId = this.client.users.resolveId(user); + if (!userId) throw new TypeError('INVALID_TYPE', 'user', 'UserResolvable'); + if (!options.force) { + const cachedUser = this.cache.get(userId); + if (cachedUser) return cachedUser; + } + const resolvedOptions = { + access_token: options.accessToken, + nick: options.nick, + mute: options.mute, + deaf: options.deaf, + }; + if (options.roles) { + if (!Array.isArray(options.roles) && !(options.roles instanceof Collection)) { + throw new TypeError('INVALID_TYPE', 'options.roles', 'Array or Collection of Roles or Snowflakes', true); + } + const resolvedRoles = []; + for (const role of options.roles.values()) { + const resolvedRole = this.guild.roles.resolveId(role); + if (!resolvedRole) throw new TypeError('INVALID_ELEMENT', 'Array or Collection', 'options.roles', role); + resolvedRoles.push(resolvedRole); + } + resolvedOptions.roles = resolvedRoles; + } + const data = await this.client.rest.put(Routes.guildMember(this.guild.id, userId), { body: resolvedOptions }); + // Data is an empty buffer if the member is already part of the guild. + return data instanceof Buffer ? (options.fetchWhenExisting === false ? null : this.fetch(userId)) : this._add(data); + } + + /** + * Options used to fetch a single member from a guild. + * @typedef {BaseFetchOptions} FetchMemberOptions + * @property {UserResolvable} user The user to fetch + */ + + /** + * Options used to fetch multiple members from a guild. + * @typedef {Object} FetchMembersOptions + * @property {UserResolvable|UserResolvable[]} user The user(s) to fetch + * @property {?string} query Limit fetch to members with similar usernames + * @property {number} [limit=0] Maximum number of members to request + * @property {boolean} [withPresences=false] Whether or not to include the presences + * @property {number} [time=120e3] Timeout for receipt of members + * @property {?string} nonce Nonce for this request (32 characters max - default to base 16 now timestamp) + * @property {boolean} [force=false] Whether to skip the cache check and request the API + */ + + /** + * Fetches member(s) from Discord, even if they're offline. + * @param {UserResolvable|FetchMemberOptions|FetchMembersOptions} [options] If a UserResolvable, the user to fetch. + * If undefined, fetches all members. + * If a query, it limits the results to users with similar usernames. + * @returns {Promise>} + * @example + * // Fetch all members from a guild + * guild.members.fetch() + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch a single member + * guild.members.fetch('66564597481480192') + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch a single member without checking cache + * guild.members.fetch({ user, force: true }) + * .then(console.log) + * .catch(console.error) + * @example + * // Fetch a single member without caching + * guild.members.fetch({ user, cache: false }) + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch by an array of users including their presences + * guild.members.fetch({ user: ['66564597481480192', '191615925336670208'], withPresences: true }) + * .then(console.log) + * .catch(console.error); + * @example + * // Fetch by query + * guild.members.fetch({ query: 'hydra', limit: 1 }) + * .then(console.log) + * .catch(console.error); + */ + fetch(options) { + if (!options) return this._fetchMany(); + const user = this.client.users.resolveId(options); + if (user) return this._fetchSingle({ user, cache: true }); + if (options.user) { + if (Array.isArray(options.user)) { + options.user = options.user.map(u => this.client.users.resolveId(u)); + return this._fetchMany(options); + } else { + options.user = this.client.users.resolveId(options.user); + } + if (!options.limit && !options.withPresences) return this._fetchSingle(options); + } + return this._fetchMany(options); + } + + /** + * Options used for searching guild members. + * @typedef {Object} GuildSearchMembersOptions + * @property {string} query Filter members whose username or nickname start with this query + * @property {number} [limit] Maximum number of members to search + * @property {boolean} [cache=true] Whether or not to cache the fetched member(s) + */ + + /** + * Searches for members in the guild based on a query. + * @param {GuildSearchMembersOptions} options Options for searching members + * @returns {Promise>} + */ + async search({ query, limit, cache = true } = {}) { + const data = await this.client.api.guilds(this.guild.id).members.search.get({ + query: new URLSearchParams({ query, limit }), + }) + return data.reduce((col, member) => col.set(member.user.id, this._add(member, cache)), new Collection()); + } + + /** + * Options used for listing guild members. + * @typedef {Object} GuildListMembersOptions + * @property {Snowflake} [after] Limit fetching members to those with an id greater than the supplied id + * @property {number} [limit] Maximum number of members to list + * @property {boolean} [cache=true] Whether or not to cache the fetched member(s) + */ + + /** + * Lists up to 1000 members of the guild. + * @param {GuildListMembersOptions} [options] Options for listing members + * @returns {Promise>} + */ + async list({ after, limit, cache = true } = {}) { + const query = new URLSearchParams({ limit }); + if (after) { + query.set('after', after); + } + const data = await this.client.api.guilds(this.guild.id).members.get({ query }); + return data.reduce((col, member) => col.set(member.user.id, this._add(member, cache)), new Collection()); + } + + /** + * The data for editing a guild member. + * @typedef {Object} GuildMemberEditData + * @property {?string} [nick] The nickname to set for the member + * @property {Collection|RoleResolvable[]} [roles] The roles or role ids to apply + * @property {boolean} [mute] Whether or not the member should be muted + * @property {boolean} [deaf] Whether or not the member should be deafened + * @property {GuildVoiceChannelResolvable|null} [channel] Channel to move the member to + * (if they are connected to voice), or `null` if you want to disconnect them from voice + * @property {DateResolvable|null} [communicationDisabledUntil] The date or timestamp + * for the member's communication to be disabled until. Provide `null` to enable communication again. + */ + + /** + * Edits a member of the guild. + * The user must be a member of the guild + * @param {UserResolvable} user The member to edit + * @param {GuildMemberEditData} data The data to edit the member with + * @param {string} [reason] Reason for editing this user + * @returns {Promise} + */ + async edit(user, data, reason) { + const id = this.client.users.resolveId(user); + if (!id) throw new TypeError('INVALID_TYPE', 'user', 'UserResolvable'); + + // Clone the data object for immutability + const _data = { ...data }; + if (_data.channel) { + _data.channel = this.guild.channels.resolve(_data.channel); + if (!(_data.channel instanceof BaseGuildVoiceChannel)) { + throw new Error('GUILD_VOICE_CHANNEL_RESOLVE'); + } + _data.channel_id = _data.channel.id; + _data.channel = undefined; + } else if (_data.channel === null) { + _data.channel_id = null; + _data.channel = undefined; + } + _data.roles &&= _data.roles.map(role => (role instanceof Role ? role.id : role)); + + _data.communication_disabled_until = + // eslint-disable-next-line eqeqeq + _data.communicationDisabledUntil != null + ? new Date(_data.communicationDisabledUntil).toISOString() + : _data.communicationDisabledUntil; + + let endpoint = this.client.api.guilds(this.guild.id); + if (id === this.client.user.id) { + const keys = Object.keys(data); + if (keys.length === 1 && keys[0] === 'nick') endpoint = endpoint.members('@me'); + else endpoint = endpoint.members(id); + } else { + endpoint = endpoint.members(id); + } + const d = await endpoint.patch({ data: _data, reason }); + + const clone = this.cache.get(id)?._clone(); + clone?._patch(d); + return clone ?? this._add(d, false); + } + + /** + * Options used for pruning guild members. + * It's recommended to set {@link GuildPruneMembersOptions#count options.count} + * to `false` for large guilds. + * @typedef {Object} GuildPruneMembersOptions + * @property {number} [days] Number of days of inactivity required to kick + * @property {boolean} [dry=false] Get the number of users that will be kicked, without actually kicking them + * @property {boolean} [count] Whether or not to return the number of users that have been kicked. + * @property {RoleResolvable[]} [roles] Array of roles to bypass the "...and no roles" constraint when pruning + * @property {string} [reason] Reason for this prune + */ + + /** + * Prunes members from the guild based on how long they have been inactive. + * @param {GuildPruneMembersOptions} [options] Options for pruning + * @returns {Promise} The number of members that were/will be kicked + * @example + * // See how many members will be pruned + * guild.members.prune({ dry: true }) + * .then(pruned => console.log(`This will prune ${pruned} people!`)) + * .catch(console.error); + * @example + * // Actually prune the members + * guild.members.prune({ days: 1, reason: 'too many people!' }) + * .then(pruned => console.log(`I just pruned ${pruned} people!`)) + * .catch(console.error); + * @example + * // Include members with a specified role + * guild.members.prune({ days: 7, roles: ['657259391652855808'] }) + * .then(pruned => console.log(`I just pruned ${pruned} people!`)) + * .catch(console.error); + */ + async prune({ days, dry = false, count: compute_prune_count, roles = [], reason } = {}) { + if (typeof days !== 'number') throw new TypeError('PRUNE_DAYS_TYPE'); + + const query = { days }; + const resolvedRoles = []; + + for (const role of roles) { + const resolvedRole = this.guild.roles.resolveId(role); + if (!resolvedRole) { + throw new TypeError('INVALID_ELEMENT', 'Array', 'options.roles', role); + } + resolvedRoles.push(resolvedRole); + } + + if (resolvedRoles.length) { + query.include_roles = dry ? resolvedRoles.join(',') : resolvedRoles; + } + + const endpoint = this.client.api.guilds(this.guild.id).prune; + + const { pruned } = await (dry + ? endpoint.get({ query: new URLSearchParams(query), reason }) + : endpoint.post({ body: { ...query, compute_prune_count }, reason })); + + return pruned; + } + + /** + * Kicks a user from the guild. + * The user must be a member of the guild + * @param {UserResolvable} user The member to kick + * @param {string} [reason] Reason for kicking + * @returns {Promise} Result object will be resolved as specifically as possible. + * If the GuildMember cannot be resolved, the User will instead be attempted to be resolved. If that also cannot + * be resolved, the user's id will be the result. + * @example + * // Kick a user by id (or with a user/guild member object) + * guild.members.kick('84484653687267328') + * .then(kickInfo => console.log(`Kicked ${kickInfo.user?.tag ?? kickInfo.tag ?? kickInfo}`)) + * .catch(console.error); + */ + async kick(user, reason) { + const id = this.client.users.resolveId(user); + if (!id) return Promise.reject(new TypeError('INVALID_TYPE', 'user', 'UserResolvable')); + + await this.clinet.api.guilds(this.guild.id).members(id).delete({ reason }); + + return this.resolve(user) ?? this.client.users.resolve(user) ?? id; + } + + /** + * Bans a user from the guild. + * @param {UserResolvable} user The user to ban + * @param {BanOptions} [options] Options for the ban + * @returns {Promise} Result object will be resolved as specifically as possible. + * If the GuildMember cannot be resolved, the User will instead be attempted to be resolved. If that also cannot + * be resolved, the user id will be the result. + * Internally calls the GuildBanManager#create method. + * @example + * // Ban a user by id (or with a user/guild member object) + * guild.members.ban('84484653687267328') + * .then(banInfo => console.log(`Banned ${banInfo.user?.tag ?? banInfo.tag ?? banInfo}`)) + * .catch(console.error); + */ + ban(user, options) { + return this.guild.bans.create(user, options); + } + + /** + * Unbans a user from the guild. Internally calls the {@link GuildBanManager#remove} method. + * @param {UserResolvable} user The user to unban + * @param {string} [reason] Reason for unbanning user + * @returns {Promise} The user that was unbanned + * @example + * // Unban a user by id (or with a user/guild member object) + * guild.members.unban('84484653687267328') + * .then(user => console.log(`Unbanned ${user.username} from ${guild.name}`)) + * .catch(console.error); + */ + unban(user, reason) { + return this.guild.bans.remove(user, reason); + } + + async _fetchSingle({ user, cache, force = false }) { + if (!force) { + const existing = this.cache.get(user); + if (existing && !existing.partial) return existing; + } + + const data = await this.client.api.guilds(this.guild.id).members(user).get(); + return this._add(data, cache); + } + + _fetchMany({ + limit = 0, + withPresences: presences = false, + user: user_ids, + query, + time = 120e3, + nonce = DiscordSnowflake.generate().toString(), + } = {}) { + return new Promise((resolve, reject) => { + if (!query && !user_ids) query = ''; + if (nonce.length > 32) throw new RangeError('MEMBER_FETCH_NONCE_LENGTH'); + this.guild.shard.send({ + op: GatewayOpcodes.RequestGuildMembers, + d: { + guild_id: this.guild.id, + presences, + user_ids, + query, + nonce, + limit, + }, + }); + const fetchedMembers = new Collection(); + let i = 0; + const handler = (members, _, chunk) => { + timeout.refresh(); + if (chunk.nonce !== nonce) return; + i++; + for (const member of members.values()) { + fetchedMembers.set(member.id, member); + } + if (members.size < 1_000 || (limit && fetchedMembers.size >= limit) || i === chunk.count) { + clearTimeout(timeout); + this.client.removeListener(Events.GuildMembersChunk, handler); + this.client.decrementMaxListeners(); + let fetched = fetchedMembers; + if (user_ids && !Array.isArray(user_ids) && fetched.size) fetched = fetched.first(); + resolve(fetched); + } + }; + const timeout = setTimeout(() => { + this.client.removeListener(Events.GuildMembersChunk, handler); + this.client.decrementMaxListeners(); + reject(new Error('GUILD_MEMBERS_TIMEOUT')); + }, time).unref(); + this.client.incrementMaxListeners(); + this.client.on(Events.GuildMembersChunk, handler); + }); + } +} + +module.exports = GuildMemberManager; diff --git a/src/managers/GuildMemberRoleManager.js b/src/managers/GuildMemberRoleManager.js new file mode 100644 index 00000000..ce645b1 --- /dev/null +++ b/src/managers/GuildMemberRoleManager.js @@ -0,0 +1,192 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const DataManager = require('./DataManager'); +const { TypeError } = require('../errors'); +const { Role } = require('../structures/Role'); + +/** + * Manages API methods for roles of a GuildMember and stores their cache. + * @extends {DataManager} + */ +class GuildMemberRoleManager extends DataManager { + constructor(member) { + super(member.client, Role); + + /** + * The GuildMember this manager belongs to + * @type {GuildMember} + */ + this.member = member; + + /** + * The Guild this manager belongs to + * @type {Guild} + */ + this.guild = member.guild; + } + + /** + * The roles of this member + * @type {Collection} + * @readonly + */ + get cache() { + const everyone = this.guild.roles.everyone; + return this.guild.roles.cache.filter(role => this.member._roles.includes(role.id)).set(everyone.id, everyone); + } + + /** + * The role of the member used to hoist them in a separate category in the users list + * @type {?Role} + * @readonly + */ + get hoist() { + const hoistedRoles = this.cache.filter(role => role.hoist); + if (!hoistedRoles.size) return null; + return hoistedRoles.reduce((prev, role) => (role.comparePositionTo(prev) > 0 ? role : prev)); + } + + /** + * The role of the member used to set their role icon + * @type {?Role} + * @readonly + */ + get icon() { + const iconRoles = this.cache.filter(role => role.icon || role.unicodeEmoji); + if (!iconRoles.size) return null; + return iconRoles.reduce((prev, role) => (role.comparePositionTo(prev) > 0 ? role : prev)); + } + + /** + * The role of the member used to set their color + * @type {?Role} + * @readonly + */ + get color() { + const coloredRoles = this.cache.filter(role => role.color); + if (!coloredRoles.size) return null; + return coloredRoles.reduce((prev, role) => (role.comparePositionTo(prev) > 0 ? role : prev)); + } + + /** + * The role of the member with the highest position + * @type {Role} + * @readonly + */ + get highest() { + return this.cache.reduce((prev, role) => (role.comparePositionTo(prev) > 0 ? role : prev), this.cache.first()); + } + + /** + * The premium subscriber role of the guild, if present on the member + * @type {?Role} + * @readonly + */ + get premiumSubscriberRole() { + return this.cache.find(role => role.tags?.premiumSubscriberRole) ?? null; + } + + /** + * The managed role this member created when joining the guild, if any + * Only ever available on bots + * @type {?Role} + * @readonly + */ + get botRole() { + if (!this.member.user.bot) return null; + return this.cache.find(role => role.tags?.botId === this.member.user.id) ?? null; + } + + /** + * Adds a role (or multiple roles) to the member. + * @param {RoleResolvable|RoleResolvable[]|Collection} roleOrRoles The role or roles to add + * @param {string} [reason] Reason for adding the role(s) + * @returns {Promise} + */ + async add(roleOrRoles, reason) { + if (roleOrRoles instanceof Collection || Array.isArray(roleOrRoles)) { + const resolvedRoles = []; + for (const role of roleOrRoles.values()) { + const resolvedRole = this.guild.roles.resolveId(role); + if (!resolvedRole) throw new TypeError('INVALID_ELEMENT', 'Array or Collection', 'roles', role); + resolvedRoles.push(resolvedRole); + } + + const newRoles = [...new Set(resolvedRoles.concat(...this.cache.keys()))]; + return this.set(newRoles, reason); + } else { + roleOrRoles = this.guild.roles.resolveId(roleOrRoles); + if (roleOrRoles === null) { + throw new TypeError('INVALID_TYPE', 'roles', 'Role, Snowflake or Array or Collection of Roles or Snowflakes'); + } + + await this.client.api.guilds[this.guild.id].members[this.member.id].roles[roleOrRoles].put({ reason }); + + const clone = this.member._clone(); + clone._roles = [...this.cache.keys(), roleOrRoles]; + return clone; + } + } + + /** + * Removes a role (or multiple roles) from the member. + * @param {RoleResolvable|RoleResolvable[]|Collection} roleOrRoles The role or roles to remove + * @param {string} [reason] Reason for removing the role(s) + * @returns {Promise} + */ + async remove(roleOrRoles, reason) { + if (roleOrRoles instanceof Collection || Array.isArray(roleOrRoles)) { + const resolvedRoles = []; + for (const role of roleOrRoles.values()) { + const resolvedRole = this.guild.roles.resolveId(role); + if (!resolvedRole) throw new TypeError('INVALID_ELEMENT', 'Array or Collection', 'roles', role); + resolvedRoles.push(resolvedRole); + } + + const newRoles = this.cache.filter(role => !resolvedRoles.includes(role.id)); + return this.set(newRoles, reason); + } else { + roleOrRoles = this.guild.roles.resolveId(roleOrRoles); + if (roleOrRoles === null) { + throw new TypeError('INVALID_TYPE', 'roles', 'Role, Snowflake or Array or Collection of Roles or Snowflakes'); + } + + await this.client.api.guilds[this.guild.id].members[this.member.id].roles[roleOrRoles].delete({ reason }); + + const clone = this.member._clone(); + const newRoles = this.cache.filter(role => role.id !== roleOrRoles); + clone._roles = [...newRoles.keys()]; + return clone; + } + } + + /** + * Sets the roles applied to the member. + * @param {Collection|RoleResolvable[]} roles The roles or role ids to apply + * @param {string} [reason] Reason for applying the roles + * @returns {Promise} + * @example + * // Set the member's roles to a single role + * guildMember.roles.set(['391156570408615936']) + * .then(console.log) + * .catch(console.error); + * @example + * // Remove all the roles from a member + * guildMember.roles.set([]) + * .then(member => console.log(`Member roles is now of ${member.roles.cache.size} size`)) + * .catch(console.error); + */ + set(roles, reason) { + return this.member.edit({ roles }, reason); + } + + clone() { + const clone = new this.constructor(this.member); + clone.member._roles = [...this.cache.keys()]; + return clone; + } +} + +module.exports = GuildMemberRoleManager; diff --git a/src/managers/GuildScheduledEventManager.js b/src/managers/GuildScheduledEventManager.js new file mode 100644 index 00000000..98fdb2b --- /dev/null +++ b/src/managers/GuildScheduledEventManager.js @@ -0,0 +1,309 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { GuildScheduledEventEntityType, Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { TypeError, Error } = require('../errors'); +const { GuildScheduledEvent } = require('../structures/GuildScheduledEvent'); +const DataResolver = require('../util/DataResolver'); + +/** + * Manages API methods for GuildScheduledEvents and stores their cache. + * @extends {CachedManager} + */ +class GuildScheduledEventManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, GuildScheduledEvent, iterable); + + /** + * The guild this manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The cache of this manager + * @type {Collection} + * @name GuildScheduledEventManager#cache + */ + + /** + * Data that resolves to give a GuildScheduledEvent object. This can be: + * * A Snowflake + * * A GuildScheduledEvent object + * @typedef {Snowflake|GuildScheduledEvent} GuildScheduledEventResolvable + */ + + /** + * Options used to create a guild scheduled event. + * @typedef {Object} GuildScheduledEventCreateOptions + * @property {string} name The name of the guild scheduled event + * @property {DateResolvable} scheduledStartTime The time to schedule the event at + * @property {DateResolvable} [scheduledEndTime] The time to end the event at + * This is required if `entityType` is {@link GuildScheduledEventEntityType.External} + * @property {PrivacyLevel|number} privacyLevel The privacy level of the guild scheduled event + * @property {GuildScheduledEventEntityType|number} entityType The scheduled entity type of the event + * @property {string} [description] The description of the guild scheduled event + * @property {GuildVoiceChannelResolvable} [channel] The channel of the guild scheduled event + * This is required if `entityType` is {@link GuildScheduledEventEntityType.StageInstance} or + * {@link GuildScheduledEventEntityType.Voice} + * @property {GuildScheduledEventEntityMetadataOptions} [entityMetadata] The entity metadata of the + * guild scheduled event + * This is required if `entityType` is {@link GuildScheduledEventEntityType.External} + * @property {?(BufferResolvable|Base64Resolvable)} [image] The cover image of the guild scheduled event + * @property {string} [reason] The reason for creating the guild scheduled event + */ + + /** + * Options used to set entity metadata of a guild scheduled event. + * @typedef {Object} GuildScheduledEventEntityMetadataOptions + * @property {string} [location] The location of the guild scheduled event + * This is required if `entityType` is {@link GuildScheduledEventEntityType.External} + */ + + /** + * Creates a new guild scheduled event. + * @param {GuildScheduledEventCreateOptions} options Options for creating the guild scheduled event + * @returns {Promise} + */ + async create(options) { + if (typeof options !== 'object') throw new TypeError('INVALID_TYPE', 'options', 'object', true); + let { + privacyLevel, + entityType, + channel, + name, + scheduledStartTime, + description, + scheduledEndTime, + entityMetadata, + reason, + image, + } = options; + + let entity_metadata, channel_id; + if (entityType === GuildScheduledEventEntityType.External) { + channel_id = typeof channel === 'undefined' ? channel : null; + entity_metadata = { location: entityMetadata?.location }; + } else { + channel_id = this.guild.channels.resolveId(channel); + if (!channel_id) throw new Error('GUILD_VOICE_CHANNEL_RESOLVE'); + entity_metadata = typeof entityMetadata === 'undefined' ? entityMetadata : null; + } + + const data = await this.client.api.guilds(this.guild.id, 'scheduled-events').post({ + body: { + channel_id, + name, + privacy_level: privacyLevel, + scheduled_start_time: new Date(scheduledStartTime).toISOString(), + scheduled_end_time: scheduledEndTime ? new Date(scheduledEndTime).toISOString() : scheduledEndTime, + description, + entity_type: entityType, + entity_metadata, + image: image && (await DataResolver.resolveImage(image)), + }, + reason, + }) + + return this._add(data); + } + + /** + * Options used to fetch a single guild scheduled event from a guild. + * @typedef {BaseFetchOptions} FetchGuildScheduledEventOptions + * @property {GuildScheduledEventResolvable} guildScheduledEvent The guild scheduled event to fetch + * @property {boolean} [withUserCount=true] Whether to fetch the number of users subscribed to the scheduled event + */ + + /** + * Options used to fetch multiple guild scheduled events from a guild. + * @typedef {Object} FetchGuildScheduledEventsOptions + * @property {boolean} [cache] Whether or not to cache the fetched guild scheduled events + * @property {boolean} [withUserCount=true] Whether to fetch the number of users subscribed to each scheduled event + * should be returned + */ + + /** + * Obtains one or more guild scheduled events from Discord, or the guild cache if it's already available. + * @param {GuildScheduledEventResolvable|FetchGuildScheduledEventOptions|FetchGuildScheduledEventsOptions} [options] + * The id of the guild scheduled event or options + * @returns {Promise>} + */ + async fetch(options = {}) { + const id = this.resolveId(options.guildScheduledEvent ?? options); + + if (id) { + if (!options.force) { + const existing = this.cache.get(id); + if (existing) return existing; + } + + const data = await this.client.api.guilds(this.guild.id, 'scheduled-events', id).get({ + query: new URLSearchParams({ with_user_count: options.withUserCount ?? true }), + }) + return this._add(data, options.cache); + } + + const data = await this.client.api.guilds(this.guild.id, 'scheduled-events').get({ + query: new URLSearchParams({ with_user_count: options.withUserCount ?? true }), + }) + + return data.reduce( + (coll, rawGuildScheduledEventData) => + coll.set( + rawGuildScheduledEventData.id, + this.guild.scheduledEvents._add(rawGuildScheduledEventData, options.cache), + ), + new Collection(), + ); + } + + /** + * Options used to edit a guild scheduled event. + * @typedef {Object} GuildScheduledEventEditOptions + * @property {string} [name] The name of the guild scheduled event + * @property {DateResolvable} [scheduledStartTime] The time to schedule the event at + * @property {DateResolvable} [scheduledEndTime] The time to end the event at + * @property {PrivacyLevel|number} [privacyLevel] The privacy level of the guild scheduled event + * @property {GuildScheduledEventEntityType|number} [entityType] The scheduled entity type of the event + * @property {string} [description] The description of the guild scheduled event + * @property {?GuildVoiceChannelResolvable} [channel] The channel of the guild scheduled event + * @property {GuildScheduledEventStatus|number} [status] The status of the guild scheduled event + * @property {GuildScheduledEventEntityMetadataOptions} [entityMetadata] The entity metadata of the + * guild scheduled event + * This can be modified only if `entityType` of the `GuildScheduledEvent` to be edited is + * {@link GuildScheduledEventEntityType.External} + * @property {?(BufferResolvable|Base64Resolvable)} [image] The cover image of the guild scheduled event + * @property {string} [reason] The reason for editing the guild scheduled event + */ + + /** + * Edits a guild scheduled event. + * @param {GuildScheduledEventResolvable} guildScheduledEvent The guild scheduled event to edit + * @param {GuildScheduledEventEditOptions} options Options to edit the guild scheduled event + * @returns {Promise} + */ + async edit(guildScheduledEvent, options) { + const guildScheduledEventId = this.resolveId(guildScheduledEvent); + if (!guildScheduledEventId) throw new Error('GUILD_SCHEDULED_EVENT_RESOLVE'); + + if (typeof options !== 'object') throw new TypeError('INVALID_TYPE', 'options', 'object', true); + let { + privacyLevel, + entityType, + channel, + status, + name, + scheduledStartTime, + description, + scheduledEndTime, + entityMetadata, + reason, + image, + } = options; + + let entity_metadata; + if (entityMetadata) { + entity_metadata = { + location: entityMetadata.location, + }; + } + + const data = await this.client.api.guilds(this.guild.id, 'scheduled-events', guildScheduledEventId).patch({ + body: { + channel_id: typeof channel === 'undefined' ? channel : this.guild.channels.resolveId(channel), + name, + privacy_level: privacyLevel, + scheduled_start_time: scheduledStartTime ? new Date(scheduledStartTime).toISOString() : undefined, + scheduled_end_time: scheduledEndTime ? new Date(scheduledEndTime).toISOString() : scheduledEndTime, + description, + entity_type: entityType, + status, + image: image && (await DataResolver.resolveImage(image)), + entity_metadata, + }, + reason, + }) + + return this._add(data); + } + + /** + * Deletes a guild scheduled event. + * @param {GuildScheduledEventResolvable} guildScheduledEvent The guild scheduled event to delete + * @returns {Promise} + */ + async delete(guildScheduledEvent) { + const guildScheduledEventId = this.resolveId(guildScheduledEvent); + if (!guildScheduledEventId) throw new Error('GUILD_SCHEDULED_EVENT_RESOLVE'); + + await this.client.api.guilds(this.guild.id, 'scheduled-events', guildScheduledEventId).delete(); + } + + /** + * Options used to fetch subscribers of a guild scheduled event + * @typedef {Object} FetchGuildScheduledEventSubscribersOptions + * @property {number} [limit] The maximum numbers of users to fetch + * @property {boolean} [withMember] Whether to fetch guild member data of the users + * @property {Snowflake} [before] Consider only users before this user id + * @property {Snowflake} [after] Consider only users after this user id + * If both `before` and `after` are provided, only `before` is respected + */ + + /** + * Represents a subscriber of a {@link GuildScheduledEvent} + * @typedef {Object} GuildScheduledEventUser + * @property {Snowflake} guildScheduledEventId The id of the guild scheduled event which the user subscribed to + * @property {User} user The user that subscribed to the guild scheduled event + * @property {?GuildMember} member The guild member associated with the user, if any + */ + + /** + * Fetches subscribers of a guild scheduled event. + * @param {GuildScheduledEventResolvable} guildScheduledEvent The guild scheduled event to fetch subscribers of + * @param {FetchGuildScheduledEventSubscribersOptions} [options={}] Options for fetching the subscribers + * @returns {Promise>} + */ + async fetchSubscribers(guildScheduledEvent, options = {}) { + const guildScheduledEventId = this.resolveId(guildScheduledEvent); + if (!guildScheduledEventId) throw new Error('GUILD_SCHEDULED_EVENT_RESOLVE'); + + let { limit, withMember, before, after } = options; + + const query = new URLSearchParams(); + + if (limit) { + query.set('limit', limit); + } + + if (typeof withMember !== 'undefined') { + query.set('with_member', withMember); + } + + if (before) { + query.set('before', before); + } + + if (after) { + query.set('after', after); + } + + const data = await this.client.api.guilds(this.guild.id, 'scheduled-events', guildScheduledEventId).users.get({ + query, + }); + + return data.reduce( + (coll, rawData) => + coll.set(rawData.user.id, { + guildScheduledEventId: rawData.guild_scheduled_event_id, + user: this.client.users._add(rawData.user), + member: rawData.member ? this.guild.members._add({ ...rawData.member, user: rawData.user }) : null, + }), + new Collection(), + ); + } +} + +module.exports = GuildScheduledEventManager; diff --git a/src/managers/GuildStickerManager.js b/src/managers/GuildStickerManager.js new file mode 100644 index 00000000..29202cf --- /dev/null +++ b/src/managers/GuildStickerManager.js @@ -0,0 +1,183 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { TypeError } = require('../errors'); +const MessagePayload = require('../structures/MessagePayload'); +const { Sticker } = require('../structures/Sticker'); + +/** + * Manages API methods for Guild Stickers and stores their cache. + * @extends {CachedManager} + */ +class GuildStickerManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, Sticker, iterable); + + /** + * The guild this manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The cache of Guild Stickers + * @type {Collection} + * @name GuildStickerManager#cache + */ + + _add(data, cache) { + return super._add(data, cache, { extras: [this.guild] }); + } + + /** + * Options for creating a guild sticker. + * @typedef {Object} GuildStickerCreateOptions + * @property {?string} [description] The description for the sticker + * @property {string} [reason] Reason for creating the sticker + */ + + /** + * Creates a new custom sticker in the guild. + * @param {BufferResolvable|Stream|FileOptions|MessageAttachment} file The file for the sticker + * @param {string} name The name for the sticker + * @param {string} tags The Discord name of a unicode emoji representing the sticker's expression + * @param {GuildStickerCreateOptions} [options] Options + * @returns {Promise} The created sticker + * @example + * // Create a new sticker from a URL + * guild.stickers.create('https://i.imgur.com/w3duR07.png', 'rip', 'headstone') + * .then(sticker => console.log(`Created new sticker with name ${sticker.name}!`)) + * .catch(console.error); + * @example + * // Create a new sticker from a file on your computer + * guild.stickers.create('./memes/banana.png', 'banana', 'banana') + * .then(sticker => console.log(`Created new sticker with name ${sticker.name}!`)) + * .catch(console.error); + */ + async create(file, name, tags, { description, reason } = {}) { + const resolvedFile = await MessagePayload.resolveFile(file); + if (!resolvedFile) throw new TypeError('REQ_RESOURCE_TYPE'); + file = { ...resolvedFile, key: 'file' }; + + const body = { name, tags, description: description ?? '' }; + + const sticker = await this.client.api.guilds(this.guild.id).stickers.post({ + appendToFormData: true, + body, + files: [file], + reason, + }); + return this.client.actions.GuildStickerCreate.handle(this.guild, sticker).sticker; + } + + /** + * Data that resolves to give a Sticker object. This can be: + * * A Sticker object + * * A Snowflake + * @typedef {Sticker|Snowflake} StickerResolvable + */ + + /** + * Resolves a StickerResolvable to a Sticker object. + * @method resolve + * @memberof GuildStickerManager + * @instance + * @param {StickerResolvable} sticker The Sticker resolvable to identify + * @returns {?Sticker} + */ + + /** + * Resolves a StickerResolvable to a Sticker id string. + * @method resolveId + * @memberof GuildStickerManager + * @instance + * @param {StickerResolvable} sticker The Sticker resolvable to identify + * @returns {?Snowflake} + */ + + /** + * Edits a sticker. + * @param {StickerResolvable} sticker The sticker to edit + * @param {GuildStickerEditData} [data] The new data for the sticker + * @param {string} [reason] Reason for editing this sticker + * @returns {Promise} + */ + async edit(sticker, data, reason) { + const stickerId = this.resolveId(sticker); + if (!stickerId) throw new TypeError('INVALID_TYPE', 'sticker', 'StickerResolvable'); + + const d = await this.client.api.guilds(this.guild.id).stickers(stickerId).patch({ + body: data, + reason, + }); + + const existing = this.cache.get(stickerId); + if (existing) { + const clone = existing._clone(); + clone._patch(d); + return clone; + } + return this._add(d); + } + + /** + * Deletes a sticker. + * @param {StickerResolvable} sticker The sticker to delete + * @param {string} [reason] Reason for deleting this sticker + * @returns {Promise} + */ + async delete(sticker, reason) { + sticker = this.resolveId(sticker); + if (!sticker) throw new TypeError('INVALID_TYPE', 'sticker', 'StickerResolvable'); + + await this.client.api.guilds(this.guild.id).stickers(sticker).delete({ reason }); + } + + /** + * Obtains one or more stickers from Discord, or the sticker cache if they're already available. + * @param {Snowflake} [id] The Sticker's id + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise>} + * @example + * // Fetch all stickers from the guild + * message.guild.stickers.fetch() + * .then(stickers => console.log(`There are ${stickers.size} stickers.`)) + * .catch(console.error); + * @example + * // Fetch a single sticker + * message.guild.stickers.fetch('222078108977594368') + * .then(sticker => console.log(`The sticker name is: ${sticker.name}`)) + * .catch(console.error); + */ + async fetch(id, { cache = true, force = false } = {}) { + if (id) { + if (!force) { + const existing = this.cache.get(id); + if (existing) return existing; + } + const sticker = await this.client.api.guilds(this.guild.id).stickers(id).get(); + return this._add(sticker, cache); + } + + const data = await this.client.api.guilds(this.guild.id).stickers.get(); + return new Collection(data.map(sticker => [sticker.id, this._add(sticker, cache)])); + } + + /** + * Fetches the user who uploaded this sticker, if this is a guild sticker. + * @param {StickerResolvable} sticker The sticker to fetch the user for + * @returns {Promise} + */ + async fetchUser(sticker) { + sticker = this.resolve(sticker); + if (!sticker) throw new TypeError('INVALID_TYPE', 'sticker', 'StickerResolvable'); + const data = await this.client.api.guilds(this.guild.id).stickers(sticker.id).get(); + sticker._patch(data); + return sticker.user; + } +} + +module.exports = GuildStickerManager; diff --git a/src/managers/MessageManager.js b/src/managers/MessageManager.js new file mode 100644 index 00000000..6f67f2d --- /dev/null +++ b/src/managers/MessageManager.js @@ -0,0 +1,235 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { TypeError } = require('../errors'); +const { Message } = require('../structures/Message'); +const MessagePayload = require('../structures/MessagePayload'); +const Util = require('../util/Util'); + +/** + * Manages API methods for Messages and holds their cache. + * @extends {CachedManager} + */ +class MessageManager extends CachedManager { + constructor(channel, iterable) { + super(channel.client, Message, iterable); + + /** + * The channel that the messages belong to + * @type {TextBasedChannels} + */ + this.channel = channel; + } + + /** + * The cache of Messages + * @type {Collection} + * @name MessageManager#cache + */ + + _add(data, cache) { + return super._add(data, cache); + } + + /** + * The parameters to pass in when requesting previous messages from a channel. `around`, `before` and + * `after` are mutually exclusive. All the parameters are optional. + * @typedef {Object} ChannelLogsQueryOptions + * @property {number} [limit] Number of messages to acquire + * @property {Snowflake} [before] The message's id to get the messages that were posted before it + * @property {Snowflake} [after] The message's id to get the messages that were posted after it + * @property {Snowflake} [around] The message's id to get the messages that were posted around it + */ + + /** + * Gets a message, or messages, from this channel. + * The returned Collection does not contain reaction users of the messages if they were not cached. + * Those need to be fetched separately in such a case. + * @param {Snowflake|ChannelLogsQueryOptions} [message] The id of the message to fetch, or query parameters. + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise>} + * @example + * // Get message + * channel.messages.fetch('99539446449315840') + * .then(message => console.log(message.content)) + * .catch(console.error); + * @example + * // Get messages + * channel.messages.fetch({ limit: 10 }) + * .then(messages => console.log(`Received ${messages.size} messages`)) + * .catch(console.error); + * @example + * // Get messages and filter by user id + * channel.messages.fetch() + * .then(messages => console.log(`${messages.filter(m => m.author.id === '84484653687267328').size} messages`)) + * .catch(console.error); + */ + fetch(message, { cache = true, force = false } = {}) { + return typeof message === 'string' ? this._fetchId(message, cache, force) : this._fetchMany(message, cache); + } + + /** + * Fetches the pinned messages of this channel and returns a collection of them. + * The returned Collection does not contain any reaction data of the messages. + * Those need to be fetched separately. + * @param {boolean} [cache=true] Whether to cache the message(s) + * @returns {Promise>} + * @example + * // Get pinned messages + * channel.messages.fetchPinned() + * .then(messages => console.log(`Received ${messages.size} messages`)) + * .catch(console.error); + */ + async fetchPinned(cache = true) { + const data = await this.client.api.channels(this.channel.id).pins.get(); + const messages = new Collection(); + for (const message of data) messages.set(message.id, this._add(message, cache)); + return messages; + } + + /** + * Data that can be resolved to a Message object. This can be: + * * A Message + * * A Snowflake + * @typedef {Message|Snowflake} MessageResolvable + */ + + /** + * Resolves a {@link MessageResolvable} to a {@link Message} object. + * @method resolve + * @memberof MessageManager + * @instance + * @param {MessageResolvable} message The message resolvable to resolve + * @returns {?Message} + */ + + /** + * Resolves a {@link MessageResolvable} to a {@link Message} id. + * @method resolveId + * @memberof MessageManager + * @instance + * @param {MessageResolvable} message The message resolvable to resolve + * @returns {?Snowflake} + */ + + /** + * Edits a message, even if it's not cached. + * @param {MessageResolvable} message The message to edit + * @param {string|MessageEditOptions|MessagePayload} options The options to edit the message + * @returns {Promise} + */ + async edit(message, options) { + const messageId = this.resolveId(message); + if (!messageId) throw new TypeError('INVALID_TYPE', 'message', 'MessageResolvable'); + + const { body, files } = await (options instanceof MessagePayload + ? options + : MessagePayload.create(message instanceof Message ? message : this, options) + ) + .resolveBody() + .resolveFiles(); + const d = await this.client.api.channels(this.channel.id).messages(messageId).patch({ body, files }); + + const existing = this.cache.get(messageId); + if (existing) { + const clone = existing._clone(); + clone._patch(d); + return clone; + } + return this._add(d); + } + + /** + * Publishes a message in an announcement channel to all channels following it, even if it's not cached. + * @param {MessageResolvable} message The message to publish + * @returns {Promise} + */ + async crosspost(message) { + message = this.resolveId(message); + if (!message) throw new TypeError('INVALID_TYPE', 'message', 'MessageResolvable'); + + const data = await this.client.api.channels(this.channel.id).messages(message).crosspost.post(); + return this.cache.get(data.id) ?? this._add(data); + } + + /** + * Pins a message to the channel's pinned messages, even if it's not cached. + * @param {MessageResolvable} message The message to pin + * @param {string} [reason] Reason for pinning + * @returns {Promise} + */ + async pin(message, reason) { + message = this.resolveId(message); + if (!message) throw new TypeError('INVALID_TYPE', 'message', 'MessageResolvable'); + + await this.client.api.channels(this.channel.id).pins(message).put({ reason }); + } + + /** + * Unpins a message from the channel's pinned messages, even if it's not cached. + * @param {MessageResolvable} message The message to unpin + * @param {string} [reason] Reason for unpinning + * @returns {Promise} + */ + async unpin(message, reason) { + message = this.resolveId(message); + if (!message) throw new TypeError('INVALID_TYPE', 'message', 'MessageResolvable'); + + await this.client.api.channels(this.channel.id).pins(message).delete({ reason }); + } + + /** + * Adds a reaction to a message, even if it's not cached. + * @param {MessageResolvable} message The message to react to + * @param {EmojiIdentifierResolvable} emoji The emoji to react with + * @returns {Promise} + */ + async react(message, emoji) { + message = this.resolveId(message); + if (!message) throw new TypeError('INVALID_TYPE', 'message', 'MessageResolvable'); + + emoji = Util.resolvePartialEmoji(emoji); + if (!emoji) throw new TypeError('EMOJI_TYPE', 'emoji', 'EmojiIdentifierResolvable'); + + const emojiId = emoji.id + ? `${emoji.animated ? 'a:' : ''}${emoji.name}:${emoji.id}` + : encodeURIComponent(emoji.name); + + await this.client.api.channels(this.channel.id).messages(message).reactions(emojiId, '@me').put(); + } + + /** + * Deletes a message, even if it's not cached. + * @param {MessageResolvable} message The message to delete + * @returns {Promise} + */ + async delete(message) { + message = this.resolveId(message); + if (!message) throw new TypeError('INVALID_TYPE', 'message', 'MessageResolvable'); + + await this.client.api.channels(this.channel.id).messages(message).delete(); + } + + async _fetchId(messageId, cache, force) { + if (!force) { + const existing = this.cache.get(messageId); + if (existing && !existing.partial) return existing; + } + + const data = await this.client.api.channels(this.channel.id).messages(messageId).get(); + return this._add(data, cache); + } + + async _fetchMany(options = {}, cache) { + const data = await this.client.api.channels(this.channel.id).messages.get({ + query: new URLSearchParams(options), + }); + const messages = new Collection(); + for (const message of data) messages.set(message.id, this._add(message, cache)); + return messages; + } +} + +module.exports = MessageManager; diff --git a/src/managers/PermissionOverwriteManager.js b/src/managers/PermissionOverwriteManager.js new file mode 100644 index 00000000..1dfb767 --- /dev/null +++ b/src/managers/PermissionOverwriteManager.js @@ -0,0 +1,162 @@ +'use strict'; + +const process = require('node:process'); +const { Collection } = require('@discordjs/collection'); +const { OverwriteType, Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { TypeError } = require('../errors'); +const PermissionOverwrites = require('../structures/PermissionOverwrites'); +const { Role } = require('../structures/Role'); + +let cacheWarningEmitted = false; + +/** + * Manages API methods for guild channel permission overwrites and stores their cache. + * @extends {CachedManager} + */ +class PermissionOverwriteManager extends CachedManager { + constructor(channel, iterable) { + super(channel.client, PermissionOverwrites); + if (!cacheWarningEmitted && this._cache.constructor.name !== 'Collection') { + cacheWarningEmitted = true; + process.emitWarning( + `Overriding the cache handling for ${this.constructor.name} is unsupported and breaks functionality.`, + 'UnsupportedCacheOverwriteWarning', + ); + } + + /** + * The channel of the permission overwrite this manager belongs to + * @type {GuildChannel} + */ + this.channel = channel; + + if (iterable) { + for (const item of iterable) { + this._add(item); + } + } + } + + /** + * The cache of this Manager + * @type {Collection} + * @name PermissionOverwriteManager#cache + */ + + _add(data, cache) { + return super._add(data, cache, { extras: [this.channel] }); + } + + /** + * Replaces the permission overwrites in this channel. + * @param {OverwriteResolvable[]|Collection} overwrites + * Permission overwrites the channel gets updated with + * @param {string} [reason] Reason for updating the channel overwrites + * @returns {Promise} + * @example + * message.channel.permissionOverwrites.set([ + * { + * id: message.author.id, + * deny: [PermissionsFlagsBit.ViewChannel], + * }, + * ], 'Needed to change permissions'); + */ + set(overwrites, reason) { + if (!Array.isArray(overwrites) && !(overwrites instanceof Collection)) { + return Promise.reject( + new TypeError('INVALID_TYPE', 'overwrites', 'Array or Collection of Permission Overwrites', true), + ); + } + return this.channel.edit({ permissionOverwrites: overwrites, reason }); + } + + /** + * Extra information about the overwrite + * @typedef {Object} GuildChannelOverwriteOptions + * @property {string} [reason] Reason for creating/editing this overwrite + * @property {number} [type] The type of overwrite, either `0` for a role or `1` for a member. Use this to bypass + * automatic resolution of type that results in an error for uncached structure + */ + + /** + * Creates or edits permission overwrites for a user or role in this channel. + * @param {RoleResolvable|UserResolvable} userOrRole The user or role to update + * @param {PermissionOverwriteOptions} options The options for the update + * @param {GuildChannelOverwriteOptions} [overwriteOptions] The extra information for the update + * @param {PermissionOverwrites} [existing] The existing overwrites to merge with this update + * @returns {Promise} + * @private + */ + async upsert(userOrRole, options, overwriteOptions = {}, existing) { + let userOrRoleId = this.channel.guild.roles.resolveId(userOrRole) ?? this.client.users.resolveId(userOrRole); + let { type, reason } = overwriteOptions; + if (typeof type !== 'number') { + userOrRole = this.channel.guild.roles.resolve(userOrRole) ?? this.client.users.resolve(userOrRole); + if (!userOrRole) throw new TypeError('INVALID_TYPE', 'parameter', 'User nor a Role'); + type = userOrRole instanceof Role ? OverwriteType.Role : OverwriteType.Member; + } + + const { allow, deny } = PermissionOverwrites.resolveOverwriteOptions(options, existing); + + await this.client.api.channels(this.channel.id).permissions(userOrRoleId).put({ + body: { id: userOrRoleId, type, allow, deny }, + reason, + }); + return this.channel; + } + + /** + * Creates permission overwrites for a user or role in this channel, or replaces them if already present. + * @param {RoleResolvable|UserResolvable} userOrRole The user or role to update + * @param {PermissionOverwriteOptions} options The options for the update + * @param {GuildChannelOverwriteOptions} [overwriteOptions] The extra information for the update + * @returns {Promise} + * @example + * // Create or Replace permission overwrites for a message author + * message.channel.permissionOverwrites.create(message.author, { + * SEND_MESSAGES: false + * }) + * .then(channel => console.log(channel.permissionOverwrites.cache.get(message.author.id))) + * .catch(console.error); + */ + create(userOrRole, options, overwriteOptions) { + return this.upsert(userOrRole, options, overwriteOptions); + } + + /** + * Edits permission overwrites for a user or role in this channel, or creates an entry if not already present. + * @param {RoleResolvable|UserResolvable} userOrRole The user or role to update + * @param {PermissionOverwriteOptions} options The options for the update + * @param {GuildChannelOverwriteOptions} [overwriteOptions] The extra information for the update + * @returns {Promise} + * @example + * // Edit or Create permission overwrites for a message author + * message.channel.permissionOverwrites.edit(message.author, { + * SEND_MESSAGES: false + * }) + * .then(channel => console.log(channel.permissionOverwrites.cache.get(message.author.id))) + * .catch(console.error); + */ + edit(userOrRole, options, overwriteOptions) { + userOrRole = this.channel.guild.roles.resolveId(userOrRole) ?? this.client.users.resolveId(userOrRole); + const existing = this.cache.get(userOrRole); + return this.upsert(userOrRole, options, overwriteOptions, existing); + } + + /** + * Deletes permission overwrites for a user or role in this channel. + * @param {UserResolvable|RoleResolvable} userOrRole The user or role to delete + * @param {string} [reason] The reason for deleting the overwrite + * @returns {Promise} + */ + async delete(userOrRole, reason) { + const userOrRoleId = this.channel.guild.roles.resolveId(userOrRole) ?? this.client.users.resolveId(userOrRole); + if (!userOrRoleId) throw new TypeError('INVALID_TYPE', 'parameter', 'User nor a Role'); + + await this.client.api.channels(this.channel.id).permissions(userOrRoleId).delete({ reason }); + return this.channel; + } +} + +module.exports = PermissionOverwriteManager; diff --git a/src/managers/PresenceManager.js b/src/managers/PresenceManager.js new file mode 100644 index 00000000..2d64834 --- /dev/null +++ b/src/managers/PresenceManager.js @@ -0,0 +1,58 @@ +'use strict'; + +const CachedManager = require('./CachedManager'); +const { Presence } = require('../structures/Presence'); + +/** + * Manages API methods for Presences and holds their cache. + * @extends {CachedManager} + */ +class PresenceManager extends CachedManager { + constructor(client, iterable) { + super(client, Presence, iterable); + } + + /** + * The cache of Presences + * @type {Collection} + * @name PresenceManager#cache + */ + + _add(data, cache) { + return super._add(data, cache, { id: data.user.id }); + } + + /** + * Data that can be resolved to a Presence object. This can be: + * * A Presence + * * A UserResolvable + * * A Snowflake + * @typedef {Presence|UserResolvable|Snowflake} PresenceResolvable + */ + + /** + * Resolves a {@link PresenceResolvable} to a {@link Presence} object. + * @param {PresenceResolvable} presence The presence resolvable to resolve + * @returns {?Presence} + */ + resolve(presence) { + const presenceResolvable = super.resolve(presence); + if (presenceResolvable) return presenceResolvable; + const UserResolvable = this.client.users.resolveId(presence); + return super.resolve(UserResolvable); + } + + /** + * Resolves a {@link PresenceResolvable} to a {@link Presence} id. + * @param {PresenceResolvable} presence The presence resolvable to resolve + * @returns {?Snowflake} + */ + resolveId(presence) { + const presenceResolvable = super.resolveId(presence); + if (presenceResolvable) return presenceResolvable; + const userResolvable = this.client.users.resolveId(presence); + return this.cache.has(userResolvable) ? userResolvable : null; + } +} + +module.exports = PresenceManager; diff --git a/src/managers/ReactionManager.js b/src/managers/ReactionManager.js new file mode 100644 index 00000000..b30635e --- /dev/null +++ b/src/managers/ReactionManager.js @@ -0,0 +1,67 @@ +'use strict'; + +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const MessageReaction = require('../structures/MessageReaction'); + +/** + * Manages API methods for reactions and holds their cache. + * @extends {CachedManager} + */ +class ReactionManager extends CachedManager { + constructor(message, iterable) { + super(message.client, MessageReaction, iterable); + + /** + * The message that this manager belongs to + * @type {Message} + */ + this.message = message; + } + + _add(data, cache) { + return super._add(data, cache, { id: data.emoji.id ?? data.emoji.name, extras: [this.message] }); + } + + /** + * The reaction cache of this manager + * @type {Collection} + * @name ReactionManager#cache + */ + + /** + * Data that can be resolved to a MessageReaction object. This can be: + * * A MessageReaction + * * A Snowflake + * @typedef {MessageReaction|Snowflake} MessageReactionResolvable + */ + + /** + * Resolves a {@link MessageReactionResolvable} to a {@link MessageReaction} object. + * @method resolve + * @memberof ReactionManager + * @instance + * @param {MessageReactionResolvable} reaction The MessageReaction to resolve + * @returns {?MessageReaction} + */ + + /** + * Resolves a {@link MessageReactionResolvable} to a {@link MessageReaction} id. + * @method resolveId + * @memberof ReactionManager + * @instance + * @param {MessageReactionResolvable} reaction The MessageReaction to resolve + * @returns {?Snowflake} + */ + + /** + * Removes all reactions from a message. + * @returns {Promise} + */ + async removeAll() { + await this.client.api.channels(this.message.channelId).messages(this.message.id).reactions.delete(); + return this.message; + } +} + +module.exports = ReactionManager; diff --git a/src/managers/ReactionUserManager.js b/src/managers/ReactionUserManager.js new file mode 100644 index 00000000..41f6a03 --- /dev/null +++ b/src/managers/ReactionUserManager.js @@ -0,0 +1,75 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { Error } = require('../errors'); +const User = require('../structures/User'); + +/** + * Manages API methods for users who reacted to a reaction and stores their cache. + * @extends {CachedManager} + */ +class ReactionUserManager extends CachedManager { + constructor(reaction, iterable) { + super(reaction.client, User, iterable); + + /** + * The reaction that this manager belongs to + * @type {MessageReaction} + */ + this.reaction = reaction; + } + + /** + * The cache of this manager + * @type {Collection} + * @name ReactionUserManager#cache + */ + + /** + * Options used to fetch users who gave a reaction. + * @typedef {Object} FetchReactionUsersOptions + * @property {number} [limit=100] The maximum amount of users to fetch, defaults to `100` + * @property {Snowflake} [after] Limit fetching users to those with an id greater than the supplied id + */ + + /** + * Fetches all the users that gave this reaction. Resolves with a collection of users, mapped by their ids. + * @param {FetchReactionUsersOptions} [options] Options for fetching the users + * @returns {Promise>} + */ + async fetch({ limit = 100, after } = {}) { + const message = this.reaction.message; + const query = new URLSearchParams({ limit }); + if (after) { + query.set('after', after); + } + const data = await this.client.api.channels(message.channelId).messages(message.id).reactions(this.reaction.emoji.identifier).get({ query }); + const users = new Collection(); + for (const rawUser of data) { + const user = this.client.users._add(rawUser); + this.cache.set(user.id, user); + users.set(user.id, user); + } + return users; + } + + /** + * Removes a user from this reaction. + * @param {UserResolvable} [user=this.client.user] The user to remove the reaction of + * @returns {Promise} + */ + async remove(user = this.client.user) { + const userId = this.client.users.resolveId(user); + if (!userId) throw new Error('REACTION_RESOLVE_USER'); + const message = this.reaction.message; + await this.client.api.channels[message.channelId] + .messages[message.id] + .reactions[this.reaction.emoji.identifier][userId === this.client.user.id ? '@me' : userId] + .delete(); + return this.reaction; + } +} + +module.exports = ReactionUserManager; diff --git a/src/managers/RoleManager.js b/src/managers/RoleManager.js new file mode 100644 index 00000000..1dd64eb --- /dev/null +++ b/src/managers/RoleManager.js @@ -0,0 +1,351 @@ +'use strict'; + +const process = require('node:process'); +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { TypeError } = require('../errors'); +const { Role } = require('../structures/Role'); +const DataResolver = require('../util/DataResolver'); +const PermissionsBitField = require('../util/PermissionsBitField'); +const { resolveColor } = require('../util/Util'); +const Util = require('../util/Util'); + +let cacheWarningEmitted = false; + +/** + * Manages API methods for roles and stores their cache. + * @extends {CachedManager} + */ +class RoleManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, Role, iterable); + if (!cacheWarningEmitted && this._cache.constructor.name !== 'Collection') { + cacheWarningEmitted = true; + process.emitWarning( + `Overriding the cache handling for ${this.constructor.name} is unsupported and breaks functionality.`, + 'UnsupportedCacheOverwriteWarning', + ); + } + + /** + * The guild belonging to this manager + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The role cache of this manager + * @type {Collection} + * @name RoleManager#cache + */ + + _add(data, cache) { + return super._add(data, cache, { extras: [this.guild] }); + } + + /** + * Obtains a role from Discord, or the role cache if they're already available. + * @param {Snowflake} [id] The role's id + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise>} + * @example + * // Fetch all roles from the guild + * message.guild.roles.fetch() + * .then(roles => console.log(`There are ${roles.size} roles.`)) + * .catch(console.error); + * @example + * // Fetch a single role + * message.guild.roles.fetch('222078108977594368') + * .then(role => console.log(`The role color is: ${role.color}`)) + * .catch(console.error); + */ + async fetch(id, { cache = true, force = false } = {}) { + if (id && !force) { + const existing = this.cache.get(id); + if (existing) return existing; + } + + // We cannot fetch a single role, as of this commit's date, Discord API throws with 405 + const data = await this.client.api.guilds(this.guild.id).roles.get(); + const roles = new Collection(); + for (const role of data) roles.set(role.id, this._add(role, cache)); + return id ? roles.get(id) ?? null : roles; + } + + /** + * Data that can be resolved to a Role object. This can be: + * * A Role + * * A Snowflake + * @typedef {Role|Snowflake} RoleResolvable + */ + + /** + * Resolves a {@link RoleResolvable} to a {@link Role} object. + * @method resolve + * @memberof RoleManager + * @instance + * @param {RoleResolvable} role The role resolvable to resolve + * @returns {?Role} + */ + + /** + * Resolves a {@link RoleResolvable} to a {@link Role} id. + * @method resolveId + * @memberof RoleManager + * @instance + * @param {RoleResolvable} role The role resolvable to resolve + * @returns {?Snowflake} + */ + + /** + * Options used to create a new role. + * @typedef {Object} CreateRoleOptions + * @property {string} [name] The name of the new role + * @property {ColorResolvable} [color] The data to create the role with + * @property {boolean} [hoist] Whether or not the new role should be hoisted + * @property {PermissionResolvable} [permissions] The permissions for the new role + * @property {number} [position] The position of the new role + * @property {boolean} [mentionable] Whether or not the new role should be mentionable + * @property {?(BufferResolvable|Base64Resolvable|EmojiResolvable)} [icon] The icon for the role + * The `EmojiResolvable` should belong to the same guild as the role. + * If not, pass the emoji's URL directly + * @property {?string} [unicodeEmoji] The unicode emoji for the role + * @property {string} [reason] The reason for creating this role + */ + + /** + * Creates a new role in the guild with given information. + * The position will silently reset to 1 if an invalid one is provided, or none. + * @param {CreateRoleOptions} [options] Options for creating the new role + * @returns {Promise} + * @example + * // Create a new role + * guild.roles.create() + * .then(console.log) + * .catch(console.error); + * @example + * // Create a new role with data and a reason + * guild.roles.create({ + * name: 'Super Cool Blue People', + * color: Colors.Blue, + * reason: 'we needed a role for Super Cool People', + * }) + * .then(console.log) + * .catch(console.error); + */ + async create(options = {}) { + let { name, color, hoist, permissions, position, mentionable, reason, icon, unicodeEmoji } = options; + color &&= resolveColor(color); + if (typeof permissions !== 'undefined') permissions = new PermissionsBitField(permissions); + if (icon) { + const guildEmojiURL = this.guild.emojis.resolve(icon)?.url; + icon = guildEmojiURL ? await DataResolver.resolveImage(guildEmojiURL) : await DataResolver.resolveImage(icon); + if (typeof icon !== 'string') icon = undefined; + } + + const data = await this.client.api.guilds(this.guild.id).roles.post({ + body: { + name, + color, + hoist, + permissions, + mentionable, + icon, + unicode_emoji: unicodeEmoji, + }, + reason, + }) + const { role } = this.client.actions.GuildRoleCreate.handle({ + guild_id: this.guild.id, + role: data, + }); + if (position) return this.setPosition(role, position, { reason }); + return role; + } + + /** + * Edits a role of the guild. + * @param {RoleResolvable} role The role to edit + * @param {RoleData} data The new data for the role + * @param {string} [reason] Reason for editing this role + * @returns {Promise} + * @example + * // Edit a role + * guild.roles.edit('222079219327434752', { name: 'buddies' }) + * .then(updated => console.log(`Edited role name to ${updated.name}`)) + * .catch(console.error); + */ + async edit(role, data, reason) { + role = this.resolve(role); + if (!role) throw new TypeError('INVALID_TYPE', 'role', 'RoleResolvable'); + + if (typeof data.position === 'number') { + await this.setPosition(role, data.position, { reason }); + } + + let icon = data.icon; + if (icon) { + const guildEmojiURL = this.guild.emojis.resolve(icon)?.url; + icon = guildEmojiURL ? await DataResolver.resolveImage(guildEmojiURL) : await DataResolver.resolveImage(icon); + if (typeof icon !== 'string') icon = undefined; + } + + const body = { + name: data.name, + color: typeof data.color === 'undefined' ? undefined : resolveColor(data.color), + hoist: data.hoist, + permissions: typeof data.permissions === 'undefined' ? undefined : new PermissionsBitField(data.permissions), + mentionable: data.mentionable, + icon, + unicode_emoji: data.unicodeEmoji, + }; + + const d = await this.client.api.guilds(this.guild.id).roles(role.id).patch({ body, reason }); + + const clone = role._clone(); + clone._patch(d); + return clone; + } + + /** + * Deletes a role. + * @param {RoleResolvable} role The role to delete + * @param {string} [reason] Reason for deleting the role + * @returns {Promise} + * @example + * // Delete a role + * guild.roles.delete('222079219327434752', 'The role needed to go') + * .then(() => console.log('Deleted the role')) + * .catch(console.error); + */ + async delete(role, reason) { + const id = this.resolveId(role); + await this.client.api.guilds(this.guild.id).roles(id).delete({ reason }); + this.client.actions.GuildRoleDelete.handle({ guild_id: this.guild.id, role_id: id }); + } + + /** + * Sets the new position of the role. + * @param {RoleResolvable} role The role to change the position of + * @param {number} position The new position for the role + * @param {SetRolePositionOptions} [options] Options for setting the position + * @returns {Promise} + * @example + * // Set the position of the role + * guild.roles.setPosition('222197033908436994', 1) + * .then(updated => console.log(`Role position: ${updated.position}`)) + * .catch(console.error); + */ + async setPosition(role, position, { relative, reason } = {}) { + role = this.resolve(role); + if (!role) throw new TypeError('INVALID_TYPE', 'role', 'RoleResolvable'); + const updatedRoles = await Util.setPosition( + role, + position, + relative, + this.guild._sortedRoles(), + this.client, + Routes.guildRoles(this.guild.id), + reason, + ); + + this.client.actions.GuildRolesPositionUpdate.handle({ + guild_id: this.guild.id, + roles: updatedRoles, + }); + return role; + } + + /** + * The data needed for updating a guild role's position + * @typedef {Object} GuildRolePosition + * @property {RoleResolvable} role The role's id + * @property {number} position The position to update + */ + + /** + * Batch-updates the guild's role positions + * @param {GuildRolePosition[]} rolePositions Role positions to update + * @returns {Promise} + * @example + * guild.roles.setPositions([{ role: roleId, position: updatedRoleIndex }]) + * .then(guild => console.log(`Role positions updated for ${guild}`)) + * .catch(console.error); + */ + async setPositions(rolePositions) { + // Make sure rolePositions are prepared for API + rolePositions = rolePositions.map(o => ({ + id: this.resolveId(o.role), + position: o.position, + })); + + // Call the API to update role positions + await this.client.api.guilds(this.guild.id).roles.patch({ body: rolePositions }); + return this.client.actions.GuildRolesPositionUpdate.handle({ + guild_id: this.guild.id, + roles: rolePositions, + }).guild; + } + + /** + * Compares the positions of two roles. + * @param {RoleResolvable} role1 First role to compare + * @param {RoleResolvable} role2 Second role to compare + * @returns {number} Negative number if the first role's position is lower (second role's is higher), + * positive number if the first's is higher (second's is lower), 0 if equal + */ + comparePositions(role1, role2) { + const resolvedRole1 = this.resolve(role1); + const resolvedRole2 = this.resolve(role2); + if (!resolvedRole1 || !resolvedRole2) throw new TypeError('INVALID_TYPE', 'role', 'Role nor a Snowflake'); + + if (resolvedRole1.position === resolvedRole2.position) { + return Number(BigInt(resolvedRole2.id) - BigInt(resolvedRole1.id)); + } + + return resolvedRole1.position - resolvedRole2.position; + } + + /** + * Gets the managed role a user created when joining the guild, if any + * Only ever available for bots + * @param {UserResolvable} user The user to access the bot role for + * @returns {?Role} + */ + botRoleFor(user) { + const userId = this.client.users.resolveId(user); + if (!userId) return null; + return this.cache.find(role => role.tags?.botId === userId) ?? null; + } + + /** + * The `@everyone` role of the guild + * @type {Role} + * @readonly + */ + get everyone() { + return this.cache.get(this.guild.id); + } + + /** + * The premium subscriber role of the guild, if any + * @type {?Role} + * @readonly + */ + get premiumSubscriberRole() { + return this.cache.find(role => role.tags?.premiumSubscriberRole) ?? null; + } + + /** + * The role with the highest position in the cache + * @type {Role} + * @readonly + */ + get highest() { + return this.cache.reduce((prev, role) => (role.comparePositionTo(prev) > 0 ? role : prev), this.cache.first()); + } +} + +module.exports = RoleManager; diff --git a/src/managers/StageInstanceManager.js b/src/managers/StageInstanceManager.js new file mode 100644 index 00000000..68f1f77 --- /dev/null +++ b/src/managers/StageInstanceManager.js @@ -0,0 +1,152 @@ +'use strict'; + +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { TypeError, Error } = require('../errors'); +const { StageInstance } = require('../structures/StageInstance'); + +/** + * Manages API methods for {@link StageInstance} objects and holds their cache. + * @extends {CachedManager} + */ +class StageInstanceManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, StageInstance, iterable); + + /** + * The guild this manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The cache of this Manager + * @type {Collection} + * @name StageInstanceManager#cache + */ + + /** + * Options used to create a stage instance. + * @typedef {Object} StageInstanceCreateOptions + * @property {string} topic The topic of the stage instance + * @property {PrivacyLevel|number} [privacyLevel] The privacy level of the stage instance + */ + + /** + * Data that can be resolved to a Stage Channel object. This can be: + * * A StageChannel + * * A Snowflake + * @typedef {StageChannel|Snowflake} StageChannelResolvable + */ + + /** + * Creates a new stage instance. + * @param {StageChannelResolvable} channel The stage channel to associate the created stage instance to + * @param {StageInstanceCreateOptions} options The options to create the stage instance + * @returns {Promise} + * @example + * // Create a stage instance + * guild.stageInstances.create('1234567890123456789', { + * topic: 'A very creative topic', + * privacyLevel: GuildPrivacyLevel.GuildOnly + * }) + * .then(stageInstance => console.log(stageInstance)) + * .catch(console.error); + */ + async create(channel, options) { + const channelId = this.guild.channels.resolveId(channel); + if (!channelId) throw new Error('STAGE_CHANNEL_RESOLVE'); + if (typeof options !== 'object') throw new TypeError('INVALID_TYPE', 'options', 'object', true); + let { topic, privacyLevel } = options; + + const data = await this.client.api['stage-instances'].post({ + body: { + channel_id: channelId, + topic, + privacy_level: privacyLevel, + }, + }); + + return this._add(data); + } + + /** + * Fetches the stage instance associated with a stage channel, if it exists. + * @param {StageChannelResolvable} channel The stage channel whose associated stage instance is to be fetched + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise} + * @example + * // Fetch a stage instance + * guild.stageInstances.fetch('1234567890123456789') + * .then(stageInstance => console.log(stageInstance)) + * .catch(console.error); + */ + async fetch(channel, { cache = true, force = false } = {}) { + const channelId = this.guild.channels.resolveId(channel); + if (!channelId) throw new Error('STAGE_CHANNEL_RESOLVE'); + + if (!force) { + const existing = this.cache.find(stageInstance => stageInstance.channelId === channelId); + if (existing) return existing; + } + + const data = await this.client.api('stage-instances', channelId).get(); + return this._add(data, cache); + } + + /** + * Options used to edit an existing stage instance. + * @typedef {Object} StageInstanceEditOptions + * @property {string} [topic] The new topic of the stage instance + * @property {PrivacyLevel|number} [privacyLevel] The new privacy level of the stage instance + */ + + /** + * Edits an existing stage instance. + * @param {StageChannelResolvable} channel The stage channel whose associated stage instance is to be edited + * @param {StageInstanceEditOptions} options The options to edit the stage instance + * @returns {Promise} + * @example + * // Edit a stage instance + * guild.stageInstances.edit('1234567890123456789', { topic: 'new topic' }) + * .then(stageInstance => console.log(stageInstance)) + * .catch(console.error); + */ + async edit(channel, options) { + if (typeof options !== 'object') throw new TypeError('INVALID_TYPE', 'options', 'object', true); + const channelId = this.guild.channels.resolveId(channel); + if (!channelId) throw new Error('STAGE_CHANNEL_RESOLVE'); + + let { topic, privacyLevel } = options; + + const data = await this.client.api('stage-instances', channelId).patch({ + body: { + topic, + privacy_level: privacyLevel, + }, + }) + + if (this.cache.has(data.id)) { + const clone = this.cache.get(data.id)._clone(); + clone._patch(data); + return clone; + } + + return this._add(data); + } + + /** + * Deletes an existing stage instance. + * @param {StageChannelResolvable} channel The stage channel whose associated stage instance is to be deleted + * @returns {Promise} + */ + async delete(channel) { + const channelId = this.guild.channels.resolveId(channel); + if (!channelId) throw new Error('STAGE_CHANNEL_RESOLVE'); + + await this.client.api('stage-instances', channelId).delete(); + } +} + +module.exports = StageInstanceManager; diff --git a/src/managers/ThreadManager.js b/src/managers/ThreadManager.js new file mode 100644 index 00000000..34cd12e --- /dev/null +++ b/src/managers/ThreadManager.js @@ -0,0 +1,267 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { ChannelType, Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { TypeError } = require('../errors'); +const ThreadChannel = require('../structures/ThreadChannel'); + +/** + * Manages API methods for {@link ThreadChannel} objects and stores their cache. + * @extends {CachedManager} + */ +class ThreadManager extends CachedManager { + constructor(channel, iterable) { + super(channel.client, ThreadChannel, iterable); + + /** + * The channel this Manager belongs to + * @type {NewsChannel|TextChannel} + */ + this.channel = channel; + } + + /** + * The cache of this Manager + * @type {Collection} + * @name ThreadManager#cache + */ + + _add(thread) { + const existing = this.cache.get(thread.id); + if (existing) return existing; + this.cache.set(thread.id, thread); + return thread; + } + + /** + * Data that can be resolved to a Thread Channel object. This can be: + * * A ThreadChannel object + * * A Snowflake + * @typedef {ThreadChannel|Snowflake} ThreadChannelResolvable + */ + + /** + * Resolves a {@link ThreadChannelResolvable} to a {@link ThreadChannel} object. + * @method resolve + * @memberof ThreadManager + * @instance + * @param {ThreadChannelResolvable} thread The ThreadChannel resolvable to resolve + * @returns {?ThreadChannel} + */ + + /** + * Resolves a {@link ThreadChannelResolvable} to a {@link ThreadChannel} id. + * @method resolveId + * @memberof ThreadManager + * @instance + * @param {ThreadChannelResolvable} thread The ThreadChannel resolvable to resolve + * @returns {?Snowflake} + */ + + /** + * Options for creating a thread. Only one of `startMessage` or `type` can be defined. + * @typedef {StartThreadOptions} ThreadCreateOptions + * @property {MessageResolvable} [startMessage] The message to start a thread from. If this is defined then type + * of thread gets automatically defined and cannot be changed. The provided `type` field will be ignored + * @property {ThreadChannelTypes|number} [type] The type of thread to create. + * Defaults to {@link ChannelType.GuildPublicThread} if created in a {@link TextChannel} + * When creating threads in a {@link NewsChannel} this is ignored and is always + * {@link ChannelType.GuildNewsThread} + * @property {boolean} [invitable] Whether non-moderators can add other non-moderators to the thread + * Can only be set when type will be {@link ChannelType.GuildPrivateThread} + */ + + /** + * Creates a new thread in the channel. + * @param {ThreadCreateOptions} [options] Options to create a new thread + * @returns {Promise} + * @example + * // Create a new public thread + * channel.threads + * .create({ + * name: 'food-talk', + * autoArchiveDuration: 60, + * reason: 'Needed a separate thread for food', + * }) + * .then(threadChannel => console.log(threadChannel)) + * .catch(console.error); + * @example + * // Create a new private thread + * channel.threads + * .create({ + * name: 'mod-talk', + * autoArchiveDuration: 60, + * type: ChannelType.GuildPrivateThread, + * reason: 'Needed a separate thread for moderation', + * }) + * .then(threadChannel => console.log(threadChannel)) + * .catch(console.error); + */ + async create({ + name, + autoArchiveDuration = this.channel.defaultAutoArchiveDuration, + startMessage, + type, + invitable, + reason, + rateLimitPerUser, + } = {}) { + if (type && typeof type !== 'string' && typeof type !== 'number') { + throw new TypeError('INVALID_TYPE', 'type', 'ThreadChannelType or Number'); + } + let resolvedType = + this.channel.type === ChannelType.GuildNews ? ChannelType.GuildNewsThread : ChannelType.GuildPublicThread; + let startMessageId; + if (startMessage) { + startMessageId = this.channel.messages.resolveId(startMessage); + if (!startMessageId) throw new TypeError('INVALID_TYPE', 'startMessage', 'MessageResolvable'); + } else if (this.channel.type !== ChannelType.GuildNews) { + resolvedType = type ?? resolvedType; + } + if (autoArchiveDuration === 'MAX') { + autoArchiveDuration = 1440; + if (this.channel.guild.features.includes('SEVEN_DAY_THREAD_ARCHIVE')) { + autoArchiveDuration = 10080; + } else if (this.channel.guild.features.includes('THREE_DAY_THREAD_ARCHIVE')) { + autoArchiveDuration = 4320; + } + } + + const data = await this.client.api.channels(this.channel.id).messages(startMessageId).threads.post({ + body: { + name, + auto_archive_duration: autoArchiveDuration, + type: resolvedType, + invitable: resolvedType === ChannelType.GuildPrivateThread ? invitable : undefined, + rate_limit_per_user: rateLimitPerUser, + }, + reason, + }); + + return this.client.actions.ThreadCreate.handle(data).thread; + } + + /** + * The options for fetching multiple threads, the properties are mutually exclusive + * @typedef {Object} FetchThreadsOptions + * @property {FetchArchivedThreadOptions} [archived] The options used to fetch archived threads + * @property {boolean} [active] When true, fetches active threads. If `archived` is set, this is ignored! + */ + + /** + * Obtains a thread from Discord, or the channel cache if it's already available. + * @param {ThreadChannelResolvable|FetchThreadsOptions} [options] The options to fetch threads. If it is a + * ThreadChannelResolvable then the specified thread will be fetched. Fetches all active threads if `undefined` + * @param {BaseFetchOptions} [cacheOptions] Additional options for this fetch. The `force` field gets ignored + * if `options` is not a {@link ThreadChannelResolvable} + * @returns {Promise} + * @example + * // Fetch a thread by its id + * channel.threads.fetch('831955138126104859') + * .then(channel => console.log(channel.name)) + * .catch(console.error); + */ + fetch(options, { cache = true, force = false } = {}) { + if (!options) return this.fetchActive(cache); + const channel = this.client.channels.resolveId(options); + if (channel) return this.client.channels.fetch(channel, cache, force); + if (options.archived) { + return this.fetchArchived(options.archived, cache); + } + return this.fetchActive(cache); + } + + /** + * Data that can be resolved to a Date object. This can be: + * * A Date object + * * A number representing a timestamp + * * An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) string + * @typedef {Date|number|string} DateResolvable + */ + + /** + * The options used to fetch archived threads. + * @typedef {Object} FetchArchivedThreadOptions + * @property {string} [type='public'] The type of threads to fetch, either `public` or `private` + * @property {boolean} [fetchAll=false] Whether to fetch **all** archived threads when type is `private`. + * Requires `MANAGE_THREADS` if true + * @property {DateResolvable|ThreadChannelResolvable} [before] Only return threads that were created before this Date + * or Snowflake. Must be a {@link ThreadChannelResolvable} when type is `private` and fetchAll is `false` + * @property {number} [limit] Maximum number of threads to return + */ + + /** + * The data returned from a thread fetch that returns multiple threads. + * @typedef {Object} FetchedThreads + * @property {Collection} threads The threads that were fetched, with any members returned + * @property {?boolean} hasMore Whether there are potentially additional threads that require a subsequent call + */ + + /** + * Obtains a set of archived threads from Discord, requires `READ_MESSAGE_HISTORY` in the parent channel. + * @param {FetchArchivedThreadOptions} [options] The options to fetch archived threads + * @param {boolean} [cache=true] Whether to cache the new thread objects if they aren't already + * @returns {Promise} + */ + async fetchArchived({ type = 'public', fetchAll = false, before, limit } = {}, cache = true) { + let path = this.client.api.channels(this.channel.id); + if (type === 'private' && !fetchAll) { + path = path.users('@me'); + } + let timestamp; + let id; + const query = new URLSearchParams(); + if (typeof before !== 'undefined') { + if (before instanceof ThreadChannel || /^\d{16,19}$/.test(String(before))) { + id = this.resolveId(before); + timestamp = this.resolve(before)?.archivedAt?.toISOString(); + const toUse = type === 'private' && !fetchAll ? id : timestamp; + if (toUse) { + query.set('before', toUse); + } + } else { + try { + timestamp = new Date(before).toISOString(); + if (type === 'public' || fetchAll) { + query.set('before', timestamp); + } + } catch { + throw new TypeError('INVALID_TYPE', 'before', 'DateResolvable or ThreadChannelResolvable'); + } + } + } + + if (limit) { + query.set('limit', limit); + } + const raw = await path.threads.archived(type).get({ query }); + return this.constructor._mapThreads(raw, this.client, { parent: this.channel, cache }); + } + + /** + * Obtains the accessible active threads from Discord, requires `READ_MESSAGE_HISTORY` in the parent channel. + * @param {boolean} [cache=true] Whether to cache the new thread objects if they aren't already + * @returns {Promise} + */ + async fetchActive(cache = true) { + const raw = await this.client.api.guilds(this.channel.guild.id).threads.active.get(); + return this.constructor._mapThreads(raw, this.client, { parent: this.channel, cache }); + } + + static _mapThreads(rawThreads, client, { parent, guild, cache }) { + const threads = rawThreads.threads.reduce((coll, raw) => { + const thread = client.channels._add(raw, guild ?? parent?.guild, { cache }); + if (parent && thread.parentId !== parent.id) return coll; + return coll.set(thread.id, thread); + }, new Collection()); + // Discord sends the thread id as id in this object + for (const rawMember of rawThreads.members) client.channels.cache.get(rawMember.id)?.members._add(rawMember); + return { + threads, + hasMore: rawThreads.has_more ?? false, + }; + } +} + +module.exports = ThreadManager; diff --git a/src/managers/ThreadMemberManager.js b/src/managers/ThreadMemberManager.js new file mode 100644 index 00000000..366894d --- /dev/null +++ b/src/managers/ThreadMemberManager.js @@ -0,0 +1,128 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { TypeError } = require('../errors'); +const ThreadMember = require('../structures/ThreadMember'); + +/** + * Manages API methods for GuildMembers and stores their cache. + * @extends {CachedManager} + */ +class ThreadMemberManager extends CachedManager { + constructor(thread, iterable) { + super(thread.client, ThreadMember, iterable); + + /** + * The thread this manager belongs to + * @type {ThreadChannel} + */ + this.thread = thread; + } + + /** + * The cache of this Manager + * @type {Collection} + * @name ThreadMemberManager#cache + */ + + _add(data, cache = true) { + const existing = this.cache.get(data.user_id); + if (cache) existing?._patch(data); + if (existing) return existing; + + const member = new ThreadMember(this.thread, data); + if (cache) this.cache.set(data.user_id, member); + return member; + } + + /** + * Data that resolves to give a ThreadMember object. This can be: + * * A ThreadMember object + * * A User resolvable + * @typedef {ThreadMember|UserResolvable} ThreadMemberResolvable + */ + + /** + * Resolves a {@link ThreadMemberResolvable} to a {@link ThreadMember} object. + * @param {ThreadMemberResolvable} member The user that is part of the thread + * @returns {?GuildMember} + */ + resolve(member) { + const memberResolvable = super.resolve(member); + if (memberResolvable) return memberResolvable; + const userResolvable = this.client.users.resolveId(member); + if (userResolvable) return super.resolve(userResolvable); + return null; + } + + /** + * Resolves a {@link ThreadMemberResolvable} to a {@link ThreadMember} id string. + * @param {ThreadMemberResolvable} member The user that is part of the guild + * @returns {?Snowflake} + */ + resolveId(member) { + const memberResolvable = super.resolveId(member); + if (memberResolvable) return memberResolvable; + const userResolvable = this.client.users.resolveId(member); + return this.cache.has(userResolvable) ? userResolvable : null; + } + + /** + * Adds a member to the thread. + * @param {UserResolvable|'@me'} member The member to add + * @param {string} [reason] The reason for adding this member + * @returns {Promise} + */ + async add(member, reason) { + const id = member === '@me' ? member : this.client.users.resolveId(member); + if (!id) throw new TypeError('INVALID_TYPE', 'member', 'UserResolvable'); + await this.client.api.channels(this.thread.id, 'thread-members', id).put({ reason }); + return id; + } + + /** + * Remove a user from the thread. + * @param {Snowflake|'@me'} id The id of the member to remove + * @param {string} [reason] The reason for removing this member from the thread + * @returns {Promise} + */ + async remove(id, reason) { + await this.client.api.channels(this.thread.id, 'thread-members', id).delete({ reason }); + return id; + } + + async _fetchOne(memberId, cache, force) { + if (!force) { + const existing = this.cache.get(memberId); + if (existing) return existing; + } + + const data = await this.client.api.channels(this.thread.id, 'thread-members', memberId).get(); + return this._add(data, cache); + } + + async _fetchMany(cache) { + const raw = await this.client.api.channels(this.thread.id, 'thread-members'); + return raw.reduce((col, member) => col.set(member.user_id, this._add(member, cache)), new Collection()); + } + + /** + * @typedef {BaseFetchOptions} ThreadMemberFetchOptions + * @property {UserResolvable} [member] The specific user to fetch from the thread + */ + + /** + * Fetches member(s) for the thread from Discord, requires access to the `GUILD_MEMBERS` gateway intent. + * @param {ThreadMemberFetchOptions|boolean} [options] Additional options for this fetch, when a `boolean` is provided + * all members are fetched with `options.cache` set to the boolean value + * @returns {Promise>} + */ + fetch({ member, cache = true, force = false } = {}) { + const id = this.resolveId(member); + return id ? this._fetchOne(id, cache, force) : this._fetchMany(member ?? cache); + } +} + +module.exports = ThreadMemberManager; diff --git a/src/managers/UserManager.js b/src/managers/UserManager.js new file mode 100644 index 00000000..fc6b8e9 --- /dev/null +++ b/src/managers/UserManager.js @@ -0,0 +1,140 @@ +'use strict'; + +const { ChannelType, Routes } = require('discord-api-types/v9'); +const CachedManager = require('./CachedManager'); +const { GuildMember } = require('../structures/GuildMember'); +const { Message } = require('../structures/Message'); +const ThreadMember = require('../structures/ThreadMember'); +const User = require('../structures/User'); + +/** + * Manages API methods for users and stores their cache. + * @extends {CachedManager} + */ +class UserManager extends CachedManager { + constructor(client, iterable) { + super(client, User, iterable); + } + + /** + * The cache of this manager + * @type {Collection} + * @name UserManager#cache + */ + + /** + * Data that resolves to give a User object. This can be: + * * A User object + * * A Snowflake + * * A Message object (resolves to the message author) + * * A GuildMember object + * * A ThreadMember object + * @typedef {User|Snowflake|Message|GuildMember|ThreadMember} UserResolvable + */ + + /** + * The DM between the client's user and a user + * @param {Snowflake} userId The user id + * @returns {?DMChannel} + * @private + */ + dmChannel(userId) { + return this.client.channels.cache.find(c => c.type === ChannelType.DM && c.recipient.id === userId) ?? null; + } + + /** + * Creates a {@link DMChannel} between the client and a user. + * @param {UserResolvable} user The UserResolvable to identify + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise} + */ + async createDM(user, { cache = true, force = false } = {}) { + const id = this.resolveId(user); + + if (!force) { + const dmChannel = this.dmChannel(id); + if (dmChannel && !dmChannel.partial) return dmChannel; + } + + const data = await this.client.api.users('@me').channels.post({ body: { recipient_id: id } }); + return this.client.channels._add(data, null, { cache }); + } + + /** + * Deletes a {@link DMChannel} (if one exists) between the client and a user. Resolves with the channel if successful. + * @param {UserResolvable} user The UserResolvable to identify + * @returns {Promise} + */ + async deleteDM(user) { + const id = this.resolveId(user); + const dmChannel = this.dmChannel(id); + if (!dmChannel) throw new Error('USER_NO_DM_CHANNEL'); + await this.client.channels(dmChannel.id).delete(); + this.client.channels._remove(dmChannel.id); + return dmChannel; + } + + /** + * Obtains a user from Discord, or the user cache if it's already available. + * @param {UserResolvable} user The user to fetch + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise} + */ + async fetch(user, { cache = true, force = false } = {}) { + const id = this.resolveId(user); + if (!force) { + const existing = this.cache.get(id); + if (existing && !existing.partial) return existing; + } + + const data = await this.client.api.users(id).get(); + const userObject = this._add(data, cache); + if(!this.user.bot) await userObject.getProfile().catch(() => {}); + return userObject; + } + + /** + * Fetches a user's flags. + * @param {UserResolvable} user The UserResolvable to identify + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise} + */ + async fetchFlags(user, options) { + return (await this.fetch(user, options)).flags; + } + + /** + * Sends a message to a user. + * @param {UserResolvable} user The UserResolvable to identify + * @param {string|MessagePayload|MessageOptions} options The options to provide + * @returns {Promise} + */ + async send(user, options) { + return (await this.createDM(user)).send(options); + } + + /** + * Resolves a {@link UserResolvable} to a {@link User} object. + * @param {UserResolvable} user The UserResolvable to identify + * @returns {?User} + */ + resolve(user) { + if (user instanceof GuildMember || user instanceof ThreadMember) return user.user; + if (user instanceof Message) return user.author; + return super.resolve(user); + } + + /** + * Resolves a {@link UserResolvable} to a {@link User} id. + * @param {UserResolvable} user The UserResolvable to identify + * @returns {?Snowflake} + */ + resolveId(user) { + if (user instanceof ThreadMember) return user.id; + if (user instanceof GuildMember) return user.user.id; + if (user instanceof Message) return user.author.id; + return super.resolveId(user); + } +} + +module.exports = UserManager; diff --git a/src/managers/VoiceStateManager.js b/src/managers/VoiceStateManager.js new file mode 100644 index 00000000..c42fdd2 --- /dev/null +++ b/src/managers/VoiceStateManager.js @@ -0,0 +1,37 @@ +'use strict'; + +const CachedManager = require('./CachedManager'); +const VoiceState = require('../structures/VoiceState'); + +/** + * Manages API methods for VoiceStates and stores their cache. + * @extends {CachedManager} + */ +class VoiceStateManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, VoiceState, iterable); + + /** + * The guild this manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The cache of this manager + * @type {Collection} + * @name VoiceStateManager#cache + */ + + _add(data, cache = true) { + const existing = this.cache.get(data.user_id); + if (existing) return existing._patch(data); + + const entry = new this.holds(this.guild, data); + if (cache) this.cache.set(data.user_id, entry); + return entry; + } +} + +module.exports = VoiceStateManager; diff --git a/src/rest/APIRequest.js b/src/rest/APIRequest.js new file mode 100644 index 00000000..beeeb50 --- /dev/null +++ b/src/rest/APIRequest.js @@ -0,0 +1,80 @@ +'use strict'; + +const https = require('node:https'); +const { setTimeout } = require('node:timers'); +const FormData = require('form-data'); +const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args)); +const { UserAgent } = require('../util/Constants'); + +let agent = null; + +class APIRequest { + constructor(rest, method, path, options) { + this.rest = rest; + this.client = rest.client; + this.method = method; + this.route = options.route; + this.options = options; + this.retries = 0; + + let queryString = ''; + if (options.query) { + const query = Object.entries(options.query) + .filter(([, value]) => value !== null && typeof value !== 'undefined') + .flatMap(([key, value]) => (Array.isArray(value) ? value.map(v => [key, v]) : [[key, value]])); + queryString = new URLSearchParams(query).toString(); + } + this.path = `${path}${queryString && `?${queryString}`}`; + } + + async make() { + agent ??= new https.Agent({ ...this.client.options.http.agent, keepAlive: true }); + + const API = + this.options.versioned === false + ? this.client.options.http.api + : `${this.client.options.http.api}/v${this.client.options.http.version}`; + const url = API + this.path; + + let headers = { + ...this.client.options.http.headers, + 'User-Agent': UserAgent, + }; + + if (this.options.auth !== false) headers.Authorization = this.rest.getAuth(); + if (this.options.reason) headers['X-Audit-Log-Reason'] = encodeURIComponent(this.options.reason); + if (this.options.headers) headers = Object.assign(headers, this.options.headers); + + let body; + if (this.options.files?.length) { + body = new FormData(); + for (const [index, file] of this.options.files.entries()) { + if (file?.file) body.append(file.key ?? `files[${index}]`, file.file, file.name); + } + if (typeof this.options.data !== 'undefined') { + if (this.options.dontUsePayloadJSON) { + for (const [key, value] of Object.entries(this.options.data)) body.append(key, value); + } else { + body.append('payload_json', JSON.stringify(this.options.data)); + } + } + headers = Object.assign(headers, body.getHeaders()); + // eslint-disable-next-line eqeqeq + } else if (this.options.data != null) { + body = JSON.stringify(this.options.data); + headers['Content-Type'] = 'application/json'; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.client.options.restRequestTimeout).unref(); + return fetch(url, { + method: this.method, + headers, + agent, + body, + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + } +} + +module.exports = APIRequest; \ No newline at end of file diff --git a/src/rest/APIRouter.js b/src/rest/APIRouter.js new file mode 100644 index 00000000..6923a30 --- /dev/null +++ b/src/rest/APIRouter.js @@ -0,0 +1,53 @@ +'use strict'; + +const noop = () => {}; // eslint-disable-line no-empty-function +const methods = ['get', 'post', 'delete', 'patch', 'put']; +const reflectors = [ + 'toString', + 'valueOf', + 'inspect', + 'constructor', + Symbol.toPrimitive, + Symbol.for('nodejs.util.inspect.custom'), +]; + +function buildRoute(manager) { + const route = ['']; + const handler = { + get(target, name) { + if (reflectors.includes(name)) return () => route.join('/'); + if (methods.includes(name)) { + const routeBucket = []; + for (let i = 0; i < route.length; i++) { + // Reactions routes and sub-routes all share the same bucket + if (route[i - 1] === 'reactions') break; + // Literal ids should only be taken account if they are the Major id (the Channel/Guild id) + if (/\d{16,19}/g.test(route[i]) && !/channels|guilds/.test(route[i - 1])) routeBucket.push(':id'); + // All other parts of the route should be considered as part of the bucket identifier + else routeBucket.push(route[i]); + } + return options => + manager.request( + name, + route.join('/'), + Object.assign( + { + versioned: manager.versioned, + route: routeBucket.join('/'), + }, + options, + ), + ); + } + route.push(name); + return new Proxy(noop, handler); + }, + apply(target, _, args) { + route.push(...args.filter(x => x != null)); // eslint-disable-line eqeqeq + return new Proxy(noop, handler); + }, + }; + return new Proxy(noop, handler); +} + +module.exports = buildRoute; \ No newline at end of file diff --git a/src/rest/DiscordAPIError.js b/src/rest/DiscordAPIError.js new file mode 100644 index 00000000..4e75562 --- /dev/null +++ b/src/rest/DiscordAPIError.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Represents an error from the Discord API. + * @extends Error + */ +class DiscordAPIError extends Error { + constructor(error, status, request) { + super(); + const flattened = this.constructor.flattenErrors(error.errors ?? error).join('\n'); + this.name = 'DiscordAPIError'; + this.message = error.message && flattened ? `${error.message}\n${flattened}` : error.message ?? flattened; + + /** + * The HTTP method used for the request + * @type {string} + */ + this.method = request.method; + + /** + * The path of the request relative to the HTTP endpoint + * @type {string} + */ + this.path = request.path; + + /** + * HTTP error code returned by Discord + * @type {number} + */ + this.code = error.code; + + /** + * The HTTP status code + * @type {number} + */ + this.httpStatus = status; + + /** + * The data associated with the request that caused this error + * @type {HTTPErrorData} + */ + this.requestData = { + json: request.options.data, + files: request.options.files ?? [], + }; + } + + /** + * Flattens an errors object returned from the API into an array. + * @param {APIError} obj Discord errors object + * @param {string} [key] Used internally to determine key names of nested fields + * @returns {string[]} + * @private + */ + static flattenErrors(obj, key = '') { + let messages = []; + + for (const [k, v] of Object.entries(obj)) { + if (k === 'message') continue; + const newKey = key ? (isNaN(k) ? `${key}.${k}` : `${key}[${k}]`) : k; + + if (v._errors) { + messages.push(`${newKey}: ${v._errors.map(e => e.message).join(' ')}`); + } else if (v.code ?? v.message) { + messages.push(`${v.code ? `${v.code}: ` : ''}${v.message}`.trim()); + } else if (typeof v === 'string') { + messages.push(v); + } else { + messages = messages.concat(this.flattenErrors(v, newKey)); + } + } + + return messages; + } +} + +module.exports = DiscordAPIError; + +/** + * @external APIError + * @see {@link https://discord.com/developers/docs/reference#error-messages} + */ \ No newline at end of file diff --git a/src/rest/HTTPError.js b/src/rest/HTTPError.js new file mode 100644 index 00000000..cec44e9 --- /dev/null +++ b/src/rest/HTTPError.js @@ -0,0 +1,61 @@ +'use strict'; + +/** + * Represents an HTTP error from a request. + * @extends Error + */ +class HTTPError extends Error { + constructor(message, name, code, request) { + super(message); + + /** + * The name of the error + * @type {string} + */ + this.name = name; + + /** + * HTTP error code returned from the request + * @type {number} + */ + this.code = code ?? 500; + + /** + * The HTTP method used for the request + * @type {string} + */ + this.method = request.method; + + /** + * The path of the request relative to the HTTP endpoint + * @type {string} + */ + this.path = request.path; + + /** + * The HTTP data that was sent to Discord + * @typedef {Object} HTTPErrorData + * @property {*} json The JSON data that was sent + * @property {HTTPAttachmentData[]} files The files that were sent with this request, if any + */ + + /** + * The attachment data that is sent to Discord + * @typedef {Object} HTTPAttachmentData + * @property {string|Buffer|Stream} attachment The source of this attachment data + * @property {string} name The file name + * @property {Buffer|Stream} file The file buffer + */ + + /** + * The data associated with the request that caused this error + * @type {HTTPErrorData} + */ + this.requestData = { + json: request.options.data, + files: request.options.files ?? [], + }; + } +} + +module.exports = HTTPError; \ No newline at end of file diff --git a/src/rest/RESTManager.js b/src/rest/RESTManager.js new file mode 100644 index 00000000..84cecf2 --- /dev/null +++ b/src/rest/RESTManager.js @@ -0,0 +1,63 @@ +'use strict'; + +const { setInterval } = require('node:timers'); +const { Collection } = require('@discordjs/collection'); +const APIRequest = require('./APIRequest'); +const routeBuilder = require('./APIRouter'); +const RequestHandler = require('./RequestHandler'); +const { Error } = require('../errors'); +const { Endpoints } = require('../util/Constants'); + +class RESTManager { + constructor(client) { + this.client = client; + this.handlers = new Collection(); + this.versioned = true; + this.globalLimit = client.options.restGlobalRateLimit > 0 ? client.options.restGlobalRateLimit : Infinity; + this.globalRemaining = this.globalLimit; + this.globalReset = null; + this.globalDelay = null; + if (client.options.restSweepInterval > 0) { + this.sweepInterval = setInterval(() => { + this.handlers.sweep(handler => handler._inactive); + }, client.options.restSweepInterval * 1_000).unref(); + } + } + + get api() { + return routeBuilder(this); + } + + getAuth() { + const token = this.client.token ?? this.client.accessToken; + if (token && !this.client.bot) return `${token}`; + else if(token && this.client.bot) return `Bot ${token}`; + throw new Error('TOKEN_MISSING'); + } + + get cdn() { + return Endpoints.CDN(this.client.options.http.cdn); + } + + request(method, url, options = {}) { + const apiRequest = new APIRequest(this, method, url, options); + let handler = this.handlers.get(apiRequest.route); + + if (!handler) { + handler = new RequestHandler(this); + this.handlers.set(apiRequest.route, handler); + } + + return handler.push(apiRequest); + } + + get endpoint() { + return this.client.options.http.api; + } + + set endpoint(endpoint) { + this.client.options.http.api = endpoint; + } +} + +module.exports = RESTManager; \ No newline at end of file diff --git a/src/rest/RateLimitError.js b/src/rest/RateLimitError.js new file mode 100644 index 00000000..0be61d0 --- /dev/null +++ b/src/rest/RateLimitError.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * Represents a RateLimit error from a request. + * @extends Error + */ +class RateLimitError extends Error { + constructor({ timeout, limit, method, path, route, global }) { + super(`A ${global ? 'global ' : ''}rate limit was hit on route ${route}`); + + /** + * The name of the error + * @type {string} + */ + this.name = 'RateLimitError'; + + /** + * Time until this rate limit ends, in ms + * @type {number} + */ + this.timeout = timeout; + + /** + * The HTTP method used for the request + * @type {string} + */ + this.method = method; + + /** + * The path of the request relative to the HTTP endpoint + * @type {string} + */ + this.path = path; + + /** + * The route of the request relative to the HTTP endpoint + * @type {string} + */ + this.route = route; + + /** + * Whether this rate limit is global + * @type {boolean} + */ + this.global = global; + + /** + * The maximum amount of requests of this endpoint + * @type {number} + */ + this.limit = limit; + } +} + +module.exports = RateLimitError; \ No newline at end of file diff --git a/src/rest/RequestHandler.js b/src/rest/RequestHandler.js new file mode 100644 index 00000000..59d197f --- /dev/null +++ b/src/rest/RequestHandler.js @@ -0,0 +1,379 @@ +'use strict'; + +const { setTimeout } = require('node:timers'); +const { setTimeout: sleep } = require('node:timers/promises'); +const { AsyncQueue } = require('@sapphire/async-queue'); +const DiscordAPIError = require('./DiscordAPIError'); +const HTTPError = require('./HTTPError'); +const RateLimitError = require('./RateLimitError'); +const { + Events: { DEBUG, RATE_LIMIT, INVALID_REQUEST_WARNING, API_RESPONSE, API_REQUEST }, +} = require('../util/Constants'); + +function parseResponse(res) { + if (res.headers.get('content-type').startsWith('application/json')) return res.json(); + return res.buffer(); +} + +function getAPIOffset(serverDate) { + return new Date(serverDate).getTime() - Date.now(); +} + +function calculateReset(reset, resetAfter, serverDate) { + // Use direct reset time when available, server date becomes irrelevant in this case + if (resetAfter) { + return Date.now() + Number(resetAfter) * 1_000; + } + return new Date(Number(reset) * 1_000).getTime() - getAPIOffset(serverDate); +} + +/* Invalid request limiting is done on a per-IP basis, not a per-token basis. + * The best we can do is track invalid counts process-wide (on the theory that + * users could have multiple bots run from one process) rather than per-bot. + * Therefore, store these at file scope here rather than in the client's + * RESTManager object. + */ +let invalidCount = 0; +let invalidCountResetTime = null; + +class RequestHandler { + constructor(manager) { + this.manager = manager; + this.queue = new AsyncQueue(); + this.reset = -1; + this.remaining = -1; + this.limit = -1; + } + + async push(request) { + await this.queue.wait(); + try { + return await this.execute(request); + } finally { + this.queue.shift(); + } + } + + get globalLimited() { + return this.manager.globalRemaining <= 0 && Date.now() < this.manager.globalReset; + } + + get localLimited() { + return this.remaining <= 0 && Date.now() < this.reset; + } + + get limited() { + return this.globalLimited || this.localLimited; + } + + get _inactive() { + return this.queue.remaining === 0 && !this.limited; + } + + globalDelayFor(ms) { + return new Promise(resolve => { + setTimeout(() => { + this.manager.globalDelay = null; + resolve(); + }, ms).unref(); + }); + } + + /* + * Determines whether the request should be queued or whether a RateLimitError should be thrown + */ + async onRateLimit(request, limit, timeout, isGlobal) { + const { options } = this.manager.client; + if (!options.rejectOnRateLimit) return; + + const rateLimitData = { + timeout, + limit, + method: request.method, + path: request.path, + route: request.route, + global: isGlobal, + }; + const shouldThrow = + typeof options.rejectOnRateLimit === 'function' + ? await options.rejectOnRateLimit(rateLimitData) + : options.rejectOnRateLimit.some(route => rateLimitData.route.startsWith(route.toLowerCase())); + if (shouldThrow) { + throw new RateLimitError(rateLimitData); + } + } + + async execute(request) { + /* + * After calculations have been done, pre-emptively stop further requests + * Potentially loop until this task can run if e.g. the global rate limit is hit twice + */ + while (this.limited) { + const isGlobal = this.globalLimited; + let limit, timeout, delayPromise; + + if (isGlobal) { + // Set the variables based on the global rate limit + limit = this.manager.globalLimit; + timeout = this.manager.globalReset + this.manager.client.options.restTimeOffset - Date.now(); + } else { + // Set the variables based on the route-specific rate limit + limit = this.limit; + timeout = this.reset + this.manager.client.options.restTimeOffset - Date.now(); + } + + if (this.manager.client.listenerCount(RATE_LIMIT)) { + /** + * Emitted when the client hits a rate limit while making a request + * @event BaseClient#rateLimit + * @param {RateLimitData} rateLimitData Object containing the rate limit info + */ + this.manager.client.emit(RATE_LIMIT, { + timeout, + limit, + method: request.method, + path: request.path, + route: request.route, + global: isGlobal, + }); + } + + if (isGlobal) { + // If this is the first task to reach the global timeout, set the global delay + if (!this.manager.globalDelay) { + // The global delay function should clear the global delay state when it is resolved + this.manager.globalDelay = this.globalDelayFor(timeout); + } + delayPromise = this.manager.globalDelay; + } else { + delayPromise = sleep(timeout); + } + + // Determine whether a RateLimitError should be thrown + await this.onRateLimit(request, limit, timeout, isGlobal); // eslint-disable-line no-await-in-loop + + // Wait for the timeout to expire in order to avoid an actual 429 + await delayPromise; // eslint-disable-line no-await-in-loop + } + + // As the request goes out, update the global usage information + if (!this.manager.globalReset || this.manager.globalReset < Date.now()) { + this.manager.globalReset = Date.now() + 1_000; + this.manager.globalRemaining = this.manager.globalLimit; + } + this.manager.globalRemaining--; + + /** + * Represents a request that will or has been made to the Discord API + * @typedef {Object} APIRequest + * @property {HTTPMethod} method The HTTP method used in this request + * @property {string} path The full path used to make the request + * @property {string} route The API route identifying the rate limit for this request + * @property {Object} options Additional options for this request + * @property {number} retries The number of times this request has been attempted + */ + + if (this.manager.client.listenerCount(API_REQUEST)) { + /** + * Emitted before every API request. + * This event can emit several times for the same request, e.g. when hitting a rate limit. + * This is an informational event that is emitted quite frequently, + * it is highly recommended to check `request.path` to filter the data. + * @event BaseClient#apiRequest + * @param {APIRequest} request The request that is about to be sent + */ + this.manager.client.emit(API_REQUEST, { + method: request.method, + path: request.path, + route: request.route, + options: request.options, + retries: request.retries, + }); + } + + // Perform the request + let res; + try { + res = await request.make(); + } catch (error) { + // Retry the specified number of times for request abortions + if (request.retries === this.manager.client.options.retryLimit) { + throw new HTTPError(error.message, error.constructor.name, error.status, request); + } + + request.retries++; + return this.execute(request); + } + + if (this.manager.client.listenerCount(API_RESPONSE)) { + /** + * Emitted after every API request has received a response. + * This event does not necessarily correlate to completion of the request, e.g. when hitting a rate limit. + * This is an informational event that is emitted quite frequently, + * it is highly recommended to check `request.path` to filter the data. + * @event BaseClient#apiResponse + * @param {APIRequest} request The request that triggered this response + * @param {Response} response The response received from the Discord API + */ + this.manager.client.emit( + API_RESPONSE, + { + method: request.method, + path: request.path, + route: request.route, + options: request.options, + retries: request.retries, + }, + res.clone(), + ); + } + + let sublimitTimeout; + if (res.headers) { + const serverDate = res.headers.get('date'); + const limit = res.headers.get('x-ratelimit-limit'); + const remaining = res.headers.get('x-ratelimit-remaining'); + const reset = res.headers.get('x-ratelimit-reset'); + const resetAfter = res.headers.get('x-ratelimit-reset-after'); + this.limit = limit ? Number(limit) : Infinity; + this.remaining = remaining ? Number(remaining) : 1; + + this.reset = reset || resetAfter ? calculateReset(reset, resetAfter, serverDate) : Date.now(); + + // https://github.com/discord/discord-api-docs/issues/182 + if (!resetAfter && request.route.includes('reactions')) { + this.reset = new Date(serverDate).getTime() - getAPIOffset(serverDate) + 250; + } + + // Handle retryAfter, which means we have actually hit a rate limit + let retryAfter = res.headers.get('retry-after'); + retryAfter = retryAfter ? Number(retryAfter) * 1_000 : -1; + if (retryAfter > 0) { + // If the global rate limit header is set, that means we hit the global rate limit + if (res.headers.get('x-ratelimit-global')) { + this.manager.globalRemaining = 0; + this.manager.globalReset = Date.now() + retryAfter; + } else if (!this.localLimited) { + /* + * This is a sublimit (e.g. 2 channel name changes/10 minutes) since the headers don't indicate a + * route-wide rate limit. Don't update remaining or reset to avoid rate limiting the whole + * endpoint, just set a reset time on the request itself to avoid retrying too soon. + */ + sublimitTimeout = retryAfter; + } + } + } + + // Count the invalid requests + if (res.status === 401 || res.status === 403 || res.status === 429) { + if (!invalidCountResetTime || invalidCountResetTime < Date.now()) { + invalidCountResetTime = Date.now() + 1_000 * 60 * 10; + invalidCount = 0; + } + invalidCount++; + + const emitInvalid = + this.manager.client.listenerCount(INVALID_REQUEST_WARNING) && + this.manager.client.options.invalidRequestWarningInterval > 0 && + invalidCount % this.manager.client.options.invalidRequestWarningInterval === 0; + if (emitInvalid) { + /** + * @typedef {Object} InvalidRequestWarningData + * @property {number} count Number of invalid requests that have been made in the window + * @property {number} remainingTime Time in ms remaining before the count resets + */ + + /** + * Emitted periodically when the process sends invalid requests to let users avoid the + * 10k invalid requests in 10 minutes threshold that causes a ban + * @event BaseClient#invalidRequestWarning + * @param {InvalidRequestWarningData} invalidRequestWarningData Object containing the invalid request info + */ + this.manager.client.emit(INVALID_REQUEST_WARNING, { + count: invalidCount, + remainingTime: invalidCountResetTime - Date.now(), + }); + } + } + + // Handle 2xx and 3xx responses + if (res.ok) { + // Nothing wrong with the request, proceed with the next one + return parseResponse(res); + } + + // Handle 4xx responses + if (res.status >= 400 && res.status < 500) { + // Handle ratelimited requests + if (res.status === 429) { + const isGlobal = this.globalLimited; + let limit, timeout; + if (isGlobal) { + // Set the variables based on the global rate limit + limit = this.manager.globalLimit; + timeout = this.manager.globalReset + this.manager.client.options.restTimeOffset - Date.now(); + } else { + // Set the variables based on the route-specific rate limit + limit = this.limit; + timeout = this.reset + this.manager.client.options.restTimeOffset - Date.now(); + } + + this.manager.client.emit( + DEBUG, + `Hit a 429 while executing a request. + Global : ${isGlobal} + Method : ${request.method} + Path : ${request.path} + Route : ${request.route} + Limit : ${limit} + Timeout : ${timeout}ms + Sublimit: ${sublimitTimeout ? `${sublimitTimeout}ms` : 'None'}`, + ); + + await this.onRateLimit(request, limit, timeout, isGlobal); + + // If caused by a sublimit, wait it out here so other requests on the route can be handled + if (sublimitTimeout) { + await sleep(sublimitTimeout); + } + return this.execute(request); + } + + // Handle possible malformed requests + let data; + try { + data = await parseResponse(res); + } catch (err) { + throw new HTTPError(err.message, err.constructor.name, err.status, request); + } + + throw new DiscordAPIError(data, res.status, request); + } + + // Handle 5xx responses + if (res.status >= 500 && res.status < 600) { + // Retry the specified number of times for possible serverside issues + if (request.retries === this.manager.client.options.retryLimit) { + throw new HTTPError(res.statusText, res.constructor.name, res.status, request); + } + + request.retries++; + return this.execute(request); + } + + // Fallback in the rare case a status code outside the range 200..=599 is returned + return null; + } +} + +module.exports = RequestHandler; + +/** + * @external HTTPMethod + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods} + */ + +/** + * @external Response + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} + */ \ No newline at end of file diff --git a/src/sharding/Shard.js b/src/sharding/Shard.js new file mode 100644 index 00000000..ceaab75 --- /dev/null +++ b/src/sharding/Shard.js @@ -0,0 +1,443 @@ +'use strict'; + +const EventEmitter = require('node:events'); +const path = require('node:path'); +const process = require('node:process'); +const { setTimeout, clearTimeout } = require('node:timers'); +const { setTimeout: sleep } = require('node:timers/promises'); +const { Error } = require('../errors'); +const Util = require('../util/Util'); +let childProcess = null; +let Worker = null; + +/** + * A self-contained shard created by the {@link ShardingManager}. Each one has a {@link ChildProcess} that contains + * an instance of the bot and its {@link Client}. When its child process/worker exits for any reason, the shard will + * spawn a new one to replace it as necessary. + * @extends EventEmitter + */ +class Shard extends EventEmitter { + constructor(manager, id) { + super(); + + if (manager.mode === 'process') childProcess = require('node:child_process'); + else if (manager.mode === 'worker') Worker = require('node:worker_threads').Worker; + + /** + * Manager that created the shard + * @type {ShardingManager} + */ + this.manager = manager; + + /** + * The shard's id in the manager + * @type {number} + */ + this.id = id; + + /** + * Arguments for the shard's process (only when {@link ShardingManager#mode} is `process`) + * @type {string[]} + */ + this.args = manager.shardArgs ?? []; + + /** + * Arguments for the shard's process executable (only when {@link ShardingManager#mode} is `process`) + * @type {string[]} + */ + this.execArgv = manager.execArgv; + + /** + * Environment variables for the shard's process, or workerData for the shard's worker + * @type {Object} + */ + this.env = Object.assign({}, process.env, { + SHARDING_MANAGER: true, + SHARDS: this.id, + SHARD_COUNT: this.manager.totalShards, + DISCORD_TOKEN: this.manager.token, + }); + + /** + * Whether the shard's {@link Client} is ready + * @type {boolean} + */ + this.ready = false; + + /** + * Process of the shard (if {@link ShardingManager#mode} is `process`) + * @type {?ChildProcess} + */ + this.process = null; + + /** + * Worker of the shard (if {@link ShardingManager#mode} is `worker`) + * @type {?Worker} + */ + this.worker = null; + + /** + * Ongoing promises for calls to {@link Shard#eval}, mapped by the `script` they were called with + * @type {Map} + * @private + */ + this._evals = new Map(); + + /** + * Ongoing promises for calls to {@link Shard#fetchClientValue}, mapped by the `prop` they were called with + * @type {Map} + * @private + */ + this._fetches = new Map(); + + /** + * Listener function for the {@link ChildProcess}' `exit` event + * @type {Function} + * @private + */ + this._exitListener = null; + } + + /** + * Forks a child process or creates a worker thread for the shard. + * You should not need to call this manually. + * @param {number} [timeout=30000] The amount in milliseconds to wait until the {@link Client} has become ready + * before resolving (`-1` or `Infinity` for no wait) + * @returns {Promise} + */ + spawn(timeout = 30_000) { + if (this.process) throw new Error('SHARDING_PROCESS_EXISTS', this.id); + if (this.worker) throw new Error('SHARDING_WORKER_EXISTS', this.id); + + this._exitListener = this._handleExit.bind(this, undefined, timeout); + + if (this.manager.mode === 'process') { + this.process = childProcess + .fork(path.resolve(this.manager.file), this.args, { + env: this.env, + execArgv: this.execArgv, + }) + .on('message', this._handleMessage.bind(this)) + .on('exit', this._exitListener); + } else if (this.manager.mode === 'worker') { + this.worker = new Worker(path.resolve(this.manager.file), { workerData: this.env }) + .on('message', this._handleMessage.bind(this)) + .on('exit', this._exitListener); + } + + this._evals.clear(); + this._fetches.clear(); + + const child = this.process ?? this.worker; + + /** + * Emitted upon the creation of the shard's child process/worker. + * @event Shard#spawn + * @param {ChildProcess|Worker} process Child process/worker that was created + */ + this.emit('spawn', child); + + if (timeout === -1 || timeout === Infinity) return Promise.resolve(child); + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(spawnTimeoutTimer); + this.off('ready', onReady); + this.off('disconnect', onDisconnect); + this.off('death', onDeath); + }; + + const onReady = () => { + cleanup(); + resolve(child); + }; + + const onDisconnect = () => { + cleanup(); + reject(new Error('SHARDING_READY_DISCONNECTED', this.id)); + }; + + const onDeath = () => { + cleanup(); + reject(new Error('SHARDING_READY_DIED', this.id)); + }; + + const onTimeout = () => { + cleanup(); + reject(new Error('SHARDING_READY_TIMEOUT', this.id)); + }; + + const spawnTimeoutTimer = setTimeout(onTimeout, timeout); + this.once('ready', onReady); + this.once('disconnect', onDisconnect); + this.once('death', onDeath); + }); + } + + /** + * Immediately kills the shard's process/worker and does not restart it. + */ + kill() { + if (this.process) { + this.process.removeListener('exit', this._exitListener); + this.process.kill(); + } else { + this.worker.removeListener('exit', this._exitListener); + this.worker.terminate(); + } + + this._handleExit(false); + } + + /** + * Options used to respawn a shard. + * @typedef {Object} ShardRespawnOptions + * @property {number} [delay=500] How long to wait between killing the process/worker and + * restarting it (in milliseconds) + * @property {number} [timeout=30000] The amount in milliseconds to wait until the {@link Client} + * has become ready before resolving (`-1` or `Infinity` for no wait) + */ + + /** + * Kills and restarts the shard's process/worker. + * @param {ShardRespawnOptions} [options] Options for respawning the shard + * @returns {Promise} + */ + async respawn({ delay = 500, timeout = 30_000 } = {}) { + this.kill(); + if (delay > 0) await sleep(delay); + return this.spawn(timeout); + } + + /** + * Sends a message to the shard's process/worker. + * @param {*} message Message to send to the shard + * @returns {Promise} + */ + send(message) { + return new Promise((resolve, reject) => { + if (this.process) { + this.process.send(message, err => { + if (err) reject(err); + else resolve(this); + }); + } else { + this.worker.postMessage(message); + resolve(this); + } + }); + } + + /** + * Fetches a client property value of the shard. + * @param {string} prop Name of the client property to get, using periods for nesting + * @returns {Promise<*>} + * @example + * shard.fetchClientValue('guilds.cache.size') + * .then(count => console.log(`${count} guilds in shard ${shard.id}`)) + * .catch(console.error); + */ + fetchClientValue(prop) { + // Shard is dead (maybe respawning), don't cache anything and error immediately + if (!this.process && !this.worker) return Promise.reject(new Error('SHARDING_NO_CHILD_EXISTS', this.id)); + + // Cached promise from previous call + if (this._fetches.has(prop)) return this._fetches.get(prop); + + const promise = new Promise((resolve, reject) => { + const child = this.process ?? this.worker; + + const listener = message => { + if (message?._fetchProp !== prop) return; + child.removeListener('message', listener); + this.decrementMaxListeners(child); + this._fetches.delete(prop); + if (!message._error) resolve(message._result); + else reject(Util.makeError(message._error)); + }; + + this.incrementMaxListeners(child); + child.on('message', listener); + + this.send({ _fetchProp: prop }).catch(err => { + child.removeListener('message', listener); + this.decrementMaxListeners(child); + this._fetches.delete(prop); + reject(err); + }); + }); + + this._fetches.set(prop, promise); + return promise; + } + + /** + * Evaluates a script or function on the shard, in the context of the {@link Client}. + * @param {string|Function} script JavaScript to run on the shard + * @param {*} [context] The context for the eval + * @returns {Promise<*>} Result of the script execution + */ + eval(script, context) { + // Stringify the script if it's a Function + const _eval = typeof script === 'function' ? `(${script})(this, ${JSON.stringify(context)})` : script; + + // Shard is dead (maybe respawning), don't cache anything and error immediately + if (!this.process && !this.worker) return Promise.reject(new Error('SHARDING_NO_CHILD_EXISTS', this.id)); + + // Cached promise from previous call + if (this._evals.has(_eval)) return this._evals.get(_eval); + + const promise = new Promise((resolve, reject) => { + const child = this.process ?? this.worker; + + const listener = message => { + if (message?._eval !== _eval) return; + child.removeListener('message', listener); + this.decrementMaxListeners(child); + this._evals.delete(_eval); + if (!message._error) resolve(message._result); + else reject(Util.makeError(message._error)); + }; + + this.incrementMaxListeners(child); + child.on('message', listener); + + this.send({ _eval }).catch(err => { + child.removeListener('message', listener); + this.decrementMaxListeners(child); + this._evals.delete(_eval); + reject(err); + }); + }); + + this._evals.set(_eval, promise); + return promise; + } + + /** + * Handles a message received from the child process/worker. + * @param {*} message Message received + * @private + */ + _handleMessage(message) { + if (message) { + // Shard is ready + if (message._ready) { + this.ready = true; + /** + * Emitted upon the shard's {@link Client#event:shardReady} event. + * @event Shard#ready + */ + this.emit('ready'); + return; + } + + // Shard has disconnected + if (message._disconnect) { + this.ready = false; + /** + * Emitted upon the shard's {@link Client#event:shardDisconnect} event. + * @event Shard#disconnect + */ + this.emit('disconnect'); + return; + } + + // Shard is attempting to reconnect + if (message._reconnecting) { + this.ready = false; + /** + * Emitted upon the shard's {@link Client#event:shardReconnecting} event. + * @event Shard#reconnecting + */ + this.emit('reconnecting'); + return; + } + + // Shard is requesting a property fetch + if (message._sFetchProp) { + const resp = { _sFetchProp: message._sFetchProp, _sFetchPropShard: message._sFetchPropShard }; + this.manager.fetchClientValues(message._sFetchProp, message._sFetchPropShard).then( + results => this.send({ ...resp, _result: results }), + err => this.send({ ...resp, _error: Util.makePlainError(err) }), + ); + return; + } + + // Shard is requesting an eval broadcast + if (message._sEval) { + const resp = { _sEval: message._sEval, _sEvalShard: message._sEvalShard }; + this.manager._performOnShards('eval', [message._sEval], message._sEvalShard).then( + results => this.send({ ...resp, _result: results }), + err => this.send({ ...resp, _error: Util.makePlainError(err) }), + ); + return; + } + + // Shard is requesting a respawn of all shards + if (message._sRespawnAll) { + const { shardDelay, respawnDelay, timeout } = message._sRespawnAll; + this.manager.respawnAll({ shardDelay, respawnDelay, timeout }).catch(() => { + // Do nothing + }); + return; + } + } + + /** + * Emitted upon receiving a message from the child process/worker. + * @event Shard#message + * @param {*} message Message that was received + */ + this.emit('message', message); + } + + /** + * Handles the shard's process/worker exiting. + * @param {boolean} [respawn=this.manager.respawn] Whether to spawn the shard again + * @param {number} [timeout] The amount in milliseconds to wait until the {@link Client} + * has become ready (`-1` or `Infinity` for no wait) + * @private + */ + _handleExit(respawn = this.manager.respawn, timeout) { + /** + * Emitted upon the shard's child process/worker exiting. + * @event Shard#death + * @param {ChildProcess|Worker} process Child process/worker that exited + */ + this.emit('death', this.process ?? this.worker); + + this.ready = false; + this.process = null; + this.worker = null; + this._evals.clear(); + this._fetches.clear(); + + if (respawn) this.spawn(timeout).catch(err => this.emit('error', err)); + } + + /** + * Increments max listeners by one for a given emitter, if they are not zero. + * @param {EventEmitter|process} emitter The emitter that emits the events. + * @private + */ + incrementMaxListeners(emitter) { + const maxListeners = emitter.getMaxListeners(); + if (maxListeners !== 0) { + emitter.setMaxListeners(maxListeners + 1); + } + } + + /** + * Decrements max listeners by one for a given emitter, if they are not zero. + * @param {EventEmitter|process} emitter The emitter that emits the events. + * @private + */ + decrementMaxListeners(emitter) { + const maxListeners = emitter.getMaxListeners(); + if (maxListeners !== 0) { + emitter.setMaxListeners(maxListeners - 1); + } + } +} + +module.exports = Shard; diff --git a/src/sharding/ShardClientUtil.js b/src/sharding/ShardClientUtil.js new file mode 100644 index 00000000..0772af6 --- /dev/null +++ b/src/sharding/ShardClientUtil.js @@ -0,0 +1,278 @@ +'use strict'; + +const process = require('node:process'); +const { Error } = require('../errors'); +const Events = require('../util/Events'); +const Util = require('../util/Util'); + +/** + * Helper class for sharded clients spawned as a child process/worker, such as from a {@link ShardingManager}. + * Utilises IPC to send and receive data to/from the master process and other shards. + */ +class ShardClientUtil { + constructor(client, mode) { + /** + * Client for the shard + * @type {Client} + */ + this.client = client; + + /** + * Mode the shard was spawned with + * @type {ShardingManagerMode} + */ + this.mode = mode; + + /** + * Message port for the master process (only when {@link ShardClientUtil#mode} is `worker`) + * @type {?MessagePort} + */ + this.parentPort = null; + + if (mode === 'process') { + process.on('message', this._handleMessage.bind(this)); + client.on('ready', () => { + process.send({ _ready: true }); + }); + client.on('disconnect', () => { + process.send({ _disconnect: true }); + }); + client.on('reconnecting', () => { + process.send({ _reconnecting: true }); + }); + } else if (mode === 'worker') { + this.parentPort = require('node:worker_threads').parentPort; + this.parentPort.on('message', this._handleMessage.bind(this)); + client.on('ready', () => { + this.parentPort.postMessage({ _ready: true }); + }); + client.on('disconnect', () => { + this.parentPort.postMessage({ _disconnect: true }); + }); + client.on('reconnecting', () => { + this.parentPort.postMessage({ _reconnecting: true }); + }); + } + } + + /** + * Array of shard ids of this client + * @type {number[]} + * @readonly + */ + get ids() { + return this.client.options.shards; + } + + /** + * Total number of shards + * @type {number} + * @readonly + */ + get count() { + return this.client.options.shardCount; + } + + /** + * Sends a message to the master process. + * @param {*} message Message to send + * @returns {Promise} + * @emits Shard#message + */ + send(message) { + return new Promise((resolve, reject) => { + if (this.mode === 'process') { + process.send(message, err => { + if (err) reject(err); + else resolve(); + }); + } else if (this.mode === 'worker') { + this.parentPort.postMessage(message); + resolve(); + } + }); + } + + /** + * Fetches a client property value of each shard, or a given shard. + * @param {string} prop Name of the client property to get, using periods for nesting + * @param {number} [shard] Shard to fetch property from, all if undefined + * @returns {Promise<*|Array<*>>} + * @example + * client.shard.fetchClientValues('guilds.cache.size') + * .then(results => console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`)) + * .catch(console.error); + * @see {@link ShardingManager#fetchClientValues} + */ + fetchClientValues(prop, shard) { + return new Promise((resolve, reject) => { + const parent = this.parentPort ?? process; + + const listener = message => { + if (message?._sFetchProp !== prop || message._sFetchPropShard !== shard) return; + parent.removeListener('message', listener); + this.decrementMaxListeners(parent); + if (!message._error) resolve(message._result); + else reject(Util.makeError(message._error)); + }; + this.incrementMaxListeners(parent); + parent.on('message', listener); + + this.send({ _sFetchProp: prop, _sFetchPropShard: shard }).catch(err => { + parent.removeListener('message', listener); + this.decrementMaxListeners(parent); + reject(err); + }); + }); + } + + /** + * Evaluates a script or function on all shards, or a given shard, in the context of the {@link Client}s. + * @param {Function} script JavaScript to run on each shard + * @param {BroadcastEvalOptions} [options={}] The options for the broadcast + * @returns {Promise<*|Array<*>>} Results of the script execution + * @example + * client.shard.broadcastEval(client => client.guilds.cache.size) + * .then(results => console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`)) + * .catch(console.error); + * @see {@link ShardingManager#broadcastEval} + */ + broadcastEval(script, options = {}) { + return new Promise((resolve, reject) => { + const parent = this.parentPort ?? process; + if (typeof script !== 'function') { + reject(new TypeError('SHARDING_INVALID_EVAL_BROADCAST')); + return; + } + script = `(${script})(this, ${JSON.stringify(options.context)})`; + + const listener = message => { + if (message?._sEval !== script || message._sEvalShard !== options.shard) return; + parent.removeListener('message', listener); + this.decrementMaxListeners(parent); + if (!message._error) resolve(message._result); + else reject(Util.makeError(message._error)); + }; + this.incrementMaxListeners(parent); + parent.on('message', listener); + this.send({ _sEval: script, _sEvalShard: options.shard }).catch(err => { + parent.removeListener('message', listener); + this.decrementMaxListeners(parent); + reject(err); + }); + }); + } + + /** + * Requests a respawn of all shards. + * @param {MultipleShardRespawnOptions} [options] Options for respawning shards + * @returns {Promise} Resolves upon the message being sent + * @see {@link ShardingManager#respawnAll} + */ + respawnAll({ shardDelay = 5_000, respawnDelay = 500, timeout = 30_000 } = {}) { + return this.send({ _sRespawnAll: { shardDelay, respawnDelay, timeout } }); + } + + /** + * Handles an IPC message. + * @param {*} message Message received + * @private + */ + async _handleMessage(message) { + if (!message) return; + if (message._fetchProp) { + try { + const props = message._fetchProp.split('.'); + let value = this.client; + for (const prop of props) value = value[prop]; + this._respond('fetchProp', { _fetchProp: message._fetchProp, _result: value }); + } catch (err) { + this._respond('fetchProp', { _fetchProp: message._fetchProp, _error: Util.makePlainError(err) }); + } + } else if (message._eval) { + try { + this._respond('eval', { _eval: message._eval, _result: await this.client._eval(message._eval) }); + } catch (err) { + this._respond('eval', { _eval: message._eval, _error: Util.makePlainError(err) }); + } + } + } + + /** + * Sends a message to the master process, emitting an error from the client upon failure. + * @param {string} type Type of response to send + * @param {*} message Message to send + * @private + */ + _respond(type, message) { + this.send(message).catch(err => { + const error = new Error(`Error when sending ${type} response to master process: ${err.message}`); + error.stack = err.stack; + /** + * Emitted when the client encounters an error. + * Errors thrown within this event do not have a catch handler, it is + * recommended to not use async functions as `error` event handlers. See the + * [Node.js docs](https://nodejs.org/api/events.html#capture-rejections-of-promises) for details. + * @event Client#error + * @param {Error} error The error encountered + */ + this.client.emit(Events.Error, error); + }); + } + + /** + * Creates/gets the singleton of this class. + * @param {Client} client The client to use + * @param {ShardingManagerMode} mode Mode the shard was spawned with + * @returns {ShardClientUtil} + */ + static singleton(client, mode) { + if (!this._singleton) { + this._singleton = new this(client, mode); + } else { + client.emit( + Events.Warn, + 'Multiple clients created in child process/worker; only the first will handle sharding helpers.', + ); + } + return this._singleton; + } + + /** + * Get the shard id for a given guild id. + * @param {Snowflake} guildId Snowflake guild id to get shard id for + * @param {number} shardCount Number of shards + * @returns {number} + */ + static shardIdForGuildId(guildId, shardCount) { + const shard = Number(BigInt(guildId) >> 22n) % shardCount; + if (shard < 0) throw new Error('SHARDING_SHARD_MISCALCULATION', shard, guildId, shardCount); + return shard; + } + + /** + * Increments max listeners by one for a given emitter, if they are not zero. + * @param {EventEmitter|process} emitter The emitter that emits the events. + * @private + */ + incrementMaxListeners(emitter) { + const maxListeners = emitter.getMaxListeners(); + if (maxListeners !== 0) { + emitter.setMaxListeners(maxListeners + 1); + } + } + + /** + * Decrements max listeners by one for a given emitter, if they are not zero. + * @param {EventEmitter|process} emitter The emitter that emits the events. + * @private + */ + decrementMaxListeners(emitter) { + const maxListeners = emitter.getMaxListeners(); + if (maxListeners !== 0) { + emitter.setMaxListeners(maxListeners - 1); + } + } +} + +module.exports = ShardClientUtil; diff --git a/src/sharding/ShardingManager.js b/src/sharding/ShardingManager.js new file mode 100644 index 00000000..db8581c --- /dev/null +++ b/src/sharding/ShardingManager.js @@ -0,0 +1,318 @@ +'use strict'; + +const EventEmitter = require('node:events'); +const fs = require('node:fs'); +const path = require('node:path'); +const process = require('node:process'); +const { setTimeout: sleep } = require('node:timers/promises'); +const { Collection } = require('@discordjs/collection'); +const Shard = require('./Shard'); +const { Error, TypeError, RangeError } = require('../errors'); +const Util = require('../util/Util'); + +/** + * This is a utility class that makes multi-process sharding of a bot an easy and painless experience. + * It works by spawning a self-contained {@link ChildProcess} or {@link Worker} for each individual shard, each + * containing its own instance of your bot's {@link Client}. They all have a line of communication with the master + * process, and there are several useful methods that utilise it in order to simplify tasks that are normally difficult + * with sharding. It can spawn a specific number of shards or the amount that Discord suggests for the bot, and takes a + * path to your main bot script to launch for each one. + * @extends {EventEmitter} + */ +class ShardingManager extends EventEmitter { + /** + * The mode to spawn shards with for a {@link ShardingManager}. Can be either one of: + * * 'process' to use child processes + * * 'worker' to use [Worker threads](https://nodejs.org/api/worker_threads.html) + * @typedef {string} ShardingManagerMode + */ + + /** + * The options to spawn shards with for a {@link ShardingManager}. + * @typedef {Object} ShardingManagerOptions + * @property {string|number} [totalShards='auto'] Number of total shards of all shard managers or "auto" + * @property {string|number[]} [shardList='auto'] List of shards to spawn or "auto" + * @property {ShardingManagerMode} [mode='process'] Which mode to use for shards + * @property {boolean} [respawn=true] Whether shards should automatically respawn upon exiting + * @property {string[]} [shardArgs=[]] Arguments to pass to the shard script when spawning + * (only available when mode is set to 'process') + * @property {string[]} [execArgv=[]] Arguments to pass to the shard script executable when spawning + * (only available when mode is set to 'process') + * @property {string} [token] Token to use for automatic shard count and passing to shards + */ + + /** + * @param {string} file Path to your shard script file + * @param {ShardingManagerOptions} [options] Options for the sharding manager + */ + constructor(file, options = {}) { + super(); + options = Util.mergeDefault( + { + totalShards: 'auto', + mode: 'process', + respawn: true, + shardArgs: [], + execArgv: [], + token: process.env.DISCORD_TOKEN, + }, + options, + ); + + /** + * Path to the shard script file + * @type {string} + */ + this.file = file; + if (!file) throw new Error('CLIENT_INVALID_OPTION', 'File', 'specified.'); + if (!path.isAbsolute(file)) this.file = path.resolve(process.cwd(), file); + const stats = fs.statSync(this.file); + if (!stats.isFile()) throw new Error('CLIENT_INVALID_OPTION', 'File', 'a file'); + + /** + * List of shards this sharding manager spawns + * @type {string|number[]} + */ + this.shardList = options.shardList ?? 'auto'; + if (this.shardList !== 'auto') { + if (!Array.isArray(this.shardList)) { + throw new TypeError('CLIENT_INVALID_OPTION', 'shardList', 'an array.'); + } + this.shardList = [...new Set(this.shardList)]; + if (this.shardList.length < 1) throw new RangeError('CLIENT_INVALID_OPTION', 'shardList', 'at least 1 id.'); + if ( + this.shardList.some( + shardId => typeof shardId !== 'number' || isNaN(shardId) || !Number.isInteger(shardId) || shardId < 0, + ) + ) { + throw new TypeError('CLIENT_INVALID_OPTION', 'shardList', 'an array of positive integers.'); + } + } + + /** + * Amount of shards that all sharding managers spawn in total + * @type {number} + */ + this.totalShards = options.totalShards || 'auto'; + if (this.totalShards !== 'auto') { + if (typeof this.totalShards !== 'number' || isNaN(this.totalShards)) { + throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'a number.'); + } + if (this.totalShards < 1) throw new RangeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'at least 1.'); + if (!Number.isInteger(this.totalShards)) { + throw new RangeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'an integer.'); + } + } + + /** + * Mode for shards to spawn with + * @type {ShardingManagerMode} + */ + this.mode = options.mode; + if (this.mode !== 'process' && this.mode !== 'worker') { + throw new RangeError('CLIENT_INVALID_OPTION', 'Sharding mode', '"process" or "worker"'); + } + + /** + * Whether shards should automatically respawn upon exiting + * @type {boolean} + */ + this.respawn = options.respawn; + + /** + * An array of arguments to pass to shards (only when {@link ShardingManager#mode} is `process`) + * @type {string[]} + */ + this.shardArgs = options.shardArgs; + + /** + * An array of arguments to pass to the executable (only when {@link ShardingManager#mode} is `process`) + * @type {string[]} + */ + this.execArgv = options.execArgv; + + /** + * Token to use for obtaining the automatic shard count, and passing to shards + * @type {?string} + */ + this.token = options.token?.replace(/^Bot\s*/i, '') ?? null; + + /** + * A collection of shards that this manager has spawned + * @type {Collection} + */ + this.shards = new Collection(); + + process.env.SHARDING_MANAGER = true; + process.env.SHARDING_MANAGER_MODE = this.mode; + process.env.DISCORD_TOKEN = this.token; + } + + /** + * Creates a single shard. + * Using this method is usually not necessary if you use the spawn method. + * @param {number} [id=this.shards.size] Id of the shard to create + * This is usually not necessary to manually specify. + * @returns {Shard} Note that the created shard needs to be explicitly spawned using its spawn method. + */ + createShard(id = this.shards.size) { + const shard = new Shard(this, id); + this.shards.set(id, shard); + /** + * Emitted upon creating a shard. + * @event ShardingManager#shardCreate + * @param {Shard} shard Shard that was created + */ + this.emit('shardCreate', shard); + return shard; + } + + /** + * Options used to spawn multiple shards. + * @typedef {Object} MultipleShardSpawnOptions + * @property {number|string} [amount=this.totalShards] Number of shards to spawn + * @property {number} [delay=5500] How long to wait in between spawning each shard (in milliseconds) + * @property {number} [timeout=30000] The amount in milliseconds to wait until the {@link Client} has become ready + */ + + /** + * Spawns multiple shards. + * @param {MultipleShardSpawnOptions} [options] Options for spawning shards + * @returns {Promise>} + */ + async spawn({ amount = this.totalShards, delay = 5500, timeout = 30_000 } = {}) { + // Obtain/verify the number of shards to spawn + if (amount === 'auto') { + amount = await Util.fetchRecommendedShards(this.token); + } else { + if (typeof amount !== 'number' || isNaN(amount)) { + throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'a number.'); + } + if (amount < 1) throw new RangeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'at least 1.'); + if (!Number.isInteger(amount)) { + throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'an integer.'); + } + } + + // Make sure this many shards haven't already been spawned + if (this.shards.size >= amount) throw new Error('SHARDING_ALREADY_SPAWNED', this.shards.size); + if (this.shardList === 'auto' || this.totalShards === 'auto' || this.totalShards !== amount) { + this.shardList = [...Array(amount).keys()]; + } + if (this.totalShards === 'auto' || this.totalShards !== amount) { + this.totalShards = amount; + } + + if (this.shardList.some(shardId => shardId >= amount)) { + throw new RangeError( + 'CLIENT_INVALID_OPTION', + 'Amount of shards', + 'bigger than the highest shardId in the shardList option.', + ); + } + + // Spawn the shards + for (const shardId of this.shardList) { + const promises = []; + const shard = this.createShard(shardId); + promises.push(shard.spawn(timeout)); + if (delay > 0 && this.shards.size !== this.shardList.length) promises.push(sleep(delay)); + await Promise.all(promises); // eslint-disable-line no-await-in-loop + } + + return this.shards; + } + + /** + * Sends a message to all shards. + * @param {*} message Message to be sent to the shards + * @returns {Promise} + */ + broadcast(message) { + const promises = []; + for (const shard of this.shards.values()) promises.push(shard.send(message)); + return Promise.all(promises); + } + + /** + * Options for {@link ShardingManager#broadcastEval} and {@link ShardClientUtil#broadcastEval}. + * @typedef {Object} BroadcastEvalOptions + * @property {number} [shard] Shard to run script on, all if undefined + * @property {*} [context] The JSON-serializable values to call the script with + */ + + /** + * Evaluates a script on all shards, or a given shard, in the context of the {@link Client}s. + * @param {Function} script JavaScript to run on each shard + * @param {BroadcastEvalOptions} [options={}] The options for the broadcast + * @returns {Promise<*|Array<*>>} Results of the script execution + */ + broadcastEval(script, options = {}) { + if (typeof script !== 'function') return Promise.reject(new TypeError('SHARDING_INVALID_EVAL_BROADCAST')); + return this._performOnShards('eval', [`(${script})(this, ${JSON.stringify(options.context)})`], options.shard); + } + + /** + * Fetches a client property value of each shard, or a given shard. + * @param {string} prop Name of the client property to get, using periods for nesting + * @param {number} [shard] Shard to fetch property from, all if undefined + * @returns {Promise<*|Array<*>>} + * @example + * manager.fetchClientValues('guilds.cache.size') + * .then(results => console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`)) + * .catch(console.error); + */ + fetchClientValues(prop, shard) { + return this._performOnShards('fetchClientValue', [prop], shard); + } + + /** + * Runs a method with given arguments on all shards, or a given shard. + * @param {string} method Method name to run on each shard + * @param {Array<*>} args Arguments to pass through to the method call + * @param {number} [shard] Shard to run on, all if undefined + * @returns {Promise<*|Array<*>>} Results of the method execution + * @private + */ + _performOnShards(method, args, shard) { + if (this.shards.size === 0) return Promise.reject(new Error('SHARDING_NO_SHARDS')); + + if (typeof shard === 'number') { + if (this.shards.has(shard)) return this.shards.get(shard)[method](...args); + return Promise.reject(new Error('SHARDING_SHARD_NOT_FOUND', shard)); + } + + if (this.shards.size !== this.shardList.length) return Promise.reject(new Error('SHARDING_IN_PROCESS')); + + const promises = []; + for (const sh of this.shards.values()) promises.push(sh[method](...args)); + return Promise.all(promises); + } + + /** + * Options used to respawn all shards. + * @typedef {Object} MultipleShardRespawnOptions + * @property {number} [shardDelay=5000] How long to wait between shards (in milliseconds) + * @property {number} [respawnDelay=500] How long to wait between killing a shard's process and restarting it + * (in milliseconds) + * @property {number} [timeout=30000] The amount in milliseconds to wait for a shard to become ready before + * continuing to another (`-1` or `Infinity` for no wait) + */ + + /** + * Kills all running shards and respawns them. + * @param {MultipleShardRespawnOptions} [options] Options for respawning shards + * @returns {Promise>} + */ + async respawnAll({ shardDelay = 5_000, respawnDelay = 500, timeout = 30_000 } = {}) { + let s = 0; + for (const shard of this.shards.values()) { + const promises = [shard.respawn({ delay: respawnDelay, timeout })]; + if (++s < this.shards.size && shardDelay > 0) promises.push(sleep(shardDelay)); + await Promise.all(promises); // eslint-disable-line no-await-in-loop + } + return this.shards; + } +} + +module.exports = ShardingManager; diff --git a/src/structures/ActionRow.js b/src/structures/ActionRow.js new file mode 100644 index 00000000..450a9f3 --- /dev/null +++ b/src/structures/ActionRow.js @@ -0,0 +1,12 @@ +'use strict'; + +const { ActionRow: BuildersActionRow } = require('@discordjs/builders'); +const Transformers = require('../util/Transformers'); + +class ActionRow extends BuildersActionRow { + constructor(data) { + super(Transformers.toSnakeCase(data)); + } +} + +module.exports = ActionRow; diff --git a/src/structures/AnonymousGuild.js b/src/structures/AnonymousGuild.js new file mode 100644 index 00000000..b919d82 --- /dev/null +++ b/src/structures/AnonymousGuild.js @@ -0,0 +1,95 @@ +'use strict'; + +const BaseGuild = require('./BaseGuild'); + +/** + * Bundles common attributes and methods between {@link Guild} and {@link InviteGuild} + * @extends {BaseGuild} + * @abstract + */ +class AnonymousGuild extends BaseGuild { + constructor(client, data, immediatePatch = true) { + super(client, data); + if (immediatePatch) this._patch(data); + } + + _patch(data) { + if ('features' in data) this.features = data.features; + + if ('splash' in data) { + /** + * The hash of the guild invite splash image + * @type {?string} + */ + this.splash = data.splash; + } + + if ('banner' in data) { + /** + * The hash of the guild banner + * @type {?string} + */ + this.banner = data.banner; + } + + if ('description' in data) { + /** + * The description of the guild, if any + * @type {?string} + */ + this.description = data.description; + } + + if ('verification_level' in data) { + /** + * The verification level of the guild + * @type {GuildVerificationLevel} + */ + this.verificationLevel = data.verification_level; + } + + if ('vanity_url_code' in data) { + /** + * The vanity invite code of the guild, if any + * @type {?string} + */ + this.vanityURLCode = data.vanity_url_code; + } + + if ('nsfw_level' in data) { + /** + * The NSFW level of this guild + * @type {GuildNSFWLevel} + */ + this.nsfwLevel = data.nsfw_level; + } + + if ('premium_subscription_count' in data) { + /** + * The total number of boosts for this server + * @type {?number} + */ + this.premiumSubscriptionCount = data.premium_subscription_count; + } + } + + /** + * The URL to this guild's banner. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + bannerURL(options = {}) { + return this.banner && this.client.rest.cdn.banner(this.id, this.banner, options); + } + + /** + * The URL to this guild's invite splash image. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + splashURL(options = {}) { + return this.splash && this.client.rest.cdn.splash(this.id, this.splash, options); + } +} + +module.exports = AnonymousGuild; diff --git a/src/structures/ApplicationCommand.js b/src/structures/ApplicationCommand.js new file mode 100644 index 00000000..f43304d --- /dev/null +++ b/src/structures/ApplicationCommand.js @@ -0,0 +1,415 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { ApplicationCommandOptionType } = require('discord-api-types/v9'); +const Base = require('./Base'); +const ApplicationCommandPermissionsManager = require('../managers/ApplicationCommandPermissionsManager'); + +/** + * Represents an application command. + * @extends {Base} + */ +class ApplicationCommand extends Base { + constructor(client, data, guild, guildId) { + super(client); + + /** + * The command's id + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The parent application's id + * @type {Snowflake} + */ + this.applicationId = data.application_id; + + /** + * The guild this command is part of + * @type {?Guild} + */ + this.guild = guild ?? null; + + /** + * The guild's id this command is part of, this may be non-null when `guild` is `null` if the command + * was fetched from the `ApplicationCommandManager` + * @type {?Snowflake} + */ + this.guildId = guild?.id ?? guildId ?? null; + + /** + * The manager for permissions of this command on its guild or arbitrary guilds when the command is global + * @type {ApplicationCommandPermissionsManager} + */ + this.permissions = new ApplicationCommandPermissionsManager(this); + + /** + * The type of this application command + * @type {ApplicationCommandType} + */ + this.type = data.type; + + this._patch(data); + } + + _patch(data) { + if ('name' in data) { + /** + * The name of this command + * @type {string} + */ + this.name = data.name; + } + + if ('description' in data) { + /** + * The description of this command + * @type {string} + */ + this.description = data.description; + } + + if ('options' in data) { + /** + * The options of this command + * @type {ApplicationCommandOption[]} + */ + this.options = data.options.map(o => this.constructor.transformOption(o, true)); + } else { + this.options ??= []; + } + + if ('default_permission' in data) { + /** + * Whether the command is enabled by default when the app is added to a guild + * @type {boolean} + */ + this.defaultPermission = data.default_permission; + } + + if ('version' in data) { + /** + * Autoincrementing version identifier updated during substantial record changes + * @type {Snowflake} + */ + this.version = data.version; + } + } + + /** + * The timestamp the command was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the command was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The manager that this command belongs to + * @type {ApplicationCommandManager} + * @readonly + */ + get manager() { + return (this.guild ?? this.client.application).commands; + } + + /** + * Data for creating or editing an application command. + * @typedef {Object} ApplicationCommandData + * @property {string} name The name of the command, must be in all lowercase if type is + * {@link ApplicationCommandType.ChatInput} + * @property {string} description The description of the command, if type is {@link ApplicationCommandType.ChatInput} + * @property {ApplicationCommandType} [type=ApplicationCommandType.ChatInput] The type of the command + * @property {ApplicationCommandOptionData[]} [options] Options for the command + * @property {boolean} [defaultPermission=true] Whether the command is enabled by default when the app is added to a + * guild + */ + + /** + * An option for an application command or subcommand. + * In addition to the listed properties, when used as a parameter, + * API style `snake_case` properties can be used for compatibility with generators like `@discordjs/builders`. + * Note that providing a value for the `camelCase` counterpart for any `snake_case` property + * will discard the provided `snake_case` property. + * @typedef {Object} ApplicationCommandOptionData + * @property {ApplicationCommandOptionType} type The type of the option + * @property {string} name The name of the option + * @property {string} description The description of the option + * @property {boolean} [autocomplete] Whether the autocomplete interaction is enabled for a + * {@link ApplicationCommandOptionType.String}, {@link ApplicationCommandOptionType.Integer} or + * {@link ApplicationCommandOptionType.Number} option + * @property {boolean} [required] Whether the option is required + * @property {ApplicationCommandOptionChoice[]} [choices] The choices of the option for the user to pick from + * @property {ApplicationCommandOptionData[]} [options] Additional options if this option is a subcommand (group) + * @property {ChannelType[]} [channelTypes] When the option type is channel, + * the allowed types of channels that can be selected + * @property {number} [minValue] The minimum value for an {@link ApplicationCommandOptionType.Integer} or + * {@link ApplicationCommandOptionType.Number} option + * @property {number} [maxValue] The maximum value for an {@link ApplicationCommandOptionType.Integer} or + * {@link ApplicationCommandOptionType.Number} option + */ + + /** + * Edits this application command. + * @param {ApplicationCommandData} data The data to update the command with + * @returns {Promise} + * @example + * // Edit the description of this command + * command.edit({ + * description: 'New description', + * }) + * .then(console.log) + * .catch(console.error); + */ + edit(data) { + return this.manager.edit(this, data, this.guildId); + } + + /** + * Edits the name of this ApplicationCommand + * @param {string} name The new name of the command + * @returns {Promise} + */ + setName(name) { + return this.edit({ name }); + } + + /** + * Edits the description of this ApplicationCommand + * @param {string} description The new description of the command + * @returns {Promise} + */ + setDescription(description) { + return this.edit({ description }); + } + + /** + * Edits the default permission of this ApplicationCommand + * @param {boolean} [defaultPermission=true] The default permission for this command + * @returns {Promise} + */ + setDefaultPermission(defaultPermission = true) { + return this.edit({ defaultPermission }); + } + + /** + * Edits the options of this ApplicationCommand + * @param {ApplicationCommandOptionData[]} options The options to set for this command + * @returns {Promise} + */ + setOptions(options) { + return this.edit({ options }); + } + + /** + * Deletes this command. + * @returns {Promise} + * @example + * // Delete this command + * command.delete() + * .then(console.log) + * .catch(console.error); + */ + delete() { + return this.manager.delete(this, this.guildId); + } + + /** + * Whether this command equals another command. It compares all properties, so for most operations + * it is advisable to just compare `command.id === command2.id` as it is much faster and is often + * what most users need. + * @param {ApplicationCommand|ApplicationCommandData|APIApplicationCommand} command The command to compare with + * @param {boolean} [enforceOptionOrder=false] Whether to strictly check that options and choices are in the same + * order in the array The client may not always respect this ordering! + * @returns {boolean} + */ + equals(command, enforceOptionOrder = false) { + // If given an id, check if the id matches + if (command.id && this.id !== command.id) return false; + + // Check top level parameters + if ( + command.name !== this.name || + ('description' in command && command.description !== this.description) || + ('version' in command && command.version !== this.version) || + ('autocomplete' in command && command.autocomplete !== this.autocomplete) || + (command.type && command.type !== this.type) || + // Future proof for options being nullable + // TODO: remove ?? 0 on each when nullable + (command.options?.length ?? 0) !== (this.options?.length ?? 0) || + (command.defaultPermission ?? command.default_permission ?? true) !== this.defaultPermission + ) { + return false; + } + + if (command.options) { + return this.constructor.optionsEqual(this.options, command.options, enforceOptionOrder); + } + return true; + } + + /** + * Recursively checks that all options for an {@link ApplicationCommand} are equal to the provided options. + * In most cases it is better to compare using {@link ApplicationCommand#equals} + * @param {ApplicationCommandOptionData[]} existing The options on the existing command, + * should be {@link ApplicationCommand#options} + * @param {ApplicationCommandOptionData[]|APIApplicationCommandOption[]} options The options to compare against + * @param {boolean} [enforceOptionOrder=false] Whether to strictly check that options and choices are in the same + * order in the array The client may not always respect this ordering! + * @returns {boolean} + */ + static optionsEqual(existing, options, enforceOptionOrder = false) { + if (existing.length !== options.length) return false; + if (enforceOptionOrder) { + return existing.every((option, index) => this._optionEquals(option, options[index], enforceOptionOrder)); + } + const newOptions = new Map(options.map(option => [option.name, option])); + for (const option of existing) { + const foundOption = newOptions.get(option.name); + if (!foundOption || !this._optionEquals(option, foundOption)) return false; + } + return true; + } + + /** + * Checks that an option for an {@link ApplicationCommand} is equal to the provided option + * In most cases it is better to compare using {@link ApplicationCommand#equals} + * @param {ApplicationCommandOptionData} existing The option on the existing command, + * should be from {@link ApplicationCommand#options} + * @param {ApplicationCommandOptionData|APIApplicationCommandOption} option The option to compare against + * @param {boolean} [enforceOptionOrder=false] Whether to strictly check that options or choices are in the same + * order in their array The client may not always respect this ordering! + * @returns {boolean} + * @private + */ + static _optionEquals(existing, option, enforceOptionOrder = false) { + if ( + option.name !== existing.name || + option.type !== existing.type || + option.description !== existing.description || + option.autocomplete !== existing.autocomplete || + (option.required ?? + ([ApplicationCommandOptionType.Subcommand, ApplicationCommandOptionType.SubcommandGroup].includes(option.type) + ? undefined + : false)) !== existing.required || + option.choices?.length !== existing.choices?.length || + option.options?.length !== existing.options?.length || + (option.channelTypes ?? option.channel_types)?.length !== existing.channelTypes?.length || + (option.minValue ?? option.min_value) !== existing.minValue || + (option.maxValue ?? option.max_value) !== existing.maxValue + ) { + return false; + } + + if (existing.choices) { + if ( + enforceOptionOrder && + !existing.choices.every( + (choice, index) => choice.name === option.choices[index].name && choice.value === option.choices[index].value, + ) + ) { + return false; + } + if (!enforceOptionOrder) { + const newChoices = new Map(option.choices.map(choice => [choice.name, choice])); + for (const choice of existing.choices) { + const foundChoice = newChoices.get(choice.name); + if (!foundChoice || foundChoice.value !== choice.value) return false; + } + } + } + + if (existing.channelTypes) { + const newTypes = option.channelTypes ?? option.channel_types; + for (const type of existing.channelTypes) { + if (!newTypes.includes(type)) return false; + } + } + + if (existing.options) { + return this.optionsEqual(existing.options, option.options, enforceOptionOrder); + } + return true; + } + + /** + * An option for an application command or subcommand. + * @typedef {Object} ApplicationCommandOption + * @property {ApplicationCommandOptionType} type The type of the option + * @property {string} name The name of the option + * @property {string} description The description of the option + * @property {boolean} [required] Whether the option is required + * @property {boolean} [autocomplete] Whether the autocomplete interaction is enabled for a + * {@link ApplicationCommandOptionType.String}, {@link ApplicationCommandOptionType.Integer} or + * {@link ApplicationCommandOptionType.Number} option + * @property {ApplicationCommandOptionChoice[]} [choices] The choices of the option for the user to pick from + * @property {ApplicationCommandOption[]} [options] Additional options if this option is a subcommand (group) + * @property {ChannelType[]} [channelTypes] When the option type is channel, + * the allowed types of channels that can be selected + * @property {number} [minValue] The minimum value for an {@link ApplicationCommandOptionType.Integer} or + * {@link ApplicationCommandOptionType.Number} option + * @property {number} [maxValue] The maximum value for an {@link ApplicationCommandOptionType.Integer} or + * {@link ApplicationCommandOptionType.Number} option + */ + + /** + * A choice for an application command option. + * @typedef {Object} ApplicationCommandOptionChoice + * @property {string} name The name of the choice + * @property {string|number} value The value of the choice + */ + + /** + * Transforms an {@link ApplicationCommandOptionData} object into something that can be used with the API. + * @param {ApplicationCommandOptionData} option The option to transform + * @param {boolean} [received] Whether this option has been received from Discord + * @returns {APIApplicationCommandOption} + * @private + */ + static transformOption(option, received) { + const channelTypesKey = received ? 'channelTypes' : 'channel_types'; + const minValueKey = received ? 'minValue' : 'min_value'; + const maxValueKey = received ? 'maxValue' : 'max_value'; + return { + type: option.type, + name: option.name, + description: option.description, + required: + option.required ?? + (option.type === ApplicationCommandOptionType.Subcommand || + option.type === ApplicationCommandOptionType.SubcommandGroup + ? undefined + : false), + autocomplete: option.autocomplete, + choices: option.choices, + options: option.options?.map(o => this.transformOption(o, received)), + [channelTypesKey]: option.channelTypes ?? option.channel_types, + [minValueKey]: option.minValue ?? option.min_value, + [maxValueKey]: option.maxValue ?? option.max_value, + }; + } +} + +module.exports = ApplicationCommand; + +/* eslint-disable max-len */ +/** + * @external APIApplicationCommand + * @see {@link https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure} + */ + +/** + * @external APIApplicationCommandOption + * @see {@link https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure} + */ diff --git a/src/structures/AutocompleteInteraction.js b/src/structures/AutocompleteInteraction.js new file mode 100644 index 00000000..c05afcb --- /dev/null +++ b/src/structures/AutocompleteInteraction.js @@ -0,0 +1,92 @@ +'use strict'; + +const { InteractionResponseType, Routes } = require('discord-api-types/v9'); +const CommandInteractionOptionResolver = require('./CommandInteractionOptionResolver'); +const Interaction = require('./Interaction'); + +/** + * Represents an autocomplete interaction. + * @extends {Interaction} + */ +class AutocompleteInteraction extends Interaction { + constructor(client, data) { + super(client, data); + + /** + * The id of the channel this interaction was sent in + * @type {Snowflake} + * @name AutocompleteInteraction#channelId + */ + + /** + * The invoked application command's id + * @type {Snowflake} + */ + this.commandId = data.data.id; + + /** + * The invoked application command's name + * @type {string} + */ + this.commandName = data.data.name; + + /** + * The invoked application command's type + * @type {ApplicationCommandType.ChatInput} + */ + this.commandType = data.data.type; + + /** + * Whether this interaction has already received a response + * @type {boolean} + */ + this.responded = false; + + /** + * The options passed to the command + * @type {CommandInteractionOptionResolver} + */ + this.options = new CommandInteractionOptionResolver(this.client, data.data.options ?? []); + } + + /** + * The invoked application command, if it was fetched before + * @type {?ApplicationCommand} + */ + get command() { + const id = this.commandId; + return this.guild?.commands.cache.get(id) ?? this.client.application.commands.cache.get(id) ?? null; + } + + /** + * Sends results for the autocomplete of this interaction. + * @param {ApplicationCommandOptionChoice[]} options The options for the autocomplete + * @returns {Promise} + * @example + * // respond to autocomplete interaction + * interaction.respond([ + * { + * name: 'Option 1', + * value: 'option1', + * }, + * ]) + * .then(console.log) + * .catch(console.error); + */ + async respond(options) { + if (this.responded) throw new Error('INTERACTION_ALREADY_REPLIED'); + + await this.client.api.interactions(this.id, this.token).callback.post({ + body: { + type: InteractionResponseType.ApplicationCommandAutocompleteResult, + data: { + choices: options, + }, + }, + auth: false, + }) + this.responded = true; + } +} + +module.exports = AutocompleteInteraction; diff --git a/src/structures/Base.js b/src/structures/Base.js new file mode 100644 index 00000000..cd43bf7 --- /dev/null +++ b/src/structures/Base.js @@ -0,0 +1,43 @@ +'use strict'; + +const Util = require('../util/Util'); + +/** + * Represents a data model that is identifiable by a Snowflake (i.e. Discord API data models). + * @abstract + */ +class Base { + constructor(client) { + /** + * The client that instantiated this + * @name Base#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + } + + _clone() { + return Object.assign(Object.create(this), this); + } + + _patch(data) { + return data; + } + + _update(data) { + const clone = this._clone(); + this._patch(data); + return clone; + } + + toJSON(...props) { + return Util.flatten(this, ...props); + } + + valueOf() { + return this.id; + } +} + +module.exports = Base; diff --git a/src/structures/BaseGuild.js b/src/structures/BaseGuild.js new file mode 100644 index 00000000..e9d43d5 --- /dev/null +++ b/src/structures/BaseGuild.js @@ -0,0 +1,118 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { Routes } = require('discord-api-types/v9'); +const Base = require('./Base'); + +/** + * The base class for {@link Guild}, {@link OAuth2Guild} and {@link InviteGuild}. + * @extends {Base} + * @abstract + */ +class BaseGuild extends Base { + constructor(client, data) { + super(client); + + /** + * The guild's id + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The name of this guild + * @type {string} + */ + this.name = data.name; + + /** + * The icon hash of this guild + * @type {?string} + */ + this.icon = data.icon; + + /** + * An array of features available to this guild + * @type {GuildFeature[]} + */ + this.features = data.features; + } + + /** + * The timestamp this guild was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time this guild was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The acronym that shows up in place of a guild icon + * @type {string} + * @readonly + */ + get nameAcronym() { + return this.name + .replace(/'s /g, ' ') + .replace(/\w+/g, e => e[0]) + .replace(/\s/g, ''); + } + + /** + * Whether this guild is partnered + * @type {boolean} + * @readonly + */ + get partnered() { + return this.features.includes('PARTNERED'); + } + + /** + * Whether this guild is verified + * @type {boolean} + * @readonly + */ + get verified() { + return this.features.includes('VERIFIED'); + } + + /** + * The URL to this guild's icon. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + iconURL(options = {}) { + return this.icon && this.client.rest.cdn.icon(this.id, this.icon, options); + } + + /** + * Fetches this guild. + * @returns {Promise} + */ + async fetch() { + const data = await this.client.api.guilds(this.id).get({ + query: new URLSearchParams({ with_counts: true }), + }); + return this.client.guilds._add(data); + } + + /** + * When concatenated with a string, this automatically returns the guild's name instead of the Guild object. + * @returns {string} + */ + toString() { + return this.name; + } +} + +module.exports = BaseGuild; diff --git a/src/structures/BaseGuildEmoji.js b/src/structures/BaseGuildEmoji.js new file mode 100644 index 00000000..5a12bd9 --- /dev/null +++ b/src/structures/BaseGuildEmoji.js @@ -0,0 +1,56 @@ +'use strict'; + +const { Emoji } = require('./Emoji'); + +/** + * Parent class for {@link GuildEmoji} and {@link GuildPreviewEmoji}. + * @extends {Emoji} + * @abstract + */ +class BaseGuildEmoji extends Emoji { + constructor(client, data, guild) { + super(client, data); + + /** + * The guild this emoji is a part of + * @type {Guild|GuildPreview} + */ + this.guild = guild; + + this.requiresColons = null; + this.managed = null; + this.available = null; + + this._patch(data); + } + + _patch(data) { + if ('name' in data) this.name = data.name; + + if ('require_colons' in data) { + /** + * Whether or not this emoji requires colons surrounding it + * @type {?boolean} + */ + this.requiresColons = data.require_colons; + } + + if ('managed' in data) { + /** + * Whether this emoji is managed by an external service + * @type {?boolean} + */ + this.managed = data.managed; + } + + if ('available' in data) { + /** + * Whether this emoji is available + * @type {?boolean} + */ + this.available = data.available; + } + } +} + +module.exports = BaseGuildEmoji; diff --git a/src/structures/BaseGuildTextChannel.js b/src/structures/BaseGuildTextChannel.js new file mode 100644 index 00000000..d0f28bb --- /dev/null +++ b/src/structures/BaseGuildTextChannel.js @@ -0,0 +1,229 @@ +'use strict'; + +const GuildChannel = require('./GuildChannel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); +const MessageManager = require('../managers/MessageManager'); +const ThreadManager = require('../managers/ThreadManager'); + +/** + * Represents a text-based guild channel on Discord. + * @extends {GuildChannel} + * @implements {TextBasedChannel} + */ +class BaseGuildTextChannel extends GuildChannel { + constructor(guild, data, client) { + super(guild, data, client, false); + + /** + * A manager of the messages sent to this channel + * @type {MessageManager} + */ + this.messages = new MessageManager(this); + + /** + * A manager of the threads belonging to this channel + * @type {ThreadManager} + */ + this.threads = new ThreadManager(this); + + /** + * If the guild considers this channel NSFW + * @type {boolean} + */ + this.nsfw = Boolean(data.nsfw); + + this._patch(data); + } + + _patch(data) { + super._patch(data); + + if ('topic' in data) { + /** + * The topic of the text channel + * @type {?string} + */ + this.topic = data.topic; + } + + if ('nsfw' in data) { + this.nsfw = Boolean(data.nsfw); + } + + if ('last_message_id' in data) { + /** + * The last message id sent in the channel, if one was sent + * @type {?Snowflake} + */ + this.lastMessageId = data.last_message_id; + } + + if ('last_pin_timestamp' in data) { + /** + * The timestamp when the last pinned message was pinned, if there was one + * @type {?number} + */ + this.lastPinTimestamp = data.last_pin_timestamp ? Date.parse(data.last_pin_timestamp) : null; + } + + if ('default_auto_archive_duration' in data) { + /** + * The default auto archive duration for newly created threads in this channel + * @type {?ThreadAutoArchiveDuration} + */ + this.defaultAutoArchiveDuration = data.default_auto_archive_duration; + } + + if ('messages' in data) { + for (const message of data.messages) this.messages._add(message); + } + } + + /** + * Sets the default auto archive duration for all newly created threads in this channel. + * @param {ThreadAutoArchiveDuration} defaultAutoArchiveDuration The new default auto archive duration + * @param {string} [reason] Reason for changing the channel's default auto archive duration + * @returns {Promise} + */ + setDefaultAutoArchiveDuration(defaultAutoArchiveDuration, reason) { + return this.edit({ defaultAutoArchiveDuration }, reason); + } + + /** + * Sets whether this channel is flagged as NSFW. + * @param {boolean} [nsfw=true] Whether the channel should be considered NSFW + * @param {string} [reason] Reason for changing the channel's NSFW flag + * @returns {Promise} + */ + setNSFW(nsfw = true, reason) { + return this.edit({ nsfw }, reason); + } + + /** + * Sets the type of this channel (only conversion between text and news is supported) + * @param {string} type The new channel type + * @param {string} [reason] Reason for changing the channel's type + * @returns {Promise} + */ + setType(type, reason) { + return this.edit({ type }, reason); + } + + /** + * Fetches all webhooks for the channel. + * @returns {Promise>} + * @example + * // Fetch webhooks + * channel.fetchWebhooks() + * .then(hooks => console.log(`This channel has ${hooks.size} hooks`)) + * .catch(console.error); + */ + fetchWebhooks() { + return this.guild.channels.fetchWebhooks(this.id); + } + + /** + * Options used to create a {@link Webhook} in a {@link TextChannel} or a {@link NewsChannel}. + * @typedef {Object} ChannelWebhookCreateOptions + * @property {?(BufferResolvable|Base64Resolvable)} [avatar] Avatar for the webhook + * @property {string} [reason] Reason for creating the webhook + */ + + /** + * Creates a webhook for the channel. + * @param {string} name The name of the webhook + * @param {ChannelWebhookCreateOptions} [options] Options for creating the webhook + * @returns {Promise} Returns the created Webhook + * @example + * // Create a webhook for the current channel + * channel.createWebhook('Snek', { + * avatar: 'https://i.imgur.com/mI8XcpG.jpg', + * reason: 'Needed a cool new Webhook' + * }) + * .then(console.log) + * .catch(console.error) + */ + createWebhook(name, options = {}) { + return this.guild.channels.createWebhook(this.id, name, options); + } + + /** + * Sets a new topic for the guild channel. + * @param {?string} topic The new topic for the guild channel + * @param {string} [reason] Reason for changing the guild channel's topic + * @returns {Promise} + * @example + * // Set a new channel topic + * channel.setTopic('needs more rate limiting') + * .then(newChannel => console.log(`Channel's new topic is ${newChannel.topic}`)) + * .catch(console.error); + */ + setTopic(topic, reason) { + return this.edit({ topic }, reason); + } + + /** + * Data that can be resolved to an Application. This can be: + * * An Application + * * An Activity with associated Application + * * A Snowflake + * @typedef {Application|Snowflake} ApplicationResolvable + */ + + /** + * Options used to create an invite to a guild channel. + * @typedef {Object} CreateInviteOptions + * @property {boolean} [temporary] Whether members that joined via the invite should be automatically + * kicked after 24 hours if they have not yet received a role + * @property {number} [maxAge] How long the invite should last (in seconds, 0 for forever) + * @property {number} [maxUses] Maximum number of uses + * @property {boolean} [unique] Create a unique invite, or use an existing one with similar settings + * @property {UserResolvable} [targetUser] The user whose stream to display for this invite, + * required if `targetType` is {@link InviteTargetType.Stream}, the user must be streaming in the channel + * @property {ApplicationResolvable} [targetApplication] The embedded application to open for this invite, + * required if `targetType` is {@link InviteTargetType.Stream}, the application must have the + * {@link InviteTargetType.EmbeddedApplication} flag + * @property {InviteTargetType} [targetType] The type of the target for this voice channel invite + * @property {string} [reason] The reason for creating the invite + */ + + /** + * Creates an invite to this guild channel. + * @param {CreateInviteOptions} [options={}] The options for creating the invite + * @returns {Promise} + * @example + * // Create an invite to a channel + * channel.createInvite() + * .then(invite => console.log(`Created an invite with a code of ${invite.code}`)) + * .catch(console.error); + */ + createInvite(options) { + return this.guild.invites.create(this.id, options); + } + + /** + * Fetches a collection of invites to this guild channel. + * Resolves with a collection mapping invites by their codes. + * @param {boolean} [cache=true] Whether or not to cache the fetched invites + * @returns {Promise>} + */ + fetchInvites(cache = true) { + return this.guild.invites.fetch({ channelId: this.id, cache }); + } + + // These are here only for documentation purposes - they are implemented by TextBasedChannel + /* eslint-disable no-empty-function */ + get lastMessage() {} + get lastPinAt() {} + send() {} + sendTyping() {} + createMessageCollector() {} + awaitMessages() {} + createMessageComponentCollector() {} + awaitMessageComponent() {} + bulkDelete() {} +} + +TextBasedChannel.applyToClass(BaseGuildTextChannel, true); + +module.exports = BaseGuildTextChannel; diff --git a/src/structures/BaseGuildVoiceChannel.js b/src/structures/BaseGuildVoiceChannel.js new file mode 100644 index 00000000..ad905cd --- /dev/null +++ b/src/structures/BaseGuildVoiceChannel.js @@ -0,0 +1,123 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { PermissionFlagsBits } = require('discord-api-types/v9'); +const GuildChannel = require('./GuildChannel'); + +/** + * Represents a voice-based guild channel on Discord. + * @extends {GuildChannel} + */ +class BaseGuildVoiceChannel extends GuildChannel { + _patch(data) { + super._patch(data); + + if ('rtc_region' in data) { + /** + * The RTC region for this voice-based channel. This region is automatically selected if `null`. + * @type {?string} + */ + this.rtcRegion = data.rtc_region; + } + + if ('bitrate' in data) { + /** + * The bitrate of this voice-based channel + * @type {number} + */ + this.bitrate = data.bitrate; + } + + if ('user_limit' in data) { + /** + * The maximum amount of users allowed in this channel. + * @type {number} + */ + this.userLimit = data.user_limit; + } + } + + /** + * The members in this voice-based channel + * @type {Collection} + * @readonly + */ + get members() { + const coll = new Collection(); + for (const state of this.guild.voiceStates.cache.values()) { + if (state.channelId === this.id && state.member) { + coll.set(state.id, state.member); + } + } + return coll; + } + + /** + * Checks if the voice-based channel is full + * @type {boolean} + * @readonly + */ + get full() { + return this.userLimit > 0 && this.members.size >= this.userLimit; + } + + /** + * Whether the channel is joinable by the client user + * @type {boolean} + * @readonly + */ + get joinable() { + if (!this.viewable) return false; + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + + // This flag allows joining even if timed out + if (permissions.has(PermissionFlagsBits.Administrator, false)) return true; + + return ( + this.guild.me.communicationDisabledUntilTimestamp < Date.now() && + permissions.has(PermissionFlagsBits.Connect, false) + ); + } + + /** + * Sets the RTC region of the channel. + * @param {?string} region The new region of the channel. Set to `null` to remove a specific region for the channel + * @returns {Promise} + * @example + * // Set the RTC region to europe + * channel.setRTCRegion('europe'); + * @example + * // Remove a fixed region for this channel - let Discord decide automatically + * channel.setRTCRegion(null); + */ + setRTCRegion(region) { + return this.edit({ rtcRegion: region }); + } + + /** + * Creates an invite to this guild channel. + * @param {CreateInviteOptions} [options={}] The options for creating the invite + * @returns {Promise} + * @example + * // Create an invite to a channel + * channel.createInvite() + * .then(invite => console.log(`Created an invite with a code of ${invite.code}`)) + * .catch(console.error); + */ + createInvite(options) { + return this.guild.invites.create(this.id, options); + } + + /** + * Fetches a collection of invites to this guild channel. + * Resolves with a collection mapping invites by their codes. + * @param {boolean} [cache=true] Whether or not to cache the fetched invites + * @returns {Promise>} + */ + fetchInvites(cache = true) { + return this.guild.invites.fetch({ channelId: this.id, cache }); + } +} + +module.exports = BaseGuildVoiceChannel; diff --git a/src/structures/ButtonComponent.js b/src/structures/ButtonComponent.js new file mode 100644 index 00000000..a6cce10 --- /dev/null +++ b/src/structures/ButtonComponent.js @@ -0,0 +1,12 @@ +'use strict'; + +const { ButtonComponent: BuildersButtonComponent } = require('@discordjs/builders'); +const Transformers = require('../util/Transformers'); + +class ButtonComponent extends BuildersButtonComponent { + constructor(data) { + super(Transformers.toSnakeCase(data)); + } +} + +module.exports = ButtonComponent; diff --git a/src/structures/ButtonInteraction.js b/src/structures/ButtonInteraction.js new file mode 100644 index 00000000..db57592 --- /dev/null +++ b/src/structures/ButtonInteraction.js @@ -0,0 +1,11 @@ +'use strict'; + +const MessageComponentInteraction = require('./MessageComponentInteraction'); + +/** + * Represents a button interaction. + * @extends {MessageComponentInteraction} + */ +class ButtonInteraction extends MessageComponentInteraction {} + +module.exports = ButtonInteraction; diff --git a/src/structures/CategoryChannel.js b/src/structures/CategoryChannel.js new file mode 100644 index 00000000..6f53858 --- /dev/null +++ b/src/structures/CategoryChannel.js @@ -0,0 +1,32 @@ +'use strict'; + +const GuildChannel = require('./GuildChannel'); +const CategoryChannelChildManager = require('../managers/CategoryChannelChildManager'); + +/** + * Represents a guild category channel on Discord. + * @extends {GuildChannel} + */ +class CategoryChannel extends GuildChannel { + /** + * A manager of the channels belonging to this category + * @type {CategoryChannelChildManager} + * @readonly + */ + get children() { + return new CategoryChannelChildManager(this); + } + + /** + * Sets the category parent of this channel. + * It is not currently possible to set the parent of a CategoryChannel. + * @method setParent + * @memberof CategoryChannel + * @instance + * @param {?CategoryChannelResolvable} channel The channel to set as parent + * @param {SetParentOptions} [options={}] The options for setting the parent + * @returns {Promise} + */ +} + +module.exports = CategoryChannel; diff --git a/src/structures/Channel.js b/src/structures/Channel.js new file mode 100644 index 00000000..01086d3 --- /dev/null +++ b/src/structures/Channel.js @@ -0,0 +1,280 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { ChannelType, Routes } = require('discord-api-types/v9'); +const Base = require('./Base'); +const { ThreadChannelTypes } = require('../util/Constants'); +let CategoryChannel; +let DMChannel; +let NewsChannel; +let StageChannel; +let StoreChannel; +let TextChannel; +let ThreadChannel; +let VoiceChannel; + +/** + * Represents any channel on Discord. + * @extends {Base} + * @abstract + */ +class Channel extends Base { + constructor(client, data, immediatePatch = true) { + super(client); + + /** + * The type of the channel + * @type {ChannelType} + */ + this.type = data.type; + + if (data && immediatePatch) this._patch(data); + } + + _patch(data) { + /** + * The channel's id + * @type {Snowflake} + */ + this.id = data.id; + } + + /** + * The timestamp the channel was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the channel was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The URL to the channel + * @type {string} + * @readonly + */ + get url() { + return `https://discord.com/channels/${this.isDMBased() ? '@me' : this.guildId}/${this.id}`; + } + + /** + * Whether this Channel is a partial + * This is always false outside of DM channels. + * @type {boolean} + * @readonly + */ + get partial() { + return false; + } + + /** + * When concatenated with a string, this automatically returns the channel's mention instead of the Channel object. + * @returns {string} + * @example + * // Logs: Hello from <#123456789012345678>! + * console.log(`Hello from ${channel}!`); + */ + toString() { + return `<#${this.id}>`; + } + + /** + * Deletes this channel. + * @returns {Promise} + * @example + * // Delete the channel + * channel.delete() + * .then(console.log) + * .catch(console.error); + */ + async delete() { + await this.client.api.channels(this.id).delete(); + return this; + } + + /** + * Fetches this channel. + * @param {boolean} [force=true] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetch(force = true) { + return this.client.channels.fetch(this.id, { force }); + } + + /** + * Indicates whether this channel is a {@link TextChannel}. + * @returns {boolean} + */ + isText() { + return this.type === ChannelType.GuildText; + } + + /** + * Indicates whether this channel is a {@link DMChannel}. + * @returns {boolean} + */ + isDM() { + return this.type === ChannelType.DM; + } + + /** + * Indicates whether this channel is a {@link VoiceChannel}. + * @returns {boolean} + */ + isVoice() { + return this.type === ChannelType.GuildVoice; + } + + /** + * Indicates whether this channel is a {@link PartialGroupDMChannel}. + * @returns {boolean} + */ + isGroupDM() { + return this.type === ChannelType.GroupDM; + } + + /** + * Indicates whether this channel is a {@link CategoryChannel}. + * @returns {boolean} + */ + isCategory() { + return this.type === ChannelType.GuildCategory; + } + + /** + * Indicates whether this channel is a {@link NewsChannel}. + * @returns {boolean} + */ + isNews() { + return this.type === ChannelType.GuildNews; + } + + /** + * Indicates whether this channel is a {@link StoreChannel}. + * @returns {boolean} + */ + isStore() { + return this.type === ChannelType.GuildStore; + } + + /** + * Indicates whether this channel is a {@link ThreadChannel}. + * @returns {boolean} + */ + isThread() { + return ThreadChannelTypes.includes(this.type); + } + + /** + * Indicates whether this channel is a {@link StageChannel}. + * @returns {boolean} + */ + isStage() { + return this.type === ChannelType.GuildStageVoice; + } + + /** + * Indicates whether this channel is {@link TextBasedChannels text-based}. + * @returns {boolean} + */ + isTextBased() { + return 'messages' in this; + } + + /** + * Indicates whether this channel is DM-based (either a {@link DMChannel} or a {@link PartialGroupDMChannel}). + * @returns {boolean} + */ + isDMBased() { + return [ChannelType.DM, ChannelType.GroupDM].includes(this.type); + } + + /** + * Indicates whether this channel is {@link BaseGuildVoiceChannel voice-based}. + * @returns {boolean} + */ + isVoiceBased() { + return 'bitrate' in this; + } + + static create(client, data, guild, { allowUnknownGuild, fromInteraction } = {}) { + CategoryChannel ??= require('./CategoryChannel'); + DMChannel ??= require('./DMChannel'); + NewsChannel ??= require('./NewsChannel'); + StageChannel ??= require('./StageChannel'); + StoreChannel ??= require('./StoreChannel'); + TextChannel ??= require('./TextChannel'); + ThreadChannel ??= require('./ThreadChannel'); + VoiceChannel ??= require('./VoiceChannel'); + + let channel; + if (!data.guild_id && !guild) { + if ((data.recipients && data.type !== ChannelType.GroupDM) || data.type === ChannelType.DM) { + channel = new DMChannel(client, data); + } else if (data.type === ChannelType.GroupDM) { + const PartialGroupDMChannel = require('./PartialGroupDMChannel'); + channel = new PartialGroupDMChannel(client, data); + } + } else { + guild ??= client.guilds.cache.get(data.guild_id); + + if (guild || allowUnknownGuild) { + switch (data.type) { + case ChannelType.GuildText: { + channel = new TextChannel(guild, data, client); + break; + } + case ChannelType.GuildVoice: { + channel = new VoiceChannel(guild, data, client); + break; + } + case ChannelType.GuildCategory: { + channel = new CategoryChannel(guild, data, client); + break; + } + case ChannelType.GuildNews: { + channel = new NewsChannel(guild, data, client); + break; + } + case ChannelType.GuildStore: { + channel = new StoreChannel(guild, data, client); + break; + } + case ChannelType.GuildStageVoice: { + channel = new StageChannel(guild, data, client); + break; + } + case ChannelType.GuildNewsThread: + case ChannelType.GuildPublicThread: + case ChannelType.GuildPrivateThread: { + channel = new ThreadChannel(guild, data, client, fromInteraction); + if (!allowUnknownGuild) channel.parent?.threads.cache.set(channel.id, channel); + break; + } + } + if (channel && !allowUnknownGuild) guild.channels?.cache.set(channel.id, channel); + } + } + return channel; + } + + toJSON(...props) { + return super.toJSON({ createdTimestamp: true }, ...props); + } +} + +exports.Channel = Channel; + +/** + * @external APIChannel + * @see {@link https://discord.com/developers/docs/resources/channel#channel-object} + */ diff --git a/src/structures/ChatInputCommandInteraction.js b/src/structures/ChatInputCommandInteraction.js new file mode 100644 index 00000000..35175e4 --- /dev/null +++ b/src/structures/ChatInputCommandInteraction.js @@ -0,0 +1,41 @@ +'use strict'; + +const CommandInteraction = require('./CommandInteraction'); +const CommandInteractionOptionResolver = require('./CommandInteractionOptionResolver'); + +/** + * Represents a command interaction. + * @extends {CommandInteraction} + */ +class ChatInputCommandInteraction extends CommandInteraction { + constructor(client, data) { + super(client, data); + + /** + * The options passed to the command. + * @type {CommandInteractionOptionResolver} + */ + this.options = new CommandInteractionOptionResolver( + this.client, + data.data.options?.map(option => this.transformOption(option, data.data.resolved)) ?? [], + this.transformResolved(data.data.resolved ?? {}), + ); + } + + /** + * Returns a string representation of the command interaction. + * This can then be copied by a user and executed again in a new command while keeping the option order. + * @returns {string} + */ + toString() { + const properties = [ + this.commandName, + this.options._group, + this.options._subcommand, + ...this.options._hoistedOptions.map(o => `${o.name}:${o.value}`), + ]; + return `/${properties.filter(Boolean).join(' ')}`; + } +} + +module.exports = ChatInputCommandInteraction; diff --git a/src/structures/ClientApplication.js b/src/structures/ClientApplication.js new file mode 100644 index 00000000..de8270e --- /dev/null +++ b/src/structures/ClientApplication.js @@ -0,0 +1,109 @@ +'use strict'; + +const Team = require('./Team'); +const { Error } = require('../errors/DJSError'); +const Application = require('./interfaces/Application'); +const ApplicationFlagsBitField = require('../util/ApplicationFlagsBitField'); +const ApplicationCommandManager = require('../managers/ApplicationCommandManager'); + +/** + * Represents a Client OAuth2 Application. + * @extends {Application} + */ +class ClientApplication extends Application { + constructor(client, data) { + super(client, data); + + /** + * The application command manager for this application + * @type {ApplicationCommandManager} + */ + this.commands = new ApplicationCommandManager(this.client); + } + + _patch(data) { + super._patch(data); + + if(!data) return; + + if ('flags' in data) { + /** + * The flags this application has + * @type {ApplicationFlagsBitField} + */ + this.flags = new ApplicationFlagsBitField(data.flags).freeze(); + } + + if ('cover_image' in data) { + /** + * The hash of the application's cover image + * @type {?string} + */ + this.cover = data.cover_image; + } else { + this.cover ??= null; + } + + if ('rpc_origins' in data) { + /** + * The application's RPC origins, if enabled + * @type {string[]} + */ + this.rpcOrigins = data.rpc_origins; + } else { + this.rpcOrigins ??= []; + } + + if ('bot_require_code_grant' in data) { + /** + * If this application's bot requires a code grant when using the OAuth2 flow + * @type {?boolean} + */ + this.botRequireCodeGrant = data.bot_require_code_grant; + } else { + this.botRequireCodeGrant ??= null; + } + + if ('bot_public' in data) { + /** + * If this application's bot is public + * @type {?boolean} + */ + this.botPublic = data.bot_public; + } else { + this.botPublic ??= null; + } + + /** + * The owner of this OAuth application + * @type {?(User|Team)} + */ + this.owner = data.team + ? new Team(this.client, data.team) + : data.owner + ? this.client.users._add(data.owner) + : this.owner ?? null; + } + + /** + * Whether this application is partial + * @type {boolean} + * @readonly + */ + get partial() { + return !this.name; + } + + /** + * Obtains this application from Discord. + * @returns {Promise} + */ + async fetch() { + if(!this.client.bot) throw new Error("INVALID_USER_METHOD"); + const app = await this.client.api.oauth2.applications('@me').get(); + this._patch(app); + return this; + } +} + +module.exports = ClientApplication; diff --git a/src/structures/ClientPresence.js b/src/structures/ClientPresence.js new file mode 100644 index 00000000..c9e67db --- /dev/null +++ b/src/structures/ClientPresence.js @@ -0,0 +1,80 @@ +'use strict'; + +const { GatewayOpcodes } = require('discord-api-types/v9'); +const { Presence } = require('./Presence'); +const { TypeError } = require('../errors'); + +/** + * Represents the client's presence. + * @extends {Presence} + */ +class ClientPresence extends Presence { + constructor(client, data = {}) { + super(client, Object.assign(data, { status: data.status ?? 'online', user: { id: null } })); + } + + /** + * Sets the client's presence + * @param {PresenceData} presence The data to set the presence to + * @returns {ClientPresence} + */ + set(presence) { + const packet = this._parse(presence); + this._patch(packet); + if (typeof presence.shardId === 'undefined') { + this.client.ws.broadcast({ op: GatewayOpcodes.PresenceUpdate, d: packet }); + } else if (Array.isArray(presence.shardId)) { + for (const shardId of presence.shardId) { + this.client.ws.shards.get(shardId).send({ op: GatewayOpcodes.PresenceUpdate, d: packet }); + } + } else { + this.client.ws.shards.get(presence.shardId).send({ op: GatewayOpcodes.PresenceUpdate, d: packet }); + } + return this; + } + + /** + * Parses presence data into a packet ready to be sent to Discord + * @param {PresenceData} presence The data to parse + * @returns {APIPresence} + * @private + */ + _parse({ status, since, afk, activities }) { + const data = { + activities: [], + afk: typeof afk === 'boolean' ? afk : false, + since: typeof since === 'number' && !Number.isNaN(since) ? since : null, + status: status ?? this.status, + }; + if (activities?.length) { + for (const [i, activity] of activities.entries()) { + if (typeof activity.name !== 'string') throw new TypeError('INVALID_TYPE', `activities[${i}].name`, 'string'); + activity.type ??= 0; + + data.activities.push({ + type: activity.type, + name: activity.name, + url: activity.url, + }); + } + } else if (!activities && (status || afk || since) && this.activities.length) { + data.activities.push( + ...this.activities.map(a => ({ + name: a.name, + type: a.type, + url: a.url ?? undefined, + })), + ); + } + + return data; + } +} + +module.exports = ClientPresence; + +/* eslint-disable max-len */ +/** + * @external APIPresence + * @see {@link https://discord.com/developers/docs/rich-presence/how-to#updating-presence-update-presence-payload-fields} + */ diff --git a/src/structures/ClientUser.js b/src/structures/ClientUser.js new file mode 100644 index 00000000..30b82a4 --- /dev/null +++ b/src/structures/ClientUser.js @@ -0,0 +1,182 @@ +'use strict'; + +const { Routes } = require('discord-api-types/v9'); +const User = require('./User'); +const DataResolver = require('../util/DataResolver'); + +/** + * Represents the logged in client's Discord user. + * @extends {User} + */ +class ClientUser extends User { + _patch(data) { + super._patch(data); + + if ('verified' in data) { + /** + * Whether or not this account has been verified + * @type {boolean} + */ + this.verified = data.verified; + } + + if ('mfa_enabled' in data) { + /** + * If the bot's {@link ClientApplication#owner Owner} has MFA enabled on their account + * @type {?boolean} + */ + this.mfaEnabled = typeof data.mfa_enabled === 'boolean' ? data.mfa_enabled : null; + } else { + this.mfaEnabled ??= null; + } + + if ('token' in data) this.client.token = data.token; + } + + /** + * Represents the client user's presence + * @type {ClientPresence} + * @readonly + */ + get presence() { + return this.client.presence; + } + + /** + * Data used to edit the logged in client + * @typedef {Object} ClientUserEditData + * @property {string} [username] The new username + * @property {?(BufferResolvable|Base64Resolvable)} [avatar] The new avatar + */ + + /** + * Edits the logged in client. + * @param {ClientUserEditData} data The new data + * @returns {Promise} + */ + async edit(data) { + if (typeof data.avatar !== 'undefined') data.avatar = await DataResolver.resolveImage(data.avatar); + const newData = await this.client.api.users('@me').patch({ data }); + this.client.token = newData.token; + const { updated } = this.client.actions.UserUpdate.handle(newData); + return updated ?? this; + } + + /** + * Sets the username of the logged in client. + * Changing usernames in Discord is heavily rate limited, with only 2 requests + * every hour. Use this sparingly! + * @param {string} username The new username + * @returns {Promise} + * @example + * // Set username + * client.user.setUsername('discordjs') + * .then(user => console.log(`My new username is ${user.username}`)) + * .catch(console.error); + */ + setUsername(username) { + return this.edit({ username }); + } + + /** + * Sets the avatar of the logged in client. + * @param {?(BufferResolvable|Base64Resolvable)} avatar The new avatar + * @returns {Promise} + * @example + * // Set avatar + * client.user.setAvatar('./avatar.png') + * .then(user => console.log(`New avatar set!`)) + * .catch(console.error); + */ + setAvatar(avatar) { + return this.edit({ avatar }); + } + + /** + * Options for setting activities + * @typedef {Object} ActivitiesOptions + * @property {string} [name] Name of the activity + * @property {ActivityType|number} [type] Type of the activity + * @property {string} [url] Twitch / YouTube stream URL + */ + + /** + * Data resembling a raw Discord presence. + * @typedef {Object} PresenceData + * @property {PresenceStatusData} [status] Status of the user + * @property {boolean} [afk] Whether the user is AFK + * @property {ActivitiesOptions[]} [activities] Activity the user is playing + * @property {number|number[]} [shardId] Shard id(s) to have the activity set on + */ + + /** + * Sets the full presence of the client user. + * @param {PresenceData} data Data for the presence + * @returns {ClientPresence} + * @example + * // Set the client user's presence + * client.user.setPresence({ activities: [{ name: 'with discord.js' }], status: 'idle' }); + */ + setPresence(data) { + return this.client.presence.set(data); + } + + /** + * A user's status. Must be one of: + * * `online` + * * `idle` + * * `invisible` + * * `dnd` (do not disturb) + * @typedef {string} PresenceStatusData + */ + + /** + * Sets the status of the client user. + * @param {PresenceStatusData} status Status to change to + * @param {number|number[]} [shardId] Shard id(s) to have the activity set on + * @returns {ClientPresence} + * @example + * // Set the client user's status + * client.user.setStatus('idle'); + */ + setStatus(status, shardId) { + return this.setPresence({ status, shardId }); + } + + /** + * Options for setting an activity. + * @typedef {Object} ActivityOptions + * @property {string} [name] Name of the activity + * @property {string} [url] Twitch / YouTube stream URL + * @property {ActivityType|number} [type] Type of the activity + * @property {number|number[]} [shardId] Shard Id(s) to have the activity set on + */ + + /** + * Sets the activity the client user is playing. + * @param {string|ActivityOptions} [name] Activity being played, or options for setting the activity + * @param {ActivityOptions} [options] Options for setting the activity + * @returns {ClientPresence} + * @example + * // Set the client user's activity + * client.user.setActivity('discord.js', { type: ActivityType.Watching }); + */ + setActivity(name, options = {}) { + if (!name) return this.setPresence({ activities: [], shardId: options.shardId }); + + const activity = Object.assign({}, options, typeof name === 'object' ? name : { name }); + return this.setPresence({ activities: [activity], shardId: activity.shardId }); + } + + /** + * Sets/removes the AFK flag for the client user. + * @param {boolean} [afk=true] Whether or not the user is AFK + * @param {number|number[]} [shardId] Shard Id(s) to have the AFK flag set on + * @returns {ClientPresence} + */ + setAFK(afk = true, shardId) { + return this.setPresence({ afk, shardId }); + } +} + +module.exports = ClientUser; diff --git a/src/structures/CommandInteraction.js b/src/structures/CommandInteraction.js new file mode 100644 index 00000000..4987d09 --- /dev/null +++ b/src/structures/CommandInteraction.js @@ -0,0 +1,216 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Interaction = require('./Interaction'); +const InteractionWebhook = require('./InteractionWebhook'); +const MessageAttachment = require('./MessageAttachment'); +const InteractionResponses = require('./interfaces/InteractionResponses'); + +/** + * Represents a command interaction. + * @extends {Interaction} + * @implements {InteractionResponses} + * @abstract + */ +class CommandInteraction extends Interaction { + constructor(client, data) { + super(client, data); + + /** + * The id of the channel this interaction was sent in + * @type {Snowflake} + * @name CommandInteraction#channelId + */ + + /** + * The invoked application command's id + * @type {Snowflake} + */ + this.commandId = data.data.id; + + /** + * The invoked application command's name + * @type {string} + */ + this.commandName = data.data.name; + + /** + * The invoked application command's type + * @type {ApplicationCommandType} + */ + this.commandType = data.data.type; + + /** + * Whether the reply to this interaction has been deferred + * @type {boolean} + */ + this.deferred = false; + + /** + * Whether this interaction has already been replied to + * @type {boolean} + */ + this.replied = false; + + /** + * Whether the reply to this interaction is ephemeral + * @type {?boolean} + */ + this.ephemeral = null; + + /** + * An associated interaction webhook, can be used to further interact with this interaction + * @type {InteractionWebhook} + */ + this.webhook = new InteractionWebhook(this.client, this.applicationId, this.token); + } + + /** + * The invoked application command, if it was fetched before + * @type {?ApplicationCommand} + */ + get command() { + const id = this.commandId; + return this.guild?.commands.cache.get(id) ?? this.client.application.commands.cache.get(id) ?? null; + } + + /** + * Represents the resolved data of a received command interaction. + * @typedef {Object} CommandInteractionResolvedData + * @property {Collection} [users] The resolved users + * @property {Collection} [members] The resolved guild members + * @property {Collection} [roles] The resolved roles + * @property {Collection} [channels] The resolved channels + * @property {Collection} [messages] The resolved messages + * @property {Collection} [attachments] The resolved attachments + */ + + /** + * Transforms the resolved received from the API. + * @param {APIInteractionDataResolved} resolved The received resolved objects + * @returns {CommandInteractionResolvedData} + * @private + */ + transformResolved({ members, users, channels, roles, messages, attachments }) { + const result = {}; + + if (members) { + result.members = new Collection(); + for (const [id, member] of Object.entries(members)) { + const user = users[id]; + result.members.set(id, this.guild?.members._add({ user, ...member }) ?? member); + } + } + + if (users) { + result.users = new Collection(); + for (const user of Object.values(users)) { + result.users.set(user.id, this.client.users._add(user)); + } + } + + if (roles) { + result.roles = new Collection(); + for (const role of Object.values(roles)) { + result.roles.set(role.id, this.guild?.roles._add(role) ?? role); + } + } + + if (channels) { + result.channels = new Collection(); + for (const channel of Object.values(channels)) { + result.channels.set(channel.id, this.client.channels._add(channel, this.guild) ?? channel); + } + } + + if (messages) { + result.messages = new Collection(); + for (const message of Object.values(messages)) { + result.messages.set(message.id, this.channel?.messages?._add(message) ?? message); + } + } + + if (attachments) { + result.attachments = new Collection(); + for (const attachment of Object.values(attachments)) { + const patched = new MessageAttachment(attachment.url, attachment.filename, attachment); + result.attachments.set(attachment.id, patched); + } + } + + return result; + } + + /** + * Represents an option of a received command interaction. + * @typedef {Object} CommandInteractionOption + * @property {string} name The name of the option + * @property {ApplicationCommandOptionType} type The type of the option + * @property {boolean} [autocomplete] Whether the autocomplete interaction is enabled for a + * {@link ApplicationCommandOptionType.String}, {@link ApplicationCommandOptionType.Integer} or + * {@link ApplicationCommandOptionType.Number} option + * @property {string|number|boolean} [value] The value of the option + * @property {CommandInteractionOption[]} [options] Additional options if this option is a + * subcommand (group) + * @property {User} [user] The resolved user + * @property {GuildMember|APIGuildMember} [member] The resolved member + * @property {GuildChannel|ThreadChannel|APIChannel} [channel] The resolved channel + * @property {Role|APIRole} [role] The resolved role + * @property {MessageAttachment} [attachment] The resolved attachment + */ + + /** + * Transforms an option received from the API. + * @param {APIApplicationCommandOption} option The received option + * @param {APIInteractionDataResolved} resolved The resolved interaction data + * @returns {CommandInteractionOption} + * @private + */ + transformOption(option, resolved) { + const result = { + name: option.name, + type: option.type, + }; + + if ('value' in option) result.value = option.value; + if ('options' in option) result.options = option.options.map(opt => this.transformOption(opt, resolved)); + + if (resolved) { + const user = resolved.users?.[option.value]; + if (user) result.user = this.client.users._add(user); + + const member = resolved.members?.[option.value]; + if (member) result.member = this.guild?.members._add({ user, ...member }) ?? member; + + const channel = resolved.channels?.[option.value]; + if (channel) result.channel = this.client.channels._add(channel, this.guild) ?? channel; + + const role = resolved.roles?.[option.value]; + if (role) result.role = this.guild?.roles._add(role) ?? role; + + const attachment = resolved.attachments?.[option.value]; + if (attachment) result.attachment = new MessageAttachment(attachment.url, attachment.filename, attachment); + } + + return result; + } + + // These are here only for documentation purposes - they are implemented by InteractionResponses + /* eslint-disable no-empty-function */ + deferReply() {} + reply() {} + fetchReply() {} + editReply() {} + deleteReply() {} + followUp() {} +} + +InteractionResponses.applyToClass(CommandInteraction, ['deferUpdate', 'update']); + +module.exports = CommandInteraction; + +/* eslint-disable max-len */ +/** + * @external APIInteractionDataResolved + * @see {@link https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-resolved-data-structure} + */ diff --git a/src/structures/CommandInteractionOptionResolver.js b/src/structures/CommandInteractionOptionResolver.js new file mode 100644 index 00000000..2aa6938 --- /dev/null +++ b/src/structures/CommandInteractionOptionResolver.js @@ -0,0 +1,272 @@ +'use strict'; + +const { ApplicationCommandOptionType } = require('discord-api-types/v9'); +const { TypeError } = require('../errors'); + +/** + * A resolver for command interaction options. + */ +class CommandInteractionOptionResolver { + constructor(client, options, resolved) { + /** + * The client that instantiated this. + * @name CommandInteractionOptionResolver#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * The name of the subcommand group. + * @type {?string} + * @private + */ + this._group = null; + + /** + * The name of the subcommand. + * @type {?string} + * @private + */ + this._subcommand = null; + + /** + * The bottom-level options for the interaction. + * If there is a subcommand (or subcommand and group), this is the options for the subcommand. + * @type {CommandInteractionOption[]} + * @private + */ + this._hoistedOptions = options; + + // Hoist subcommand group if present + if (this._hoistedOptions[0]?.type === ApplicationCommandOptionType.SubcommandGroup) { + this._group = this._hoistedOptions[0].name; + this._hoistedOptions = this._hoistedOptions[0].options ?? []; + } + // Hoist subcommand if present + if (this._hoistedOptions[0]?.type === ApplicationCommandOptionType.Subcommand) { + this._subcommand = this._hoistedOptions[0].name; + this._hoistedOptions = this._hoistedOptions[0].options ?? []; + } + + /** + * The interaction options array. + * @name CommandInteractionOptionResolver#data + * @type {ReadonlyArray} + * @readonly + */ + Object.defineProperty(this, 'data', { value: Object.freeze([...options]) }); + + /** + * The interaction resolved data + * @name CommandInteractionOptionResolver#resolved + * @type {Readonly} + */ + Object.defineProperty(this, 'resolved', { value: Object.freeze(resolved) }); + } + + /** + * Gets an option by its name. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?CommandInteractionOption} The option, if found. + */ + get(name, required = false) { + const option = this._hoistedOptions.find(opt => opt.name === name); + if (!option) { + if (required) { + throw new TypeError('COMMAND_INTERACTION_OPTION_NOT_FOUND', name); + } + return null; + } + return option; + } + + /** + * Gets an option by name and property and checks its type. + * @param {string} name The name of the option. + * @param {ApplicationCommandOptionType} type The type of the option. + * @param {string[]} properties The properties to check for for `required`. + * @param {boolean} required Whether to throw an error if the option is not found. + * @returns {?CommandInteractionOption} The option, if found. + * @private + */ + _getTypedOption(name, type, properties, required) { + const option = this.get(name, required); + if (!option) { + return null; + } else if (option.type !== type) { + throw new TypeError('COMMAND_INTERACTION_OPTION_TYPE', name, option.type, type); + } else if (required && properties.every(prop => option[prop] === null || typeof option[prop] === 'undefined')) { + throw new TypeError('COMMAND_INTERACTION_OPTION_EMPTY', name, option.type); + } + return option; + } + + /** + * Gets the selected subcommand. + * @param {boolean} [required=true] Whether to throw an error if there is no subcommand. + * @returns {?string} The name of the selected subcommand, or null if not set and not required. + */ + getSubcommand(required = true) { + if (required && !this._subcommand) { + throw new TypeError('COMMAND_INTERACTION_OPTION_NO_SUB_COMMAND'); + } + return this._subcommand; + } + + /** + * Gets the selected subcommand group. + * @param {boolean} [required=false] Whether to throw an error if there is no subcommand group. + * @returns {?string} The name of the selected subcommand group, or null if not set and not required. + */ + getSubcommandGroup(required = false) { + if (required && !this._group) { + throw new TypeError('COMMAND_INTERACTION_OPTION_NO_SUB_COMMAND_GROUP'); + } + return this._group; + } + + /** + * Gets a boolean option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?boolean} The value of the option, or null if not set and not required. + */ + getBoolean(name, required = false) { + const option = this._getTypedOption(name, ApplicationCommandOptionType.Boolean, ['value'], required); + return option?.value ?? null; + } + + /** + * Gets a channel option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?(GuildChannel|ThreadChannel|APIChannel)} + * The value of the option, or null if not set and not required. + */ + getChannel(name, required = false) { + const option = this._getTypedOption(name, ApplicationCommandOptionType.Channel, ['channel'], required); + return option?.channel ?? null; + } + + /** + * Gets a string option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?string} The value of the option, or null if not set and not required. + */ + getString(name, required = false) { + const option = this._getTypedOption(name, ApplicationCommandOptionType.String, ['value'], required); + return option?.value ?? null; + } + + /** + * Gets an integer option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?number} The value of the option, or null if not set and not required. + */ + getInteger(name, required = false) { + const option = this._getTypedOption(name, ApplicationCommandOptionType.Integer, ['value'], required); + return option?.value ?? null; + } + + /** + * Gets a number option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?number} The value of the option, or null if not set and not required. + */ + getNumber(name, required = false) { + const option = this._getTypedOption(name, ApplicationCommandOptionType.Number, ['value'], required); + return option?.value ?? null; + } + + /** + * Gets a user option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?User} The value of the option, or null if not set and not required. + */ + getUser(name, required = false) { + const option = this._getTypedOption(name, ApplicationCommandOptionType.User, ['user'], required); + return option?.user ?? null; + } + + /** + * Gets a member option. + * @param {string} name The name of the option. + * @returns {?(GuildMember|APIGuildMember)} + * The value of the option, or null if the user is not present in the guild or the option is not set. + */ + getMember(name) { + const option = this._getTypedOption(name, ApplicationCommandOptionType.User, ['member'], false); + return option?.member ?? null; + } + + /** + * Gets a role option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?(Role|APIRole)} The value of the option, or null if not set and not required. + */ + getRole(name, required = false) { + const option = this._getTypedOption(name, ApplicationCommandOptionType.Role, ['role'], required); + return option?.role ?? null; + } + + /** + * Gets an attachment option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?MessageAttachment} The value of the option, or null if not set and not required. + */ + getAttachment(name, required = false) { + const option = this._getTypedOption(name, ApplicationCommandOptionType.Attachment, ['attachment'], required); + return option?.attachment ?? null; + } + + /** + * Gets a mentionable option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?(User|GuildMember|APIGuildMember|Role|APIRole)} + * The value of the option, or null if not set and not required. + */ + getMentionable(name, required = false) { + const option = this._getTypedOption( + name, + ApplicationCommandOptionType.Mentionable, + ['user', 'member', 'role'], + required, + ); + return option?.member ?? option?.user ?? option?.role ?? null; + } + + /** + * Gets a message option. + * @param {string} name The name of the option. + * @param {boolean} [required=false] Whether to throw an error if the option is not found. + * @returns {?(Message|APIMessage)} + * The value of the option, or null if not set and not required. + */ + getMessage(name, required = false) { + const option = this._getTypedOption(name, '_MESSAGE', ['message'], required); + return option?.message ?? null; + } + + /** + * Gets the focused option. + * @param {boolean} [getFull=false] Whether to get the full option object + * @returns {string|number|ApplicationCommandOptionChoice} + * The value of the option, or the whole option if getFull is true + */ + getFocused(getFull = false) { + const focusedOption = this._hoistedOptions.find(option => option.focused); + if (!focusedOption) throw new TypeError('AUTOCOMPLETE_INTERACTION_OPTION_NO_FOCUSED_OPTION'); + return getFull ? focusedOption : focusedOption.value; + } +} + +module.exports = CommandInteractionOptionResolver; diff --git a/src/structures/ContextMenuCommandInteraction.js b/src/structures/ContextMenuCommandInteraction.js new file mode 100644 index 00000000..360f97e --- /dev/null +++ b/src/structures/ContextMenuCommandInteraction.js @@ -0,0 +1,59 @@ +'use strict'; + +const { ApplicationCommandOptionType } = require('discord-api-types/v9'); +const CommandInteraction = require('./CommandInteraction'); +const CommandInteractionOptionResolver = require('./CommandInteractionOptionResolver'); + +/** + * Represents a context menu interaction. + * @extends {CommandInteraction} + */ +class ContextMenuCommandInteraction extends CommandInteraction { + constructor(client, data) { + super(client, data); + /** + * The target of the interaction, parsed into options + * @type {CommandInteractionOptionResolver} + */ + this.options = new CommandInteractionOptionResolver( + this.client, + this.resolveContextMenuOptions(data.data), + this.transformResolved(data.data.resolved), + ); + + /** + * The id of the target of the interaction + * @type {Snowflake} + */ + this.targetId = data.data.target_id; + } + + /** + * Resolves and transforms options received from the API for a context menu interaction. + * @param {APIApplicationCommandInteractionData} data The interaction data + * @returns {CommandInteractionOption[]} + * @private + */ + resolveContextMenuOptions({ target_id, resolved }) { + const result = []; + + if (resolved.users?.[target_id]) { + result.push( + this.transformOption({ name: 'user', type: ApplicationCommandOptionType.User, value: target_id }, resolved), + ); + } + + if (resolved.messages?.[target_id]) { + result.push({ + name: 'message', + type: '_MESSAGE', + value: target_id, + message: this.channel?.messages._add(resolved.messages[target_id]) ?? resolved.messages[target_id], + }); + } + + return result; + } +} + +module.exports = ContextMenuCommandInteraction; diff --git a/src/structures/DMChannel.js b/src/structures/DMChannel.js new file mode 100644 index 00000000..5117a0e --- /dev/null +++ b/src/structures/DMChannel.js @@ -0,0 +1,102 @@ +'use strict'; + +const { ChannelType } = require('discord-api-types/v9'); +const { Channel } = require('./Channel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); +const MessageManager = require('../managers/MessageManager'); + +/** + * Represents a direct message channel between two users. + * @extends {Channel} + * @implements {TextBasedChannel} + */ +class DMChannel extends Channel { + constructor(client, data) { + super(client, data); + + // Override the channel type so partials have a known type + this.type = ChannelType.DM; + + /** + * A manager of the messages belonging to this channel + * @type {MessageManager} + */ + this.messages = new MessageManager(this); + } + + _patch(data) { + super._patch(data); + + if (data.recipients) { + /** + * The recipient on the other end of the DM + * @type {User} + */ + this.recipient = this.client.users._add(data.recipients[0]); + } + + if ('last_message_id' in data) { + /** + * The channel's last message id, if one was sent + * @type {?Snowflake} + */ + this.lastMessageId = data.last_message_id; + } + + if ('last_pin_timestamp' in data) { + /** + * The timestamp when the last pinned message was pinned, if there was one + * @type {?number} + */ + this.lastPinTimestamp = Date.parse(data.last_pin_timestamp); + } else { + this.lastPinTimestamp ??= null; + } + } + + /** + * Whether this DMChannel is a partial + * @type {boolean} + * @readonly + */ + get partial() { + return typeof this.lastMessageId === 'undefined'; + } + + /** + * Fetch this DMChannel. + * @param {boolean} [force=true] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetch(force = true) { + return this.recipient.createDM(force); + } + + /** + * When concatenated with a string, this automatically returns the recipient's mention instead of the + * DMChannel object. + * @returns {string} + * @example + * // Logs: Hello from <@123456789012345678>! + * console.log(`Hello from ${channel}!`); + */ + toString() { + return this.recipient.toString(); + } + + // These are here only for documentation purposes - they are implemented by TextBasedChannel + /* eslint-disable no-empty-function */ + get lastMessage() {} + get lastPinAt() {} + send() {} + sendTyping() {} + createMessageCollector() {} + awaitMessages() {} + createMessageComponentCollector() {} + awaitMessageComponent() {} + // Doesn't work on DM channels; bulkDelete() {} +} + +TextBasedChannel.applyToClass(DMChannel, true, ['bulkDelete']); + +module.exports = DMChannel; diff --git a/src/structures/Embed.js b/src/structures/Embed.js new file mode 100644 index 00000000..b200237 --- /dev/null +++ b/src/structures/Embed.js @@ -0,0 +1,12 @@ +'use strict'; + +const { Embed: BuildersEmbed } = require('@discordjs/builders'); +const Transformers = require('../util/Transformers'); + +class Embed extends BuildersEmbed { + constructor(data) { + super(Transformers.toSnakeCase(data)); + } +} + +module.exports = Embed; diff --git a/src/structures/Emoji.js b/src/structures/Emoji.js new file mode 100644 index 00000000..409d292 --- /dev/null +++ b/src/structures/Emoji.js @@ -0,0 +1,108 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const Base = require('./Base'); + +/** + * Represents raw emoji data from the API + * @typedef {APIEmoji} RawEmoji + * @property {?Snowflake} id The emoji's id + * @property {?string} name The emoji's name + * @property {?boolean} animated Whether the emoji is animated + */ + +/** + * Represents an emoji, see {@link GuildEmoji} and {@link ReactionEmoji}. + * @extends {Base} + */ +class Emoji extends Base { + constructor(client, emoji) { + super(client); + /** + * Whether or not the emoji is animated + * @type {?boolean} + */ + this.animated = emoji.animated ?? null; + + /** + * The emoji's name + * @type {?string} + */ + this.name = emoji.name ?? null; + + /** + * The emoji's id + * @type {?Snowflake} + */ + this.id = emoji.id; + } + + /** + * The identifier of this emoji, used for message reactions + * @type {string} + * @readonly + */ + get identifier() { + if (this.id) return `${this.animated ? 'a:' : ''}${this.name}:${this.id}`; + return encodeURIComponent(this.name); + } + + /** + * The URL to the emoji file if it's a custom emoji + * @type {?string} + * @readonly + */ + get url() { + return this.id && this.client.rest.cdn.emoji(this.id, this.animated ? 'gif' : 'png'); + } + + /** + * The timestamp the emoji was created at, or null if unicode + * @type {?number} + * @readonly + */ + get createdTimestamp() { + return this.id && DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the emoji was created at, or null if unicode + * @type {?Date} + * @readonly + */ + get createdAt() { + return this.id && new Date(this.createdTimestamp); + } + + /** + * When concatenated with a string, this automatically returns the text required to form a graphical emoji on Discord + * instead of the Emoji object. + * @returns {string} + * @example + * // Send a custom emoji from a guild: + * const emoji = guild.emojis.cache.first(); + * msg.channel.send(`Hello! ${emoji}`); + * @example + * // Send the emoji used in a reaction to the channel the reaction is part of + * reaction.message.channel.send(`The emoji used was: ${reaction.emoji}`); + */ + toString() { + return this.id ? `<${this.animated ? 'a' : ''}:${this.name}:${this.id}>` : this.name; + } + + toJSON() { + return super.toJSON({ + guild: 'guildId', + createdTimestamp: true, + url: true, + identifier: true, + }); + } +} + +exports.Emoji = Emoji; + +/** + * @external APIEmoji + * @see {@link https://discord.com/developers/docs/resources/emoji#emoji-object} + */ diff --git a/src/structures/Guild.js b/src/structures/Guild.js new file mode 100644 index 00000000..bd22e98 --- /dev/null +++ b/src/structures/Guild.js @@ -0,0 +1,1354 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { ChannelType, GuildPremiumTier, Routes } = require('discord-api-types/v9'); +const AnonymousGuild = require('./AnonymousGuild'); +const GuildAuditLogs = require('./GuildAuditLogs'); +const GuildPreview = require('./GuildPreview'); +const GuildTemplate = require('./GuildTemplate'); +const Integration = require('./Integration'); +const Webhook = require('./Webhook'); +const WelcomeScreen = require('./WelcomeScreen'); +const { Error, TypeError } = require('../errors'); +const GuildApplicationCommandManager = require('../managers/GuildApplicationCommandManager'); +const GuildBanManager = require('../managers/GuildBanManager'); +const GuildChannelManager = require('../managers/GuildChannelManager'); +const GuildEmojiManager = require('../managers/GuildEmojiManager'); +const GuildInviteManager = require('../managers/GuildInviteManager'); +const GuildMemberManager = require('../managers/GuildMemberManager'); +const GuildScheduledEventManager = require('../managers/GuildScheduledEventManager'); +const GuildStickerManager = require('../managers/GuildStickerManager'); +const PresenceManager = require('../managers/PresenceManager'); +const RoleManager = require('../managers/RoleManager'); +const StageInstanceManager = require('../managers/StageInstanceManager'); +const VoiceStateManager = require('../managers/VoiceStateManager'); +const DataResolver = require('../util/DataResolver'); +const Partials = require('../util/Partials'); +const Status = require('../util/Status'); +const SystemChannelFlagsBitField = require('../util/SystemChannelFlagsBitField'); +const Util = require('../util/Util'); + +/** + * Represents a guild (or a server) on Discord. + * It's recommended to see if a guild is available before performing operations or reading data from it. You can + * check this with {@link Guild#available}. + * @extends {AnonymousGuild} + */ +class Guild extends AnonymousGuild { + constructor(client, data) { + super(client, data, false); + + /** + * A manager of the application commands belonging to this guild + * @type {GuildApplicationCommandManager} + */ + this.commands = new GuildApplicationCommandManager(this); + + /** + * A manager of the members belonging to this guild + * @type {GuildMemberManager} + */ + this.members = new GuildMemberManager(this); + + /** + * A manager of the channels belonging to this guild + * @type {GuildChannelManager} + */ + this.channels = new GuildChannelManager(this); + + /** + * A manager of the bans belonging to this guild + * @type {GuildBanManager} + */ + this.bans = new GuildBanManager(this); + + /** + * A manager of the roles belonging to this guild + * @type {RoleManager} + */ + this.roles = new RoleManager(this); + + /** + * A manager of the presences belonging to this guild + * @type {PresenceManager} + */ + this.presences = new PresenceManager(this.client); + + /** + * A manager of the voice states of this guild + * @type {VoiceStateManager} + */ + this.voiceStates = new VoiceStateManager(this); + + /** + * A manager of the stage instances of this guild + * @type {StageInstanceManager} + */ + this.stageInstances = new StageInstanceManager(this); + + /** + * A manager of the invites of this guild + * @type {GuildInviteManager} + */ + this.invites = new GuildInviteManager(this); + + /** + * A manager of the scheduled events of this guild + * @type {GuildScheduledEventManager} + */ + this.scheduledEvents = new GuildScheduledEventManager(this); + + if (!data) return; + if (data.unavailable) { + /** + * Whether the guild is available to access. If it is not available, it indicates a server outage + * @type {boolean} + */ + this.available = false; + } else { + this._patch(data); + if (!data.channels) this.available = false; + } + + /** + * The id of the shard this Guild belongs to. + * @type {number} + */ + this.shardId = data.shardId; + } + + /** + * The Shard this Guild belongs to. + * @type {WebSocketShard} + * @readonly + */ + get shard() { + return this.client.ws.shards.get(this.shardId); + } + + _patch(data) { + super._patch(data); + this.id = data.id; + if ('name' in data) this.name = data.name; + if ('icon' in data) this.icon = data.icon; + if ('unavailable' in data) { + this.available = !data.unavailable; + } else { + this.available ??= true; + } + + if ('discovery_splash' in data) { + /** + * The hash of the guild discovery splash image + * @type {?string} + */ + this.discoverySplash = data.discovery_splash; + } + + if ('member_count' in data) { + /** + * The full amount of members in this guild + * @type {number} + */ + this.memberCount = data.member_count; + } + + if ('large' in data) { + /** + * Whether the guild is "large" (has more than {@link WebsocketOptions large_threshold} members, 50 by default) + * @type {boolean} + */ + this.large = Boolean(data.large); + } + + if ('premium_progress_bar_enabled' in data) { + /** + * Whether this guild has its premium (boost) progress bar enabled + * @type {boolean} + */ + this.premiumProgressBarEnabled = data.premium_progress_bar_enabled; + } + + if ('application_id' in data) { + /** + * The id of the application that created this guild (if applicable) + * @type {?Snowflake} + */ + this.applicationId = data.application_id; + } + + if ('afk_timeout' in data) { + /** + * The time in seconds before a user is counted as "away from keyboard" + * @type {?number} + */ + this.afkTimeout = data.afk_timeout; + } + + if ('afk_channel_id' in data) { + /** + * The id of the voice channel where AFK members are moved + * @type {?Snowflake} + */ + this.afkChannelId = data.afk_channel_id; + } + + if ('system_channel_id' in data) { + /** + * The system channel's id + * @type {?Snowflake} + */ + this.systemChannelId = data.system_channel_id; + } + + if ('premium_tier' in data) { + /** + * The premium tier of this guild + * @type {GuildPremiumTier} + */ + this.premiumTier = data.premium_tier; + } + + if ('widget_enabled' in data) { + /** + * Whether widget images are enabled on this guild + * @type {?boolean} + */ + this.widgetEnabled = data.widget_enabled; + } + + if ('widget_channel_id' in data) { + /** + * The widget channel's id, if enabled + * @type {?string} + */ + this.widgetChannelId = data.widget_channel_id; + } + + if ('explicit_content_filter' in data) { + /** + * The explicit content filter level of the guild + * @type {GuildExplicitContentFilter} + */ + this.explicitContentFilter = data.explicit_content_filter; + } + + if ('mfa_level' in data) { + /** + * The required MFA level for this guild + * @type {MFALevel} + */ + this.mfaLevel = data.mfa_level; + } + + if ('joined_at' in data) { + /** + * The timestamp the client user joined the guild at + * @type {number} + */ + this.joinedTimestamp = Date.parse(data.joined_at); + } + + if ('default_message_notifications' in data) { + /** + * The default message notification level of the guild + * @type {GuildDefaultMessageNotifications} + */ + this.defaultMessageNotifications = data.default_message_notifications; + } + + if ('system_channel_flags' in data) { + /** + * The value set for the guild's system channel flags + * @type {Readonly} + */ + this.systemChannelFlags = new SystemChannelFlagsBitField( + data.system_channel_flags, + ).freeze(); + } + + if ('max_members' in data) { + /** + * The maximum amount of members the guild can have + * @type {?number} + */ + this.maximumMembers = data.max_members; + } else { + this.maximumMembers ??= null; + } + + if ('max_presences' in data) { + /** + * The maximum amount of presences the guild can have (this is `null` for all but the largest of guilds) + * You will need to fetch the guild using {@link Guild#fetch} if you want to receive this parameter + * @type {?number} + */ + this.maximumPresences = data.max_presences; + } else { + this.maximumPresences ??= null; + } + + if ('approximate_member_count' in data) { + /** + * The approximate amount of members the guild has + * You will need to fetch the guild using {@link Guild#fetch} if you want to receive this parameter + * @type {?number} + */ + this.approximateMemberCount = data.approximate_member_count; + } else { + this.approximateMemberCount ??= null; + } + + if ('approximate_presence_count' in data) { + /** + * The approximate amount of presences the guild has + * You will need to fetch the guild using {@link Guild#fetch} if you want to receive this parameter + * @type {?number} + */ + this.approximatePresenceCount = data.approximate_presence_count; + } else { + this.approximatePresenceCount ??= null; + } + + /** + * The use count of the vanity URL code of the guild, if any + * You will need to fetch this parameter using {@link Guild#fetchVanityData} if you want to receive it + * @type {?number} + */ + this.vanityURLUses ??= null; + + if ('rules_channel_id' in data) { + /** + * The rules channel's id for the guild + * @type {?Snowflake} + */ + this.rulesChannelId = data.rules_channel_id; + } + + if ('public_updates_channel_id' in data) { + /** + * The community updates channel's id for the guild + * @type {?Snowflake} + */ + this.publicUpdatesChannelId = data.public_updates_channel_id; + } + + if ('preferred_locale' in data) { + /** + * The preferred locale of the guild, defaults to `en-US` + * @type {string} + * @see {@link https://discord.com/developers/docs/reference#locales} + */ + this.preferredLocale = data.preferred_locale; + } + + if (data.channels) { + this.channels.cache.clear(); + for (const rawChannel of data.channels) { + this.client.channels._add(rawChannel, this); + } + } + + if (data.threads) { + for (const rawThread of data.threads) { + this.client.channels._add(rawThread, this); + } + } + + if (data.roles) { + this.roles.cache.clear(); + for (const role of data.roles) this.roles._add(role); + } + + if (data.members) { + this.members.cache.clear(); + for (const guildUser of data.members) this.members._add(guildUser); + } + + if ('owner_id' in data) { + /** + * The user id of this guild's owner + * @type {Snowflake} + */ + this.ownerId = data.owner_id; + } + + if (data.presences) { + for (const presence of data.presences) { + this.presences._add(Object.assign(presence, { guild: this })); + } + } + + if (data.stage_instances) { + this.stageInstances.cache.clear(); + for (const stageInstance of data.stage_instances) { + this.stageInstances._add(stageInstance); + } + } + + if (data.guild_scheduled_events) { + this.scheduledEvents.cache.clear(); + for (const scheduledEvent of data.guild_scheduled_events) { + this.scheduledEvents._add(scheduledEvent); + } + } + + if (data.voice_states) { + this.voiceStates.cache.clear(); + for (const voiceState of data.voice_states) { + this.voiceStates._add(voiceState); + } + } + + if (!this.emojis) { + /** + * A manager of the emojis belonging to this guild + * @type {GuildEmojiManager} + */ + this.emojis = new GuildEmojiManager(this); + if (data.emojis) for (const emoji of data.emojis) this.emojis._add(emoji); + } else if (data.emojis) { + this.client.actions.GuildEmojisUpdate.handle({ + guild_id: this.id, + emojis: data.emojis, + }); + } + + if (!this.stickers) { + /** + * A manager of the stickers belonging to this guild + * @type {GuildStickerManager} + */ + this.stickers = new GuildStickerManager(this); + if (data.stickers) + for (const sticker of data.stickers) this.stickers._add(sticker); + } else if (data.stickers) { + this.client.actions.GuildStickersUpdate.handle({ + guild_id: this.id, + stickers: data.stickers, + }); + } + } + + /** + * The time the client user joined the guild + * @type {Date} + * @readonly + */ + get joinedAt() { + return new Date(this.joinedTimestamp); + } + + /** + * Positions of the guild [User Account] + * @type {number} + * @readonly + */ + get position() { + return ( + this.client.setting.guildMetadata.get(this.id.toString())?.guildIndex || null + ); + } + + /** + * Folder Guilds + * @type {object} + * @readonly + */ + get folder() { + return ( + this.client.setting.guildMetadata.get(this.id.toString()) || {} + ); + } + + /** + * The URL to this guild's discovery splash image. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + discoverySplashURL(options = {}) { + return ( + this.discoverySplash && + this.client.rest.cdn.discoverySplash( + this.id, + this.discoverySplash, + options, + ) + ); + } + + /** + * Fetches the owner of the guild. + * If the member object isn't needed, use {@link Guild#ownerId} instead. + * @param {BaseFetchOptions} [options] The options for fetching the member + * @returns {Promise} + */ + fetchOwner(options) { + return this.members.fetch({ ...options, user: this.ownerId }); + } + + /** + * AFK voice channel for this guild + * @type {?VoiceChannel} + * @readonly + */ + get afkChannel() { + return this.client.channels.resolve(this.afkChannelId); + } + + /** + * System channel for this guild + * @type {?TextChannel} + * @readonly + */ + get systemChannel() { + return this.client.channels.resolve(this.systemChannelId); + } + + /** + * Widget channel for this guild + * @type {?TextChannel} + * @readonly + */ + get widgetChannel() { + return this.client.channels.resolve(this.widgetChannelId); + } + + /** + * Rules channel for this guild + * @type {?TextChannel} + * @readonly + */ + get rulesChannel() { + return this.client.channels.resolve(this.rulesChannelId); + } + + /** + * Public updates channel for this guild + * @type {?TextChannel} + * @readonly + */ + get publicUpdatesChannel() { + return this.client.channels.resolve(this.publicUpdatesChannelId); + } + + /** + * The client user as a GuildMember of this guild + * @type {?GuildMember} + * @readonly + */ + get me() { + return ( + this.members.resolve(this.client.user.id) ?? + (this.client.options.partials.includes(Partials.GuildMember) + ? this.members._add({ user: { id: this.client.user.id } }, true) + : null) + ); + } + + /** + * The maximum bitrate available for this guild + * @type {number} + * @readonly + */ + get maximumBitrate() { + if (this.features.includes('VIP_REGIONS')) { + return 384_000; + } + + switch (this.premiumTier) { + case GuildPremiumTier.Tier1: + return 128_000; + case GuildPremiumTier.Tier2: + return 256_000; + case GuildPremiumTier.Tier3: + return 384_000; + default: + return 96_000; + } + } + + /** + * Fetches a collection of integrations to this guild. + * Resolves with a collection mapping integrations by their ids. + * @returns {Promise>} + * @example + * // Fetch integrations + * guild.fetchIntegrations() + * .then(integrations => console.log(`Fetched ${integrations.size} integrations`)) + * .catch(console.error); + */ + async fetchIntegrations() { + const data = await this.client.api.guilds(this.id).integrations.get(); + return data.reduce( + (collection, integration) => + collection.set( + integration.id, + new Integration(this.client, integration, this), + ), + new Collection(), + ); + } + + /** + * Fetches a collection of templates from this guild. + * Resolves with a collection mapping templates by their codes. + * @returns {Promise>} + */ + async fetchTemplates() { + const templates = await this.client.api.guilds(this.id).templates.get(); + return templates.reduce( + (col, data) => col.set(data.code, new GuildTemplate(this.client, data)), + new Collection(), + ); + } + + /** + * Fetches the welcome screen for this guild. + * @returns {Promise} + */ + async fetchWelcomeScreen() { + const data = await this.client.api.guilds(this.id, 'welcome-screen').get(); + return new WelcomeScreen(this, data); + } + + /** + * Creates a template for the guild. + * @param {string} name The name for the template + * @param {string} [description] The description for the template + * @returns {Promise} + */ + async createTemplate(name, description) { + const data = await this.client.api + .guilds(this.id) + .templates.post({ body: { name, description } }); + return new GuildTemplate(this.client, data); + } + + /** + * Obtains a guild preview for this guild from Discord. + * @returns {Promise} + */ + async fetchPreview() { + const data = await this.client.api.guilds(this.id).preview.get(); + return new GuildPreview(this.client, data); + } + + /** + * An object containing information about a guild's vanity invite. + * @typedef {Object} Vanity + * @property {?string} code Vanity invite code + * @property {number} uses How many times this invite has been used + */ + + /** + * Fetches the vanity URL invite object to this guild. + * Resolves with an object containing the vanity URL invite code and the use count + * @returns {Promise} + * @example + * // Fetch invite data + * guild.fetchVanityData() + * .then(res => { + * console.log(`Vanity URL: https://discord.gg/${res.code} with ${res.uses} uses`); + * }) + * .catch(console.error); + */ + async fetchVanityData() { + if (!this.features.includes('VANITY_URL')) { + throw new Error('VANITY_URL'); + } + const data = await this.client.api.guilds(this.id, 'vanity-url').get(); + this.vanityURLCode = data.code; + this.vanityURLUses = data.uses; + + return data; + } + + /** + * Fetches all webhooks for the guild. + * @returns {Promise>} + * @example + * // Fetch webhooks + * guild.fetchWebhooks() + * .then(webhooks => console.log(`Fetched ${webhooks.size} webhooks`)) + * .catch(console.error); + */ + async fetchWebhooks() { + const apiHooks = await this.client.api.guilds(this.id).webhooks.get(); + const hooks = new Collection(); + for (const hook of apiHooks) + hooks.set(hook.id, new Webhook(this.client, hook)); + return hooks; + } + + /** + * Fetches the guild widget data, requires the widget to be enabled. + * @returns {Promise} + * @example + * // Fetches the guild widget data + * guild.fetchWidget() + * .then(widget => console.log(`The widget shows ${widget.channels.size} channels`)) + * .catch(console.error); + */ + fetchWidget() { + return this.client.fetchGuildWidget(this.id); + } + + /** + * Data for the Guild Widget Settings object + * @typedef {Object} GuildWidgetSettings + * @property {boolean} enabled Whether the widget is enabled + * @property {?GuildChannel} channel The widget invite channel + */ + + /** + * The Guild Widget Settings object + * @typedef {Object} GuildWidgetSettingsData + * @property {boolean} enabled Whether the widget is enabled + * @property {?GuildChannelResolvable} channel The widget invite channel + */ + + /** + * Fetches the guild widget settings. + * @returns {Promise} + * @example + * // Fetches the guild widget settings + * guild.fetchWidgetSettings() + * .then(widget => console.log(`The widget is ${widget.enabled ? 'enabled' : 'disabled'}`)) + * .catch(console.error); + */ + async fetchWidgetSettings() { + const data = await this.client.api.guilds(this.id).widget.get(); + this.widgetEnabled = data.enabled; + this.widgetChannelId = data.channel_id; + return { + enabled: data.enabled, + channel: data.channel_id + ? this.channels.cache.get(data.channel_id) + : null, + }; + } + + /** + * Options used to fetch audit logs. + * @typedef {Object} GuildAuditLogsFetchOptions + * @property {Snowflake|GuildAuditLogsEntry} [before] Only return entries before this entry + * @property {number} [limit] The number of entries to return + * @property {UserResolvable} [user] Only return entries for actions made by this user + * @property {?AuditLogEvent} [type] Only return entries for this action type + */ + + /** + * Fetches audit logs for this guild. + * @param {GuildAuditLogsFetchOptions} [options={}] Options for fetching audit logs + * @returns {Promise} + * @example + * // Output audit log entries + * guild.fetchAuditLogs() + * .then(audit => console.log(audit.entries.first())) + * .catch(console.error); + */ + async fetchAuditLogs(options = {}) { + if (options.before && options.before instanceof GuildAuditLogs.Entry) + options.before = options.before.id; + + const query = new URLSearchParams(); + + if (options.before) { + query.set('before', options.before); + } + + if (options.limit) { + query.set('limit', options.limit); + } + + if (options.user) { + const id = this.client.users.resolveId(options.user); + if (!id) throw new TypeError('INVALID_TYPE', 'user', 'UserResolvable'); + query.set('user_id', id); + } + + if (options.type) { + query.set('action_type', options.type); + } + + const data = await this.client.api + .guilds(this.id) + ['audit-logs'].get({ query }); + return GuildAuditLogs.build(this, data); + } + + /** + * The data for editing a guild. + * @typedef {Object} GuildEditData + * @property {string} [name] The name of the guild + * @property {VerificationLevel|number} [verificationLevel] The verification level of the guild + * @property {ExplicitContentFilterLevel|number} [explicitContentFilter] The level of the explicit content filter + * @property {VoiceChannelResolvable} [afkChannel] The AFK channel of the guild + * @property {TextChannelResolvable} [systemChannel] The system channel of the guild + * @property {number} [afkTimeout] The AFK timeout of the guild + * @property {?(BufferResolvable|Base64Resolvable)} [icon] The icon of the guild + * @property {GuildMemberResolvable} [owner] The owner of the guild + * @property {?(BufferResolvable|Base64Resolvable)} [splash] The invite splash image of the guild + * @property {?(BufferResolvable|Base64Resolvable)} [discoverySplash] The discovery splash image of the guild + * @property {?(BufferResolvable|Base64Resolvable)} [banner] The banner of the guild + * @property {DefaultMessageNotificationLevel|number} [defaultMessageNotifications] The default message notification + * level of the guild + * @property {SystemChannelFlagsResolvable} [systemChannelFlags] The system channel flags of the guild + * @property {TextChannelResolvable} [rulesChannel] The rules channel of the guild + * @property {TextChannelResolvable} [publicUpdatesChannel] The community updates channel of the guild + * @property {string} [preferredLocale] The preferred locale of the guild + * @property {boolean} [premiumProgressBarEnabled] Whether the guild's premium progress bar is enabled + * @property {string} [description] The discovery description of the guild + * @property {GuildFeature[]} [features] The features of the guild + */ + + /** + * Data that can be resolved to a Text Channel object. This can be: + * * A TextChannel + * * A Snowflake + * @typedef {TextChannel|Snowflake} TextChannelResolvable + */ + + /** + * Data that can be resolved to a Voice Channel object. This can be: + * * A VoiceChannel + * * A Snowflake + * @typedef {VoiceChannel|Snowflake} VoiceChannelResolvable + */ + + /** + * Updates the guild with new information - e.g. a new name. + * @param {GuildEditData} data The data to update the guild with + * @param {string} [reason] Reason for editing this guild + * @returns {Promise} + * @example + * // Set the guild name + * guild.edit({ + * name: 'Discord Guild', + * }) + * .then(updated => console.log(`New guild name ${updated}`)) + * .catch(console.error); + */ + async edit(data, reason) { + const _data = {}; + if (data.name) _data.name = data.name; + if (typeof data.verificationLevel !== 'undefined') { + _data.verification_level = data.verificationLevel; + } + if (typeof data.afkChannel !== 'undefined') { + _data.afk_channel_id = this.client.channels.resolveId(data.afkChannel); + } + if (typeof data.systemChannel !== 'undefined') { + _data.system_channel_id = this.client.channels.resolveId( + data.systemChannel, + ); + } + if (data.afkTimeout) _data.afk_timeout = Number(data.afkTimeout); + if (typeof data.icon !== 'undefined') + _data.icon = await DataResolver.resolveImage(data.icon); + if (data.owner) _data.owner_id = this.client.users.resolveId(data.owner); + if (typeof data.splash !== 'undefined') + _data.splash = await DataResolver.resolveImage(data.splash); + if (typeof data.discoverySplash !== 'undefined') { + _data.discovery_splash = await DataResolver.resolveImage( + data.discoverySplash, + ); + } + if (typeof data.banner !== 'undefined') + _data.banner = await DataResolver.resolveImage(data.banner); + if (typeof data.explicitContentFilter !== 'undefined') { + _data.explicit_content_filter = data.explicitContentFilter; + } + if (typeof data.defaultMessageNotifications !== 'undefined') { + _data.default_message_notifications = data.defaultMessageNotifications; + } + if (typeof data.systemChannelFlags !== 'undefined') { + _data.system_channel_flags = SystemChannelFlagsBitField.resolve( + data.systemChannelFlags, + ); + } + if (typeof data.rulesChannel !== 'undefined') { + _data.rules_channel_id = this.client.channels.resolveId( + data.rulesChannel, + ); + } + if (typeof data.publicUpdatesChannel !== 'undefined') { + _data.public_updates_channel_id = this.client.channels.resolveId( + data.publicUpdatesChannel, + ); + } + if (typeof data.features !== 'undefined') { + _data.features = data.features; + } + if (typeof data.description !== 'undefined') { + _data.description = data.description; + } + if (data.preferredLocale) _data.preferred_locale = data.preferredLocale; + if ('premiumProgressBarEnabled' in data) + _data.premium_progress_bar_enabled = data.premiumProgressBarEnabled; + const newData = await this.client.api + .guilds(this.id) + .patch({ body: _data, reason }); + return this.client.actions.GuildUpdate.handle(newData).updated; + } + + /** + * Welcome channel data + * @typedef {Object} WelcomeChannelData + * @property {string} description The description to show for this welcome channel + * @property {TextChannel|NewsChannel|StoreChannel|Snowflake} channel The channel to link for this welcome channel + * @property {EmojiIdentifierResolvable} [emoji] The emoji to display for this welcome channel + */ + + /** + * Welcome screen edit data + * @typedef {Object} WelcomeScreenEditData + * @property {boolean} [enabled] Whether the welcome screen is enabled + * @property {string} [description] The description for the welcome screen + * @property {WelcomeChannelData[]} [welcomeChannels] The welcome channel data for the welcome screen + */ + + /** + * Data that can be resolved to a GuildTextChannel object. This can be: + * * A TextChannel + * * A NewsChannel + * * A Snowflake + * @typedef {TextChannel|NewsChannel|Snowflake} GuildTextChannelResolvable + */ + + /** + * Data that can be resolved to a GuildVoiceChannel object. This can be: + * * A VoiceChannel + * * A StageChannel + * * A Snowflake + * @typedef {VoiceChannel|StageChannel|Snowflake} GuildVoiceChannelResolvable + */ + + /** + * Updates the guild's welcome screen + * @param {WelcomeScreenEditData} data Data to edit the welcome screen with + * @returns {Promise} + * @example + * guild.editWelcomeScreen({ + * description: 'Hello World', + * enabled: true, + * welcomeChannels: [ + * { + * description: 'foobar', + * channel: '222197033908436994', + * } + * ], + * }) + */ + async editWelcomeScreen(data) { + const { enabled, description, welcomeChannels } = data; + const welcome_channels = welcomeChannels?.map((welcomeChannelData) => { + const emoji = this.emojis.resolve(welcomeChannelData.emoji); + return { + emoji_id: emoji?.id, + emoji_name: emoji?.name ?? welcomeChannelData.emoji, + channel_id: this.channels.resolveId(welcomeChannelData.channel), + description: welcomeChannelData.description, + }; + }); + + const patchData = await this.client.api + .guilds(this.id, 'welcome-screen') + .patch({ + body: { + welcome_channels, + description, + enabled, + }, + }); + return new WelcomeScreen(this, patchData); + } + + /** + * Edits the level of the explicit content filter. + * @param {ExplicitContentFilterLevel|number} explicitContentFilter The new level of the explicit content filter + * @param {string} [reason] Reason for changing the level of the guild's explicit content filter + * @returns {Promise} + */ + setExplicitContentFilter(explicitContentFilter, reason) { + return this.edit({ explicitContentFilter }, reason); + } + + /* eslint-disable max-len */ + /** + * Edits the setting of the default message notifications of the guild. + * @param {DefaultMessageNotificationLevel|number} defaultMessageNotifications The new default message notification level of the guild + * @param {string} [reason] Reason for changing the setting of the default message notifications + * @returns {Promise} + */ + setDefaultMessageNotifications(defaultMessageNotifications, reason) { + return this.edit({ defaultMessageNotifications }, reason); + } + /* eslint-enable max-len */ + + /** + * Edits the flags of the default message notifications of the guild. + * @param {SystemChannelFlagsResolvable} systemChannelFlags The new flags for the default message notifications + * @param {string} [reason] Reason for changing the flags of the default message notifications + * @returns {Promise} + */ + setSystemChannelFlags(systemChannelFlags, reason) { + return this.edit({ systemChannelFlags }, reason); + } + + /** + * Edits the name of the guild. + * @param {string} name The new name of the guild + * @param {string} [reason] Reason for changing the guild's name + * @returns {Promise} + * @example + * // Edit the guild name + * guild.setName('Discord Guild') + * .then(updated => console.log(`Updated guild name to ${updated.name}`)) + * .catch(console.error); + */ + setName(name, reason) { + return this.edit({ name }, reason); + } + + /** + * Edits the verification level of the guild. + * @param {VerificationLevel} verificationLevel The new verification level of the guild + * @param {string} [reason] Reason for changing the guild's verification level + * @returns {Promise} + * @example + * // Edit the guild verification level + * guild.setVerificationLevel(1) + * .then(updated => console.log(`Updated guild verification level to ${guild.verificationLevel}`)) + * .catch(console.error); + */ + setVerificationLevel(verificationLevel, reason) { + return this.edit({ verificationLevel }, reason); + } + + /** + * Edits the AFK channel of the guild. + * @param {VoiceChannelResolvable} afkChannel The new AFK channel + * @param {string} [reason] Reason for changing the guild's AFK channel + * @returns {Promise} + * @example + * // Edit the guild AFK channel + * guild.setAFKChannel(channel) + * .then(updated => console.log(`Updated guild AFK channel to ${guild.afkChannel.name}`)) + * .catch(console.error); + */ + setAFKChannel(afkChannel, reason) { + return this.edit({ afkChannel }, reason); + } + + /** + * Edits the system channel of the guild. + * @param {TextChannelResolvable} systemChannel The new system channel + * @param {string} [reason] Reason for changing the guild's system channel + * @returns {Promise} + * @example + * // Edit the guild system channel + * guild.setSystemChannel(channel) + * .then(updated => console.log(`Updated guild system channel to ${guild.systemChannel.name}`)) + * .catch(console.error); + */ + setSystemChannel(systemChannel, reason) { + return this.edit({ systemChannel }, reason); + } + + /** + * Edits the AFK timeout of the guild. + * @param {number} afkTimeout The time in seconds that a user must be idle to be considered AFK + * @param {string} [reason] Reason for changing the guild's AFK timeout + * @returns {Promise} + * @example + * // Edit the guild AFK channel + * guild.setAFKTimeout(60) + * .then(updated => console.log(`Updated guild AFK timeout to ${guild.afkTimeout}`)) + * .catch(console.error); + */ + setAFKTimeout(afkTimeout, reason) { + return this.edit({ afkTimeout }, reason); + } + + /** + * Sets a new guild icon. + * @param {?(Base64Resolvable|BufferResolvable)} icon The new icon of the guild + * @param {string} [reason] Reason for changing the guild's icon + * @returns {Promise} + * @example + * // Edit the guild icon + * guild.setIcon('./icon.png') + * .then(updated => console.log('Updated the guild icon')) + * .catch(console.error); + */ + setIcon(icon, reason) { + return this.edit({ icon }, reason); + } + + /** + * Sets a new owner of the guild. + * @param {GuildMemberResolvable} owner The new owner of the guild + * @param {string} [reason] Reason for setting the new owner + * @returns {Promise} + * @example + * // Edit the guild owner + * guild.setOwner(guild.members.cache.first()) + * .then(guild => guild.fetchOwner()) + * .then(owner => console.log(`Updated the guild owner to ${owner.displayName}`)) + * .catch(console.error); + */ + setOwner(owner, reason) { + return this.edit({ owner }, reason); + } + + /** + * Sets a new guild invite splash image. + * @param {?(Base64Resolvable|BufferResolvable)} splash The new invite splash image of the guild + * @param {string} [reason] Reason for changing the guild's invite splash image + * @returns {Promise} + * @example + * // Edit the guild splash + * guild.setSplash('./splash.png') + * .then(updated => console.log('Updated the guild splash')) + * .catch(console.error); + */ + setSplash(splash, reason) { + return this.edit({ splash }, reason); + } + + /** + * Sets a new guild discovery splash image. + * @param {?(Base64Resolvable|BufferResolvable)} discoverySplash The new discovery splash image of the guild + * @param {string} [reason] Reason for changing the guild's discovery splash image + * @returns {Promise} + * @example + * // Edit the guild discovery splash + * guild.setDiscoverySplash('./discoverysplash.png') + * .then(updated => console.log('Updated the guild discovery splash')) + * .catch(console.error); + */ + setDiscoverySplash(discoverySplash, reason) { + return this.edit({ discoverySplash }, reason); + } + + /** + * Sets a new guild banner. + * @param {?(Base64Resolvable|BufferResolvable)} banner The new banner of the guild + * @param {string} [reason] Reason for changing the guild's banner + * @returns {Promise} + * @example + * guild.setBanner('./banner.png') + * .then(updated => console.log('Updated the guild banner')) + * .catch(console.error); + */ + setBanner(banner, reason) { + return this.edit({ banner }, reason); + } + + /** + * Edits the rules channel of the guild. + * @param {TextChannelResolvable} rulesChannel The new rules channel + * @param {string} [reason] Reason for changing the guild's rules channel + * @returns {Promise} + * @example + * // Edit the guild rules channel + * guild.setRulesChannel(channel) + * .then(updated => console.log(`Updated guild rules channel to ${guild.rulesChannel.name}`)) + * .catch(console.error); + */ + setRulesChannel(rulesChannel, reason) { + return this.edit({ rulesChannel }, reason); + } + + /** + * Edits the community updates channel of the guild. + * @param {TextChannelResolvable} publicUpdatesChannel The new community updates channel + * @param {string} [reason] Reason for changing the guild's community updates channel + * @returns {Promise} + * @example + * // Edit the guild community updates channel + * guild.setPublicUpdatesChannel(channel) + * .then(updated => console.log(`Updated guild community updates channel to ${guild.publicUpdatesChannel.name}`)) + * .catch(console.error); + */ + setPublicUpdatesChannel(publicUpdatesChannel, reason) { + return this.edit({ publicUpdatesChannel }, reason); + } + + /** + * Edits the preferred locale of the guild. + * @param {string} preferredLocale The new preferred locale of the guild + * @param {string} [reason] Reason for changing the guild's preferred locale + * @returns {Promise} + * @example + * // Edit the guild preferred locale + * guild.setPreferredLocale('en-US') + * .then(updated => console.log(`Updated guild preferred locale to ${guild.preferredLocale}`)) + * .catch(console.error); + */ + setPreferredLocale(preferredLocale, reason) { + return this.edit({ preferredLocale }, reason); + } + + /** + * Edits the enabled state of the guild's premium progress bar + * @param {boolean} [enabled=true] The new enabled state of the guild's premium progress bar + * @param {string} [reason] Reason for changing the state of the guild's premium progress bar + * @returns {Promise} + */ + setPremiumProgressBarEnabled(enabled = true, reason) { + return this.edit({ premiumProgressBarEnabled: enabled }, reason); + } + + /** + * Edits the guild's widget settings. + * @param {GuildWidgetSettingsData} settings The widget settings for the guild + * @param {string} [reason] Reason for changing the guild's widget settings + * @returns {Promise} + */ + async setWidgetSettings(settings, reason) { + await this.client.api.guilds(this.id).widget.patch({ + body: { + enabled: settings.enabled, + channel_id: this.channels.resolveId(settings.channel), + }, + reason, + }); + return this; + } + + /** + * Leaves the guild. + * @returns {Promise} + * @example + * // Leave a guild + * guild.leave() + * .then(g => console.log(`Left the guild ${g}`)) + * .catch(console.error); + */ + async leave() { + if (this.ownerId === this.client.user.id) throw new Error('GUILD_OWNED'); + await this.client.api.users('@me').guilds(this.id).delete(); + return this; + } + + /** + * Deletes the guild. + * @returns {Promise} + * @example + * // Delete a guild + * guild.delete() + * .then(g => console.log(`Deleted the guild ${g}`)) + * .catch(console.error); + */ + async delete() { + await this.client.api.guilds(this.id).delete(); + return this; + } + + /** + * Whether this guild equals another guild. It compares all properties, so for most operations + * it is advisable to just compare `guild.id === guild2.id` as it is much faster and is often + * what most users need. + * @param {Guild} guild The guild to compare with + * @returns {boolean} + */ + equals(guild) { + return ( + guild && + guild instanceof this.constructor && + this.id === guild.id && + this.available === guild.available && + this.splash === guild.splash && + this.discoverySplash === guild.discoverySplash && + this.name === guild.name && + this.memberCount === guild.memberCount && + this.large === guild.large && + this.icon === guild.icon && + this.ownerId === guild.ownerId && + this.verificationLevel === guild.verificationLevel && + (this.features === guild.features || + (this.features.length === guild.features.length && + this.features.every((feat, i) => feat === guild.features[i]))) + ); + } + + toJSON() { + const json = super.toJSON({ + available: false, + createdTimestamp: true, + nameAcronym: true, + presences: false, + voiceStates: false, + }); + json.iconURL = this.iconURL(); + json.splashURL = this.splashURL(); + json.discoverySplashURL = this.discoverySplashURL(); + json.bannerURL = this.bannerURL(); + return json; + } + + /** + * The voice state adapter for this guild that can be used with @discordjs/voice to play audio in voice + * and stage channels. + * @type {Function} + * @readonly + */ + get voiceAdapterCreator() { + return (methods) => { + this.client.voice.adapters.set(this.id, methods); + return { + sendPayload: (data) => { + if (this.shard.status !== Status.Ready) return false; + this.shard.send(data); + return true; + }, + destroy: () => { + this.client.voice.adapters.delete(this.id); + }, + }; + }; + } + + /** + * Creates a collection of this guild's roles, sorted by their position and ids. + * @returns {Collection} + * @private + */ + _sortedRoles() { + return Util.discordSort(this.roles.cache); + } + + /** + * Creates a collection of this guild's or a specific category's channels, sorted by their position and ids. + * @param {GuildChannel} [channel] Category to get the channels of + * @returns {Collection} + * @private + */ + _sortedChannels(channel) { + const category = channel.type === ChannelType.GuildCategory; + const channelTypes = [ + ChannelType.GuildText, + ChannelType.GuildNews, + ChannelType.GuildStore, + ]; + return Util.discordSort( + this.channels.cache.filter( + (c) => + (channelTypes.includes(channel.type) + ? channelTypes.includes(c.type) + : c.type === channel.type) && + (category || c.parent === channel.parent), + ), + ); + } +} + +exports.Guild = Guild; + +/** + * @external APIGuild + * @see {@link https://discord.com/developers/docs/resources/guild#guild-object} + */ diff --git a/src/structures/GuildAuditLogs.js b/src/structures/GuildAuditLogs.js new file mode 100644 index 00000000..199e9d1 --- /dev/null +++ b/src/structures/GuildAuditLogs.js @@ -0,0 +1,528 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { OverwriteType, AuditLogEvent } = require('discord-api-types/v9'); +const { GuildScheduledEvent } = require('./GuildScheduledEvent'); +const Integration = require('./Integration'); +const Invite = require('./Invite'); +const { StageInstance } = require('./StageInstance'); +const { Sticker } = require('./Sticker'); +const Webhook = require('./Webhook'); +const Partials = require('../util/Partials'); +const Util = require('../util/Util'); + +/** + * The target type of an entry. Here are the available types: + * * Guild + * * Channel + * * User + * * Role + * * Invite + * * Webhook + * * Emoji + * * Message + * * Integration + * * StageInstance + * * Sticker + * * Thread + * * GuildScheduledEvent + * @typedef {string} AuditLogTargetType + */ + +/** + * Key mirror of all available audit log targets. + * @name GuildAuditLogs.Targets + * @type {Object} + */ +const Targets = { + All: 'All', + Guild: 'Guild', + GuildScheduledEvent: 'GuildScheduledEvent', + Channel: 'Channel', + User: 'User', + Role: 'Role', + Invite: 'Invite', + Webhook: 'Webhook', + Emoji: 'Emoji', + Message: 'Message', + Integration: 'Integration', + StageInstance: 'StageInstance', + Sticker: 'Sticker', + Thread: 'Thread', + Unknown: 'Unknown', +}; + +/** + * Audit logs entries are held in this class. + */ +class GuildAuditLogs { + constructor(guild, data) { + if (data.users) for (const user of data.users) guild.client.users._add(user); + if (data.threads) for (const thread of data.threads) guild.client.channels._add(thread, guild); + /** + * Cached webhooks + * @type {Collection} + * @private + */ + this.webhooks = new Collection(); + if (data.webhooks) { + for (const hook of data.webhooks) { + this.webhooks.set(hook.id, new Webhook(guild.client, hook)); + } + } + + /** + * Cached integrations + * @type {Collection} + * @private + */ + this.integrations = new Collection(); + if (data.integrations) { + for (const integration of data.integrations) { + this.integrations.set(integration.id, new Integration(guild.client, integration, guild)); + } + } + + /** + * The entries for this guild's audit logs + * @type {Collection} + */ + this.entries = new Collection(); + for (const item of data.audit_log_entries) { + const entry = new GuildAuditLogsEntry(this, guild, item); + this.entries.set(entry.id, entry); + } + } + + /** + * Handles possible promises for entry targets. + * @returns {Promise} + */ + static async build(...args) { + const logs = new GuildAuditLogs(...args); + await Promise.all(logs.entries.map(e => e.target)); + return logs; + } + + /** + * The target of an entry. It can be one of: + * * A guild + * * A channel + * * A user + * * A role + * * An invite + * * A webhook + * * An emoji + * * A message + * * An integration + * * A stage instance + * * A sticker + * * A guild scheduled event + * * A thread + * * An object with an id key if target was deleted + * * An object where the keys represent either the new value or the old value + * @typedef {?(Object|Guild|Channel|User|Role|Invite|Webhook|GuildEmoji|Message|Integration|StageInstance|Sticker| + * GuildScheduledEvent)} AuditLogEntryTarget + */ + + /** + * Finds the target type from the entry action. + * @param {AuditLogAction} target The action target + * @returns {AuditLogTargetType} + */ + static targetType(target) { + if (target < 10) return Targets.Guild; + if (target < 20) return Targets.Channel; + if (target < 30) return Targets.User; + if (target < 40) return Targets.Role; + if (target < 50) return Targets.Invite; + if (target < 60) return Targets.Webhook; + if (target < 70) return Targets.Emoji; + if (target < 80) return Targets.Message; + if (target < 83) return Targets.Integration; + if (target < 86) return Targets.StageInstance; + if (target < 100) return Targets.Sticker; + if (target < 110) return Targets.GuildScheduledEvent; + if (target < 120) return Targets.Thread; + return Targets.Unknown; + } + + /** + * The action type of an entry, e.g. `Create`. Here are the available types: + * * Create + * * Delete + * * Update + * * All + * @typedef {string} AuditLogActionType + */ + + /** + * Finds the action type from the entry action. + * @param {AuditLogAction} action The action target + * @returns {AuditLogActionType} + */ + static actionType(action) { + if ( + [ + AuditLogEvent.ChannelCreate, + AuditLogEvent.ChannelOverwriteCreate, + AuditLogEvent.MemberBanRemove, + AuditLogEvent.BotAdd, + AuditLogEvent.RoleCreate, + AuditLogEvent.InviteCreate, + AuditLogEvent.WebhookCreate, + AuditLogEvent.EmojiCreate, + AuditLogEvent.MessagePin, + AuditLogEvent.IntegrationCreate, + AuditLogEvent.StageInstanceCreate, + AuditLogEvent.StickerCreate, + AuditLogEvent.GuildScheduledEventCreate, + AuditLogEvent.ThreadCreate, + ].includes(action) + ) { + return 'Create'; + } + + if ( + [ + AuditLogEvent.ChannelDelete, + AuditLogEvent.ChannelOverwriteDelete, + AuditLogEvent.MemberKick, + AuditLogEvent.MemberPrune, + AuditLogEvent.MemberBanAdd, + AuditLogEvent.MemberDisconnect, + AuditLogEvent.RoleDelete, + AuditLogEvent.InviteDelete, + AuditLogEvent.WebhookDelete, + AuditLogEvent.EmojiDelete, + AuditLogEvent.MessageDelete, + AuditLogEvent.MessageBulkDelete, + AuditLogEvent.MessageUnpin, + AuditLogEvent.IntegrationDelete, + AuditLogEvent.StageInstanceDelete, + AuditLogEvent.StickerDelete, + AuditLogEvent.GuildScheduledEventDelete, + AuditLogEvent.ThreadDelete, + ].includes(action) + ) { + return 'Delete'; + } + + if ( + [ + AuditLogEvent.GuildUpdate, + AuditLogEvent.ChannelUpdate, + AuditLogEvent.ChannelOverwriteUpdate, + AuditLogEvent.MemberUpdate, + AuditLogEvent.MemberRoleUpdate, + AuditLogEvent.MemberMove, + AuditLogEvent.RoleUpdate, + AuditLogEvent.InviteUpdate, + AuditLogEvent.WebhookUpdate, + AuditLogEvent.EmojiUpdate, + AuditLogEvent.IntegrationUpdate, + AuditLogEvent.StageInstanceUpdate, + AuditLogEvent.StickerUpdate, + AuditLogEvent.GuildScheduledEventUpdate, + AuditLogEvent.ThreadUpdate, + ].includes(action) + ) { + return 'Update'; + } + + return 'All'; + } + + toJSON() { + return Util.flatten(this); + } +} + +/** + * Audit logs entry. + */ +class GuildAuditLogsEntry { + constructor(logs, guild, data) { + const targetType = GuildAuditLogs.targetType(data.action_type); + /** + * The target type of this entry + * @type {AuditLogTargetType} + */ + this.targetType = targetType; + + /** + * The action type of this entry + * @type {AuditLogActionType} + */ + this.actionType = GuildAuditLogs.actionType(data.action_type); + + /** + * Specific action type of this entry in its string presentation + * @type {AuditLogAction} + */ + this.action = Object.keys(AuditLogEvent).find(k => AuditLogEvent[k] === data.action_type); + + /** + * The reason of this entry + * @type {?string} + */ + this.reason = data.reason ?? null; + + /** + * The user that executed this entry + * @type {?User} + */ + this.executor = data.user_id + ? guild.client.options.partials.includes(Partials.User) + ? guild.client.users._add({ id: data.user_id }) + : guild.client.users.cache.get(data.user_id) + : null; + + /** + * An entry in the audit log representing a specific change. + * @typedef {Object} AuditLogChange + * @property {string} key The property that was changed, e.g. `nick` for nickname changes + * @property {*} [old] The old value of the change, e.g. for nicknames, the old nickname + * @property {*} [new] The new value of the change, e.g. for nicknames, the new nickname + */ + + /** + * Specific property changes + * @type {?AuditLogChange[]} + */ + this.changes = data.changes?.map(c => ({ key: c.key, old: c.old_value, new: c.new_value })) ?? null; + + /** + * The entry's id + * @type {Snowflake} + */ + this.id = data.id; + + /** + * Any extra data from the entry + * @type {?(Object|Role|GuildMember)} + */ + this.extra = null; + switch (data.action_type) { + case AuditLogEvent.MemberPrune: + this.extra = { + removed: Number(data.options.members_removed), + days: Number(data.options.delete_member_days), + }; + break; + + case AuditLogEvent.MemberMove: + case AuditLogEvent.MessageDelete: + case AuditLogEvent.MessageBulkDelete: + this.extra = { + channel: guild.channels.cache.get(data.options.channel_id) ?? { id: data.options.channel_id }, + count: Number(data.options.count), + }; + break; + + case AuditLogEvent.MessagePin: + case AuditLogEvent.MessageUnpin: + this.extra = { + channel: guild.client.channels.cache.get(data.options.channel_id) ?? { id: data.options.channel_id }, + messageId: data.options.message_id, + }; + break; + + case AuditLogEvent.MemberDisconnect: + this.extra = { + count: Number(data.options.count), + }; + break; + + case AuditLogEvent.ChannelOverwriteCreate: + case AuditLogEvent.ChannelOverwriteUpdate: + case AuditLogEvent.ChannelOverwriteDelete: + switch (data.options.type) { + case OverwriteType.Role: + this.extra = guild.roles.cache.get(data.options.id) ?? { + id: data.options.id, + name: data.options.role_name, + type: OverwriteType.Role, + }; + break; + + case OverwriteType.Member: + this.extra = guild.members.cache.get(data.options.id) ?? { + id: data.options.id, + type: OverwriteType.Member, + }; + break; + + default: + break; + } + break; + + case AuditLogEvent.StageInstanceCreate: + case AuditLogEvent.StageInstanceDelete: + case AuditLogEvent.StageInstanceUpdate: + this.extra = { + channel: guild.client.channels.cache.get(data.options?.channel_id) ?? { id: data.options?.channel_id }, + }; + break; + + default: + break; + } + + /** + * The target of this entry + * @type {?AuditLogEntryTarget} + */ + this.target = null; + if (targetType === Targets.Unknown) { + this.target = this.changes.reduce((o, c) => { + o[c.key] = c.new ?? c.old; + return o; + }, {}); + this.target.id = data.target_id; + // MemberDisconnect and similar types do not provide a target_id. + } else if (targetType === Targets.User && data.target_id) { + this.target = guild.client.options.partials.includes(Partials.User) + ? guild.client.users._add({ id: data.target_id }) + : guild.client.users.cache.get(data.target_id); + } else if (targetType === Targets.Guild) { + this.target = guild.client.guilds.cache.get(data.target_id); + } else if (targetType === Targets.Webhook) { + this.target = + logs.webhooks.get(data.target_id) ?? + new Webhook( + guild.client, + this.changes.reduce( + (o, c) => { + o[c.key] = c.new ?? c.old; + return o; + }, + { + id: data.target_id, + guild_id: guild.id, + }, + ), + ); + } else if (targetType === Targets.Invite) { + let change = this.changes.find(c => c.key === 'code'); + change = change.new ?? change.old; + + this.target = + guild.invites.cache.get(change) ?? + new Invite( + guild.client, + this.changes.reduce( + (o, c) => { + o[c.key] = c.new ?? c.old; + return o; + }, + { guild }, + ), + ); + } else if (targetType === Targets.Message) { + // Discord sends a channel id for the MessageBulkDelete action type. + this.target = + data.action_type === AuditLogEvent.MessageBulkDelete + ? guild.channels.cache.get(data.target_id) ?? { id: data.target_id } + : guild.client.users.cache.get(data.target_id); + } else if (targetType === Targets.Integration) { + this.target = + logs.integrations.get(data.target_id) ?? + new Integration( + guild.client, + this.changes.reduce( + (o, c) => { + o[c.key] = c.new ?? c.old; + return o; + }, + { id: data.target_id }, + ), + guild, + ); + } else if (targetType === Targets.Channel || targetType === Targets.Thread) { + this.target = + guild.channels.cache.get(data.target_id) ?? + this.changes.reduce( + (o, c) => { + o[c.key] = c.new ?? c.old; + return o; + }, + { id: data.target_id }, + ); + } else if (targetType === Targets.StageInstance) { + this.target = + guild.stageInstances.cache.get(data.target_id) ?? + new StageInstance( + guild.client, + this.changes.reduce( + (o, c) => { + o[c.key] = c.new ?? c.old; + return o; + }, + { + id: data.target_id, + channel_id: data.options?.channel_id, + guild_id: guild.id, + }, + ), + ); + } else if (targetType === Targets.Sticker) { + this.target = + guild.stickers.cache.get(data.target_id) ?? + new Sticker( + guild.client, + this.changes.reduce( + (o, c) => { + o[c.key] = c.new ?? c.old; + return o; + }, + { id: data.target_id }, + ), + ); + } else if (targetType === Targets.GuildScheduledEvent) { + this.target = + guild.scheduledEvents.cache.get(data.target_id) ?? + new GuildScheduledEvent( + guild.client, + this.changes.reduce( + (o, c) => { + o[c.key] = c.new ?? c.old; + return o; + }, + { id: data.target_id, guild_id: guild.id }, + ), + ); + } else if (data.target_id) { + this.target = guild[`${targetType.toLowerCase()}s`]?.cache.get(data.target_id) ?? { id: data.target_id }; + } + } + + /** + * The timestamp this entry was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time this entry was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + toJSON() { + return Util.flatten(this, { createdTimestamp: true }); + } +} + +GuildAuditLogs.Targets = Targets; +GuildAuditLogs.Entry = GuildAuditLogsEntry; + +module.exports = GuildAuditLogs; diff --git a/src/structures/GuildBan.js b/src/structures/GuildBan.js new file mode 100644 index 00000000..9c5a10e --- /dev/null +++ b/src/structures/GuildBan.js @@ -0,0 +1,59 @@ +'use strict'; + +const Base = require('./Base'); + +/** + * Represents a ban in a guild on Discord. + * @extends {Base} + */ +class GuildBan extends Base { + constructor(client, data, guild) { + super(client); + + /** + * The guild in which the ban is + * @type {Guild} + */ + this.guild = guild; + + this._patch(data); + } + + _patch(data) { + if ('user' in data) { + /** + * The user this ban applies to + * @type {User} + */ + this.user = this.client.users._add(data.user, true); + } + + if ('reason' in data) { + /** + * The reason for the ban + * @type {?string} + */ + this.reason = data.reason; + } + } + + /** + * Whether this GuildBan is partial. If the reason is not provided the value is null + * @type {boolean} + * @readonly + */ + get partial() { + return !('reason' in this); + } + + /** + * Fetches this GuildBan. + * @param {boolean} [force=true] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetch(force = true) { + return this.guild.bans.fetch({ user: this.user, cache: true, force }); + } +} + +module.exports = GuildBan; diff --git a/src/structures/GuildChannel.js b/src/structures/GuildChannel.js new file mode 100644 index 00000000..67c2f9e --- /dev/null +++ b/src/structures/GuildChannel.js @@ -0,0 +1,456 @@ +'use strict'; + +const { PermissionFlagsBits } = require('discord-api-types/v9'); +const { Channel } = require('./Channel'); +const { Error } = require('../errors'); +const PermissionOverwriteManager = require('../managers/PermissionOverwriteManager'); +const { VoiceBasedChannelTypes } = require('../util/Constants'); +const PermissionsBitField = require('../util/PermissionsBitField'); + +/** + * Represents a guild channel from any of the following: + * - {@link TextChannel} + * - {@link VoiceChannel} + * - {@link CategoryChannel} + * - {@link NewsChannel} + * - {@link StoreChannel} + * - {@link StageChannel} + * @extends {Channel} + * @abstract + */ +class GuildChannel extends Channel { + constructor(guild, data, client, immediatePatch = true) { + super(guild?.client ?? client, data, false); + + /** + * The guild the channel is in + * @type {Guild} + */ + this.guild = guild; + + /** + * The id of the guild the channel is in + * @type {Snowflake} + */ + this.guildId = guild?.id ?? data.guild_id; + + this.parentId = this.parentId ?? null; + /** + * A manager of permission overwrites that belong to this channel + * @type {PermissionOverwriteManager} + */ + this.permissionOverwrites = new PermissionOverwriteManager(this); + + if (data && immediatePatch) this._patch(data); + } + + _patch(data) { + super._patch(data); + + if ('name' in data) { + /** + * The name of the guild channel + * @type {string} + */ + this.name = data.name; + } + + if ('position' in data) { + /** + * The raw position of the channel from Discord + * @type {number} + */ + this.rawPosition = data.position; + } + + if ('guild_id' in data) { + this.guildId = data.guild_id; + } + + if ('parent_id' in data) { + /** + * The id of the category parent of this channel + * @type {?Snowflake} + */ + this.parentId = data.parent_id; + } + + if ('permission_overwrites' in data) { + this.permissionOverwrites.cache.clear(); + for (const overwrite of data.permission_overwrites) { + this.permissionOverwrites._add(overwrite); + } + } + } + + _clone() { + const clone = super._clone(); + clone.permissionOverwrites = new PermissionOverwriteManager(clone, this.permissionOverwrites.cache.values()); + return clone; + } + + /** + * The category parent of this channel + * @type {?CategoryChannel} + * @readonly + */ + get parent() { + return this.guild.channels.resolve(this.parentId); + } + + /** + * If the permissionOverwrites match the parent channel, null if no parent + * @type {?boolean} + * @readonly + */ + get permissionsLocked() { + if (!this.parent) return null; + + // Get all overwrites + const overwriteIds = new Set([ + ...this.permissionOverwrites.cache.keys(), + ...this.parent.permissionOverwrites.cache.keys(), + ]); + + // Compare all overwrites + return [...overwriteIds].every(key => { + const channelVal = this.permissionOverwrites.cache.get(key); + const parentVal = this.parent.permissionOverwrites.cache.get(key); + + // Handle empty overwrite + if ( + (!channelVal && + parentVal.deny.bitfield === PermissionsBitField.defaultBit && + parentVal.allow.bitfield === PermissionsBitField.defaultBit) || + (!parentVal && + channelVal.deny.bitfield === PermissionsBitField.defaultBit && + channelVal.allow.bitfield === PermissionsBitField.defaultBit) + ) { + return true; + } + + // Compare overwrites + return ( + typeof channelVal !== 'undefined' && + typeof parentVal !== 'undefined' && + channelVal.deny.bitfield === parentVal.deny.bitfield && + channelVal.allow.bitfield === parentVal.allow.bitfield + ); + }); + } + + /** + * The position of the channel + * @type {number} + * @readonly + */ + get position() { + const sorted = this.guild._sortedChannels(this); + return [...sorted.values()].indexOf(sorted.get(this.id)); + } + + /** + * Gets the overall set of permissions for a member or role in this channel, taking into account channel overwrites. + * @param {GuildMemberResolvable|RoleResolvable} memberOrRole The member or role to obtain the overall permissions for + * @param {boolean} [checkAdmin=true] Whether having `ADMINISTRATOR` will return all permissions + * @returns {?Readonly} + */ + permissionsFor(memberOrRole, checkAdmin = true) { + const member = this.guild.members.resolve(memberOrRole); + if (member) return this.memberPermissions(member, checkAdmin); + const role = this.guild.roles.resolve(memberOrRole); + return role && this.rolePermissions(role, checkAdmin); + } + + overwritesFor(member, verified = false, roles = null) { + if (!verified) member = this.guild.members.resolve(member); + if (!member) return []; + + roles ??= member.roles.cache; + const roleOverwrites = []; + let memberOverwrites; + let everyoneOverwrites; + + for (const overwrite of this.permissionOverwrites.cache.values()) { + if (overwrite.id === this.guild.id) { + everyoneOverwrites = overwrite; + } else if (roles.has(overwrite.id)) { + roleOverwrites.push(overwrite); + } else if (overwrite.id === member.id) { + memberOverwrites = overwrite; + } + } + + return { + everyone: everyoneOverwrites, + roles: roleOverwrites, + member: memberOverwrites, + }; + } + + /** + * Gets the overall set of permissions for a member in this channel, taking into account channel overwrites. + * @param {GuildMember} member The member to obtain the overall permissions for + * @param {boolean} checkAdmin=true Whether having `ADMINISTRATOR` will return all permissions + * @returns {Readonly} + * @private + */ + memberPermissions(member, checkAdmin) { + if (checkAdmin && member.id === this.guild.ownerId) { + return new PermissionsBitField(PermissionsBitField.All).freeze(); + } + + const roles = member.roles.cache; + const permissions = new PermissionsBitField(roles.map(role => role.permissions)); + + if (checkAdmin && permissions.has(PermissionFlagsBits.Administrator)) { + return new PermissionsBitField(PermissionsBitField.All).freeze(); + } + + const overwrites = this.overwritesFor(member, true, roles); + + return permissions + .remove(overwrites.everyone?.deny ?? PermissionsBitField.defaultBit) + .add(overwrites.everyone?.allow ?? PermissionsBitField.defaultBit) + .remove(overwrites.roles.length > 0 ? overwrites.roles.map(role => role.deny) : PermissionsBitField.defaultBit) + .add(overwrites.roles.length > 0 ? overwrites.roles.map(role => role.allow) : PermissionsBitField.defaultBit) + .remove(overwrites.member?.deny ?? PermissionsBitField.defaultBit) + .add(overwrites.member?.allow ?? PermissionsBitField.defaultBit) + .freeze(); + } + + /** + * Gets the overall set of permissions for a role in this channel, taking into account channel overwrites. + * @param {Role} role The role to obtain the overall permissions for + * @param {boolean} checkAdmin Whether having `ADMINISTRATOR` will return all permissions + * @returns {Readonly} + * @private + */ + rolePermissions(role, checkAdmin) { + if (checkAdmin && role.permissions.has(PermissionFlagsBits.Administrator)) { + return new PermissionsBitField(PermissionsBitField.All).freeze(); + } + + const everyoneOverwrites = this.permissionOverwrites.cache.get(this.guild.id); + const roleOverwrites = this.permissionOverwrites.cache.get(role.id); + + return role.permissions + .remove(everyoneOverwrites?.deny ?? PermissionsBitField.defaultBit) + .add(everyoneOverwrites?.allow ?? PermissionsBitField.defaultBit) + .remove(roleOverwrites?.deny ?? PermissionsBitField.defaultBit) + .add(roleOverwrites?.allow ?? PermissionsBitField.defaultBit) + .freeze(); + } + + /** + * Locks in the permission overwrites from the parent channel. + * @returns {Promise} + */ + lockPermissions() { + if (!this.parent) return Promise.reject(new Error('GUILD_CHANNEL_ORPHAN')); + const permissionOverwrites = this.parent.permissionOverwrites.cache.map(overwrite => overwrite.toJSON()); + return this.edit({ permissionOverwrites }); + } + + /** + * A collection of cached members of this channel, mapped by their ids. + * Members that can view this channel, if the channel is text-based. + * Members in the channel, if the channel is voice-based. + * @type {Collection} + * @readonly + */ + get members() { + return this.guild.members.cache.filter(m => this.permissionsFor(m).has(PermissionFlagsBits.ViewChannel, false)); + } + + /** + * Edits the channel. + * @param {ChannelData} data The new data for the channel + * @param {string} [reason] Reason for editing this channel + * @returns {Promise} + * @example + * // Edit a channel + * channel.edit({ name: 'new-channel' }) + * .then(console.log) + * .catch(console.error); + */ + edit(data, reason) { + return this.guild.channels.edit(this, data, reason); + } + + /** + * Sets a new name for the guild channel. + * @param {string} name The new name for the guild channel + * @param {string} [reason] Reason for changing the guild channel's name + * @returns {Promise} + * @example + * // Set a new channel name + * channel.setName('not_general') + * .then(newChannel => console.log(`Channel's new name is ${newChannel.name}`)) + * .catch(console.error); + */ + setName(name, reason) { + return this.edit({ name }, reason); + } + + /** + * Options used to set the parent of a channel. + * @typedef {Object} SetParentOptions + * @property {boolean} [lockPermissions=true] Whether to lock the permissions to what the parent's permissions are + * @property {string} [reason] The reason for modifying the parent of the channel + */ + + /** + * Sets the parent of this channel. + * @param {?CategoryChannelResolvable} channel The category channel to set as the parent + * @param {SetParentOptions} [options={}] The options for setting the parent + * @returns {Promise} + * @example + * // Add a parent to a channel + * message.channel.setParent('355908108431917066', { lockPermissions: false }) + * .then(channel => console.log(`New parent of ${message.channel.name}: ${channel.name}`)) + * .catch(console.error); + */ + setParent(channel, { lockPermissions = true, reason } = {}) { + return this.edit( + { + parent: channel ?? null, + lockPermissions, + }, + reason, + ); + } + + /** + * Options used to set the position of a channel. + * @typedef {Object} SetChannelPositionOptions + * @property {boolean} [relative=false] Whether or not to change the position relative to its current value + * @property {string} [reason] The reason for changing the position + */ + + /** + * Sets a new position for the guild channel. + * @param {number} position The new position for the guild channel + * @param {SetChannelPositionOptions} [options] Options for setting position + * @returns {Promise} + * @example + * // Set a new channel position + * channel.setPosition(2) + * .then(newChannel => console.log(`Channel's new position is ${newChannel.position}`)) + * .catch(console.error); + */ + setPosition(position, options = {}) { + return this.guild.channels.setPosition(this, position, options); + } + + /** + * Options used to clone a guild channel. + * @typedef {GuildChannelCreateOptions} GuildChannelCloneOptions + * @property {string} [name=this.name] Name of the new channel + */ + + /** + * Clones this channel. + * @param {GuildChannelCloneOptions} [options] The options for cloning this channel + * @returns {Promise} + */ + clone(options = {}) { + return this.guild.channels.create(options.name ?? this.name, { + permissionOverwrites: this.permissionOverwrites.cache, + topic: this.topic, + type: this.type, + nsfw: this.nsfw, + parent: this.parent, + bitrate: this.bitrate, + userLimit: this.userLimit, + rateLimitPerUser: this.rateLimitPerUser, + position: this.rawPosition, + reason: null, + ...options, + }); + } + + /** + * Checks if this channel has the same type, topic, position, name, overwrites, and id as another channel. + * In most cases, a simple `channel.id === channel2.id` will do, and is much faster too. + * @param {GuildChannel} channel Channel to compare with + * @returns {boolean} + */ + equals(channel) { + let equal = + channel && + this.id === channel.id && + this.type === channel.type && + this.topic === channel.topic && + this.position === channel.position && + this.name === channel.name; + + if (equal) { + if (this.permissionOverwrites && channel.permissionOverwrites) { + equal = this.permissionOverwrites.cache.equals(channel.permissionOverwrites.cache); + } else { + equal = !this.permissionOverwrites && !channel.permissionOverwrites; + } + } + + return equal; + } + + /** + * Whether the channel is deletable by the client user + * @type {boolean} + * @readonly + */ + get deletable() { + return this.manageable && this.guild.rulesChannelId !== this.id && this.guild.publicUpdatesChannelId !== this.id; + } + + /** + * Whether the channel is manageable by the client user + * @type {boolean} + * @readonly + */ + get manageable() { + if (this.client.user.id === this.guild.ownerId) return true; + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + + // This flag allows managing even if timed out + if (permissions.has(PermissionFlagsBits.Administrator, false)) return true; + if (this.guild.me.communicationDisabledUntilTimestamp > Date.now()) return false; + + const bitfield = VoiceBasedChannelTypes.includes(this.type) + ? PermissionFlagsBits.ManageChannels | PermissionFlagsBits.Connect + : PermissionFlagsBits.ViewChannel | PermissionFlagsBits.ManageChannels; + return permissions.has(bitfield, false); + } + + /** + * Whether the channel is viewable by the client user + * @type {boolean} + * @readonly + */ + get viewable() { + if (this.client.user.id === this.guild.ownerId) return true; + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + return permissions.has(PermissionFlagsBits.ViewChannel, false); + } + + /** + * Deletes this channel. + * @param {string} [reason] Reason for deleting this channel + * @returns {Promise} + * @example + * // Delete the channel + * channel.delete('making room for new channels') + * .then(console.log) + * .catch(console.error); + */ + async delete(reason) { + await this.guild.channels.delete(this.id, reason); + return this; + } +} + +module.exports = GuildChannel; diff --git a/src/structures/GuildEmoji.js b/src/structures/GuildEmoji.js new file mode 100644 index 00000000..609083b --- /dev/null +++ b/src/structures/GuildEmoji.js @@ -0,0 +1,148 @@ +'use strict'; + +const { PermissionFlagsBits } = require('discord-api-types/v9'); +const BaseGuildEmoji = require('./BaseGuildEmoji'); +const { Error } = require('../errors'); +const GuildEmojiRoleManager = require('../managers/GuildEmojiRoleManager'); + +/** + * Represents a custom emoji. + * @extends {BaseGuildEmoji} + */ +class GuildEmoji extends BaseGuildEmoji { + constructor(client, data, guild) { + super(client, data, guild); + + /** + * The user who created this emoji + * @type {?User} + */ + this.author = null; + + /** + * Array of role ids this emoji is active for + * @name GuildEmoji#_roles + * @type {Snowflake[]} + * @private + */ + Object.defineProperty(this, '_roles', { value: [], writable: true }); + + this._patch(data); + } + + /** + * The guild this emoji is part of + * @type {Guild} + * @name GuildEmoji#guild + */ + + _clone() { + const clone = super._clone(); + clone._roles = this._roles.slice(); + return clone; + } + + _patch(data) { + super._patch(data); + + if (data.user) this.author = this.client.users._add(data.user); + if (data.roles) this._roles = data.roles; + } + + /** + * Whether the emoji is deletable by the client user + * @type {boolean} + * @readonly + */ + get deletable() { + if (!this.guild.me) throw new Error('GUILD_UNCACHED_ME'); + return !this.managed && this.guild.me.permissions.has(PermissionFlagsBits.ManageEmojisAndStickers); + } + + /** + * A manager for roles this emoji is active for. + * @type {GuildEmojiRoleManager} + * @readonly + */ + get roles() { + return new GuildEmojiRoleManager(this); + } + + /** + * Fetches the author for this emoji + * @returns {Promise} + */ + fetchAuthor() { + return this.guild.emojis.fetchAuthor(this); + } + + /** + * Data for editing an emoji. + * @typedef {Object} GuildEmojiEditData + * @property {string} [name] The name of the emoji + * @property {Collection|RoleResolvable[]} [roles] Roles to restrict emoji to + */ + + /** + * Edits the emoji. + * @param {GuildEmojiEditData} data The new data for the emoji + * @param {string} [reason] Reason for editing this emoji + * @returns {Promise} + * @example + * // Edit an emoji + * emoji.edit({ name: 'newemoji' }) + * .then(e => console.log(`Edited emoji ${e}`)) + * .catch(console.error); + */ + edit(data, reason) { + return this.guild.emojis.edit(this.id, data, reason); + } + + /** + * Sets the name of the emoji. + * @param {string} name The new name for the emoji + * @param {string} [reason] Reason for changing the emoji's name + * @returns {Promise} + */ + setName(name, reason) { + return this.edit({ name }, reason); + } + + /** + * Deletes the emoji. + * @param {string} [reason] Reason for deleting the emoji + * @returns {Promise} + */ + async delete(reason) { + await this.guild.emojis.delete(this.id, reason); + return this; + } + + /** + * Whether this emoji is the same as another one. + * @param {GuildEmoji|APIEmoji} other The emoji to compare it to + * @returns {boolean} + */ + equals(other) { + if (other instanceof GuildEmoji) { + return ( + other.id === this.id && + other.name === this.name && + other.managed === this.managed && + other.available === this.available && + other.requiresColons === this.requiresColons && + other.roles.cache.size === this.roles.cache.size && + other.roles.cache.every(role => this.roles.cache.has(role.id)) + ); + } else { + return ( + other.id === this.id && + other.name === this.name && + other.roles.length === this.roles.cache.size && + other.roles.every(role => this.roles.cache.has(role)) + ); + } + } +} + +module.exports = GuildEmoji; diff --git a/src/structures/GuildMember.js b/src/structures/GuildMember.js new file mode 100644 index 00000000..a02fd7a --- /dev/null +++ b/src/structures/GuildMember.js @@ -0,0 +1,458 @@ +'use strict'; + +const { PermissionFlagsBits } = require('discord-api-types/v9'); +const Base = require('./Base'); +const VoiceState = require('./VoiceState'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); +const { Error } = require('../errors'); +const GuildMemberRoleManager = require('../managers/GuildMemberRoleManager'); +const PermissionsBitField = require('../util/PermissionsBitField'); + +/** + * Represents a member of a guild on Discord. + * @implements {TextBasedChannel} + * @extends {Base} + */ +class GuildMember extends Base { + constructor(client, data, guild) { + super(client); + + /** + * The guild that this member is part of + * @type {Guild} + */ + this.guild = guild; + + /** + * The timestamp the member joined the guild at + * @type {?number} + */ + this.joinedTimestamp = null; + + /** + * The last timestamp this member started boosting the guild + * @type {?number} + */ + this.premiumSinceTimestamp = null; + + /** + * The nickname of this member, if they have one + * @type {?string} + */ + this.nickname = null; + + /** + * Whether this member has yet to pass the guild's membership gate + * @type {?boolean} + */ + this.pending = null; + + /** + * The timestamp this member's timeout will be removed + * @type {?number} + */ + this.communicationDisabledUntilTimestamp = null; + + this._roles = []; + if (data) this._patch(data); + } + + _patch(data) { + if ('user' in data) { + /** + * The user that this guild member instance represents + * @type {?User} + */ + this.user = this.client.users._add(data.user, true); + } + + if ('nick' in data) this.nickname = data.nick; + if ('avatar' in data) { + /** + * The guild member's avatar hash + * @type {?string} + */ + this.avatar = data.avatar; + } else if (typeof this.avatar !== 'string') { + this.avatar = null; + } + if ('joined_at' in data) this.joinedTimestamp = Date.parse(data.joined_at); + if ('premium_since' in data) { + this.premiumSinceTimestamp = data.premium_since ? Date.parse(data.premium_since) : null; + } + if ('roles' in data) this._roles = data.roles; + + if ('pending' in data) { + this.pending = data.pending; + } else if (!this.partial) { + // See https://github.com/discordjs/discord.js/issues/6546 for more info. + this.pending ??= false; + } + + if ('communication_disabled_until' in data) { + this.communicationDisabledUntilTimestamp = + data.communication_disabled_until && Date.parse(data.communication_disabled_until); + } + } + + _clone() { + const clone = super._clone(); + clone._roles = this._roles.slice(); + return clone; + } + + /** + * Whether this GuildMember is a partial + * @type {boolean} + * @readonly + */ + get partial() { + return this.joinedTimestamp === null; + } + + /** + * A manager for the roles belonging to this member + * @type {GuildMemberRoleManager} + * @readonly + */ + get roles() { + return new GuildMemberRoleManager(this); + } + + /** + * The voice state of this member + * @type {VoiceState} + * @readonly + */ + get voice() { + return this.guild.voiceStates.cache.get(this.id) ?? new VoiceState(this.guild, { user_id: this.id }); + } + + /** + * A link to the member's guild avatar. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + avatarURL(options = {}) { + return this.avatar && this.client.rest.cdn.guildMemberAvatar(this.guild.id, this.id, this.avatar, options); + } + + /** + * A link to the member's guild avatar if they have one. + * Otherwise, a link to their {@link User#displayAvatarURL} will be returned. + * @param {ImageURLOptions} [options={}] Options for the Image URL + * @returns {string} + */ + displayAvatarURL(options) { + return this.avatarURL(options) ?? this.user.displayAvatarURL(options); + } + + /** + * The time this member joined the guild + * @type {?Date} + * @readonly + */ + get joinedAt() { + return this.joinedTimestamp && new Date(this.joinedTimestamp); + } + + /** + * The time this member's timeout will be removed + * @type {?Date} + * @readonly + */ + get communicationDisabledUntil() { + return this.communicationDisabledUntilTimestamp && new Date(this.communicationDisabledUntilTimestamp); + } + + /** + * The last time this member started boosting the guild + * @type {?Date} + * @readonly + */ + get premiumSince() { + return this.premiumSinceTimestamp && new Date(this.premiumSinceTimestamp); + } + + /** + * The presence of this guild member + * @type {?Presence} + * @readonly + */ + get presence() { + return this.guild.presences.resolve(this.id); + } + + /** + * The displayed color of this member in base 10 + * @type {number} + * @readonly + */ + get displayColor() { + return this.roles.color?.color ?? 0; + } + + /** + * The displayed color of this member in hexadecimal + * @type {string} + * @readonly + */ + get displayHexColor() { + return this.roles.color?.hexColor ?? '#000000'; + } + + /** + * The member's id + * @type {Snowflake} + * @readonly + */ + get id() { + return this.user.id; + } + + /** + * The nickname of this member, or their username if they don't have one + * @type {?string} + * @readonly + */ + get displayName() { + return this.nickname ?? this.user.username; + } + + /** + * The overall set of permissions for this member, taking only roles and owner status into account + * @type {Readonly} + * @readonly + */ + get permissions() { + if (this.user.id === this.guild.ownerId) return new PermissionsBitField(PermissionsBitField.All).freeze(); + return new PermissionsBitField(this.roles.cache.map(role => role.permissions)).freeze(); + } + + /** + * Whether the client user is above this user in the hierarchy, according to role position and guild ownership. + * This is a prerequisite for many moderative actions. + * @type {boolean} + * @readonly + */ + get manageable() { + if (this.user.id === this.guild.ownerId) return false; + if (this.user.id === this.client.user.id) return false; + if (this.client.user.id === this.guild.ownerId) return true; + if (!this.guild.me) throw new Error('GUILD_UNCACHED_ME'); + return this.guild.me.roles.highest.comparePositionTo(this.roles.highest) > 0; + } + + /** + * Whether this member is kickable by the client user + * @type {boolean} + * @readonly + */ + get kickable() { + if (!this.guild.me) throw new Error('GUILD_UNCACHED_ME'); + return this.manageable && this.guild.me.permissions.has(PermissionFlagsBits.KickMembers); + } + + /** + * Whether this member is bannable by the client user + * @type {boolean} + * @readonly + */ + get bannable() { + if (!this.guild.me) throw new Error('GUILD_UNCACHED_ME'); + return this.manageable && this.guild.me.permissions.has(PermissionFlagsBits.BanMembers); + } + + /** + * Whether this member is moderatable by the client user + * @type {boolean} + * @readonly + */ + get moderatable() { + return ( + !this.permissions.has(PermissionFlagsBits.Administrator) && + this.manageable && + (this.guild.me?.permissions.has(PermissionFlagsBits.ModerateMembers) ?? false) + ); + } + + /** + * Whether this member is currently timed out + * @returns {boolean} + */ + isCommunicationDisabled() { + return this.communicationDisabledUntilTimestamp > Date.now(); + } + + /** + * Returns `channel.permissionsFor(guildMember)`. Returns permissions for a member in a guild channel, + * taking into account roles and permission overwrites. + * @param {GuildChannelResolvable} channel The guild channel to use as context + * @returns {Readonly} + */ + permissionsIn(channel) { + channel = this.guild.channels.resolve(channel); + if (!channel) throw new Error('GUILD_CHANNEL_RESOLVE'); + return channel.permissionsFor(this); + } + + /** + * Edits this member. + * @param {GuildMemberEditData} data The data to edit the member with + * @param {string} [reason] Reason for editing this user + * @returns {Promise} + */ + edit(data, reason) { + return this.guild.members.edit(this, data, reason); + } + + /** + * Sets the nickname for this member. + * @param {?string} nick The nickname for the guild member, or `null` if you want to reset their nickname + * @param {string} [reason] Reason for setting the nickname + * @returns {Promise} + */ + setNickname(nick, reason) { + return this.edit({ nick }, reason); + } + + /** + * Creates a DM channel between the client and this member. + * @param {boolean} [force=false] Whether to skip the cache check and request the API + * @returns {Promise} + */ + createDM(force = false) { + return this.user.createDM(force); + } + + /** + * Deletes any DMs with this member. + * @returns {Promise} + */ + deleteDM() { + return this.user.deleteDM(); + } + + /** + * Kicks this member from the guild. + * @param {string} [reason] Reason for kicking user + * @returns {Promise} + */ + kick(reason) { + return this.guild.members.kick(this, reason); + } + + /** + * Bans this guild member. + * @param {BanOptions} [options] Options for the ban + * @returns {Promise} + * @example + * // ban a guild member + * guildMember.ban({ deleteMessageDays: 7, reason: 'They deserved it' }) + * .then(console.log) + * .catch(console.error); + */ + ban(options) { + return this.guild.members.ban(this, options); + } + + /** + * Times this guild member out. + * @param {DateResolvable|null} communicationDisabledUntil The date or timestamp + * for the member's communication to be disabled until. Provide `null` to remove the timeout. + * @param {string} [reason] The reason for this timeout. + * @returns {Promise} + * @example + * // Time a guild member out for 5 minutes + * guildMember.disableCommunicationUntil(Date.now() + (5 * 60 * 1000), 'They deserved it') + * .then(console.log) + * .catch(console.error); + */ + disableCommunicationUntil(communicationDisabledUntil, reason) { + return this.edit({ communicationDisabledUntil }, reason); + } + + /** + * Times this guild member out. + * @param {number|null} timeout The time in milliseconds + * for the member's communication to be disabled until. Provide `null` to remove the timeout. + * @param {string} [reason] The reason for this timeout. + * @returns {Promise} + * @example + * // Time a guild member out for 5 minutes + * guildMember.timeout(5 * 60 * 1000, 'They deserved it') + * .then(console.log) + * .catch(console.error); + */ + timeout(timeout, reason) { + return this.disableCommunicationUntil(timeout && Date.now() + timeout, reason); + } + + /** + * Fetches this GuildMember. + * @param {boolean} [force=true] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetch(force = true) { + return this.guild.members.fetch({ user: this.id, cache: true, force }); + } + + /** + * Whether this guild member equals another guild member. It compares all properties, so for most + * comparison it is advisable to just compare `member.id === member2.id` as it is significantly faster + * and is often what most users need. + * @param {GuildMember} member The member to compare with + * @returns {boolean} + */ + equals(member) { + return ( + member instanceof this.constructor && + this.id === member.id && + this.partial === member.partial && + this.guild.id === member.guild.id && + this.joinedTimestamp === member.joinedTimestamp && + this.nickname === member.nickname && + this.avatar === member.avatar && + this.pending === member.pending && + this.communicationDisabledUntilTimestamp === member.communicationDisabledUntilTimestamp && + (this._roles === member._roles || + (this._roles.length === member._roles.length && this._roles.every((role, i) => role === member._roles[i]))) + ); + } + + /** + * When concatenated with a string, this automatically returns the user's mention instead of the GuildMember object. + * @returns {string} + * @example + * // Logs: Hello from <@123456789012345678>! + * console.log(`Hello from ${member}!`); + */ + toString() { + return `<@${this.nickname ? '!' : ''}${this.user.id}>`; + } + + toJSON() { + const json = super.toJSON({ + guild: 'guildId', + user: 'userId', + displayName: true, + roles: true, + }); + json.avatarURL = this.avatarURL(); + json.displayAvatarURL = this.displayAvatarURL(); + return json; + } + + // These are here only for documentation purposes - they are implemented by TextBasedChannel + /* eslint-disable no-empty-function */ + send() {} +} + +TextBasedChannel.applyToClass(GuildMember); + +exports.GuildMember = GuildMember; + +/** + * @external APIGuildMember + * @see {@link https://discord.com/developers/docs/resources/guild#guild-member-object} + */ diff --git a/src/structures/GuildPreview.js b/src/structures/GuildPreview.js new file mode 100644 index 00000000..8047971 --- /dev/null +++ b/src/structures/GuildPreview.js @@ -0,0 +1,193 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { Routes } = require('discord-api-types/v9'); +const Base = require('./Base'); +const GuildPreviewEmoji = require('./GuildPreviewEmoji'); +const { Sticker } = require('./Sticker'); + +/** + * Represents the data about the guild any bot can preview, connected to the specified guild. + * @extends {Base} + */ +class GuildPreview extends Base { + constructor(client, data) { + super(client); + + if (!data) return; + + this._patch(data); + } + + _patch(data) { + /** + * The id of this guild + * @type {string} + */ + this.id = data.id; + + if ('name' in data) { + /** + * The name of this guild + * @type {string} + */ + this.name = data.name; + } + + if ('icon' in data) { + /** + * The icon of this guild + * @type {?string} + */ + this.icon = data.icon; + } + + if ('splash' in data) { + /** + * The splash icon of this guild + * @type {?string} + */ + this.splash = data.splash; + } + + if ('discovery_splash' in data) { + /** + * The discovery splash icon of this guild + * @type {?string} + */ + this.discoverySplash = data.discovery_splash; + } + + if ('features' in data) { + /** + * An array of enabled guild features + * @type {GuildFeature[]} + */ + this.features = data.features; + } + + if ('approximate_member_count' in data) { + /** + * The approximate count of members in this guild + * @type {number} + */ + this.approximateMemberCount = data.approximate_member_count; + } + + if ('approximate_presence_count' in data) { + /** + * The approximate count of online members in this guild + * @type {number} + */ + this.approximatePresenceCount = data.approximate_presence_count; + } + + if ('description' in data) { + /** + * The description for this guild + * @type {?string} + */ + this.description = data.description; + } else { + this.description ??= null; + } + + if (!this.emojis) { + /** + * Collection of emojis belonging to this guild + * @type {Collection} + */ + this.emojis = new Collection(); + } else { + this.emojis.clear(); + } + for (const emoji of data.emojis) { + this.emojis.set(emoji.id, new GuildPreviewEmoji(this.client, emoji, this)); + } + + /** + * Collection of stickers belonging to this guild + * @type {Collection} + */ + this.stickers = data.stickers.reduce( + (stickers, sticker) => stickers.set(sticker.id, new Sticker(this.client, sticker)), + new Collection(), + ); + } + + /** + * The timestamp this guild was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time this guild was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The URL to this guild's splash. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + splashURL(options = {}) { + return this.splash && this.client.rest.cdn.splash(this.id, this.splash, options); + } + + /** + * The URL to this guild's discovery splash. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + discoverySplashURL(options = {}) { + return this.discoverySplash && this.client.rest.cdn.discoverySplash(this.id, this.discoverySplash, options); + } + + /** + * The URL to this guild's icon. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + iconURL(options = {}) { + return this.icon && this.client.rest.cdn.icon(this.id, this.icon, options); + } + + /** + * Fetches this guild. + * @returns {Promise} + */ + async fetch() { + const data = await this.client.api.guilds(this.id).preview.get(); + this._patch(data); + return this; + } + + /** + * When concatenated with a string, this automatically returns the guild's name instead of the Guild object. + * @returns {string} + * @example + * // Logs: Hello from My Guild! + * console.log(`Hello from ${previewGuild}!`); + */ + toString() { + return this.name; + } + + toJSON() { + const json = super.toJSON(); + json.iconURL = this.iconURL(); + json.splashURL = this.splashURL(); + return json; + } +} + +module.exports = GuildPreview; diff --git a/src/structures/GuildPreviewEmoji.js b/src/structures/GuildPreviewEmoji.js new file mode 100644 index 00000000..144b41d --- /dev/null +++ b/src/structures/GuildPreviewEmoji.js @@ -0,0 +1,27 @@ +'use strict'; + +const BaseGuildEmoji = require('./BaseGuildEmoji'); + +/** + * Represents an instance of an emoji belonging to a public guild obtained through Discord's preview endpoint. + * @extends {BaseGuildEmoji} + */ +class GuildPreviewEmoji extends BaseGuildEmoji { + /** + * The public guild this emoji is part of + * @type {GuildPreview} + * @name GuildPreviewEmoji#guild + */ + + constructor(client, data, guild) { + super(client, data, guild); + + /** + * The roles this emoji is active for + * @type {Snowflake[]} + */ + this.roles = data.roles; + } +} + +module.exports = GuildPreviewEmoji; diff --git a/src/structures/GuildScheduledEvent.js b/src/structures/GuildScheduledEvent.js new file mode 100644 index 00000000..b082ed3 --- /dev/null +++ b/src/structures/GuildScheduledEvent.js @@ -0,0 +1,434 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { GuildScheduledEventStatus, GuildScheduledEventEntityType, RouteBases } = require('discord-api-types/v9'); +const Base = require('./Base'); +const { Error } = require('../errors'); + +/** + * Represents a scheduled event in a {@link Guild}. + * @extends {Base} + */ +class GuildScheduledEvent extends Base { + constructor(client, data) { + super(client); + + /** + * The id of the guild scheduled event + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The id of the guild this guild scheduled event belongs to + * @type {Snowflake} + */ + this.guildId = data.guild_id; + + this._patch(data); + } + + _patch(data) { + if ('channel_id' in data) { + /** + * The channel id in which the scheduled event will be hosted, + * or `null` if entity type is {@link GuildScheduledEventEntityType.External} + * @type {?Snowflake} + */ + this.channelId = data.channel_id; + } else { + this.channelId ??= null; + } + + if ('creator_id' in data) { + /** + * The id of the user that created this guild scheduled event + * @type {?Snowflake} + */ + this.creatorId = data.creator_id; + } else { + this.creatorId ??= null; + } + + /** + * The name of the guild scheduled event + * @type {string} + */ + this.name = data.name; + + if ('description' in data) { + /** + * The description of the guild scheduled event + * @type {?string} + */ + this.description = data.description; + } else { + this.description ??= null; + } + + /** + * The timestamp the guild scheduled event will start at + * This can be potentially `null` only when it's an {@link AuditLogEntryTarget} + * @type {?number} + */ + this.scheduledStartTimestamp = data.scheduled_start_time ? Date.parse(data.scheduled_start_time) : null; + + /** + * The timestamp the guild scheduled event will end at, + * or `null` if the event does not have a scheduled time to end + * @type {?number} + */ + this.scheduledEndTimestamp = data.scheduled_end_time ? Date.parse(data.scheduled_end_time) : null; + + /** + * The privacy level of the guild scheduled event + * @type {GuildScheduledEventPrivacyLevel} + */ + this.privacyLevel = data.privacy_level; + + /** + * The status of the guild scheduled event + * @type {GuildScheduledEventStatus} + */ + this.status = data.status; + + /** + * The type of hosting entity associated with the scheduled event + * @type {GuildScheduledEventEntityType} + */ + this.entityType = data.entity_type; + + if ('entity_id' in data) { + /** + * The id of the hosting entity associated with the scheduled event + * @type {?Snowflake} + */ + this.entityId = data.entity_id; + } else { + this.entityId ??= null; + } + + if ('user_count' in data) { + /** + * The number of users who are subscribed to this guild scheduled event + * @type {?number} + */ + this.userCount = data.user_count; + } else { + this.userCount ??= null; + } + + if ('creator' in data) { + /** + * The user that created this guild scheduled event + * @type {?User} + */ + this.creator = this.client.users._add(data.creator); + } else { + this.creator ??= this.client.users.resolve(this.creatorId); + } + + /* eslint-disable max-len */ + /** + * Represents the additional metadata for a {@link GuildScheduledEvent} + * @see {@link https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-object-guild-scheduled-event-entity-metadata} + * @typedef {Object} GuildScheduledEventEntityMetadata + * @property {?string} location The location of the guild scheduled event + */ + /* eslint-enable max-len */ + + if ('entity_metadata' in data) { + if (data.entity_metadata) { + /** + * Additional metadata + * @type {?GuildScheduledEventEntityMetadata} + */ + this.entityMetadata = { + location: data.entity_metadata.location ?? this.entityMetadata?.location ?? null, + }; + } else { + this.entityMetadata = null; + } + } else { + this.entityMetadata ??= null; + } + + /** + * The cover image hash for this scheduled event + * @type {?string} + */ + this.image = data.image ?? null; + } + + /** + * The URL of this scheduled event's cover image + * @param {BaseImageURLOptions} [options={}] Options for image URL + * @returns {?string} + */ + coverImageURL(options = {}) { + return this.image && this.client.rest.cdn.guildScheduledEventCover(this.id, this.image, options); + } + + /** + * The timestamp the guild scheduled event was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the guild scheduled event was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The time the guild scheduled event will start at + * @type {Date} + * @readonly + */ + get scheduledStartAt() { + return new Date(this.scheduledStartTimestamp); + } + + /** + * The time the guild scheduled event will end at, + * or `null` if the event does not have a scheduled time to end + * @type {?Date} + * @readonly + */ + get scheduledEndAt() { + return this.scheduledEndTimestamp && new Date(this.scheduledEndTimestamp); + } + + /** + * The channel associated with this scheduled event + * @type {?(VoiceChannel|StageChannel)} + * @readonly + */ + get channel() { + return this.client.channels.resolve(this.channelId); + } + + /** + * The guild this scheduled event belongs to + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.resolve(this.guildId); + } + + /** + * The URL to the guild scheduled event + * @type {string} + * @readonly + */ + get url() { + return `${RouteBases.scheduledEvent}/${this.guildId}/${this.id}`; + } + + /** + * Options used to create an invite URL to a {@link GuildScheduledEvent} + * @typedef {CreateInviteOptions} CreateGuildScheduledEventInviteURLOptions + * @property {GuildInvitableChannelResolvable} [channel] The channel to create the invite in. + * This is required when the `entityType` of `GuildScheduledEvent` is + * {@link GuildScheduledEventEntityType.External}, gets ignored otherwise + */ + + /** + * Creates an invite URL to this guild scheduled event. + * @param {CreateGuildScheduledEventInviteURLOptions} [options] The options to create the invite + * @returns {Promise} + */ + async createInviteURL(options) { + let channelId = this.channelId; + if (this.entityType === GuildScheduledEventEntityType.External) { + if (!options?.channel) throw new Error('INVITE_OPTIONS_MISSING_CHANNEL'); + channelId = this.guild.channels.resolveId(options.channel); + if (!channelId) throw new Error('GUILD_CHANNEL_RESOLVE'); + } + const invite = await this.guild.invites.create(channelId, options); + return `${RouteBases.invite}/${invite.code}?event=${this.id}`; + } + + /** + * Edits this guild scheduled event. + * @param {GuildScheduledEventEditOptions} options The options to edit the guild scheduled event + * @returns {Promise} + * @example + * // Edit a guild scheduled event + * guildScheduledEvent.edit({ name: 'Party' }) + * .then(guildScheduledEvent => console.log(guildScheduledEvent)) + * .catch(console.error); + */ + edit(options) { + return this.guild.scheduledEvents.edit(this.id, options); + } + + /** + * Deletes this guild scheduled event. + * @returns {Promise} + * @example + * // Delete a guild scheduled event + * guildScheduledEvent.delete() + * .then(guildScheduledEvent => console.log(guildScheduledEvent)) + * .catch(console.error); + */ + async delete() { + await this.guild.scheduledEvents.delete(this.id); + return this; + } + + /** + * Sets a new name for the guild scheduled event. + * @param {string} name The new name of the guild scheduled event + * @param {string} [reason] The reason for changing the name + * @returns {Promise} + * @example + * // Set name of a guild scheduled event + * guildScheduledEvent.setName('Birthday Party') + * .then(guildScheduledEvent => console.log(`Set the name to: ${guildScheduledEvent.name}`)) + * .catch(console.error); + */ + setName(name, reason) { + return this.edit({ name, reason }); + } + + /** + * Sets a new time to schedule the event at. + * @param {DateResolvable} scheduledStartTime The time to schedule the event at + * @param {string} [reason] The reason for changing the scheduled start time + * @returns {Promise} + * @example + * // Set start time of a guild scheduled event + * guildScheduledEvent.setScheduledStartTime('2022-09-24T00:00:00+05:30') + * .then(guildScheduledEvent => console.log(`Set the start time to: ${guildScheduledEvent.scheduledStartTime}`)) + * .catch(console.error); + */ + setScheduledStartTime(scheduledStartTime, reason) { + return this.edit({ scheduledStartTime, reason }); + } + + // TODO: scheduledEndTime gets reset on passing null but it hasn't been documented + /** + * Sets a new time to end the event at. + * @param {DateResolvable} scheduledEndTime The time to end the event at + * @param {string} [reason] The reason for changing the scheduled end time + * @returns {Promise} + * @example + * // Set end time of a guild scheduled event + * guildScheduledEvent.setScheduledEndTime('2022-09-25T00:00:00+05:30') + * .then(guildScheduledEvent => console.log(`Set the end time to: ${guildScheduledEvent.scheduledEndTime}`)) + * .catch(console.error); + */ + setScheduledEndTime(scheduledEndTime, reason) { + return this.edit({ scheduledEndTime, reason }); + } + + /** + * Sets the new description of the guild scheduled event. + * @param {string} description The description of the guild scheduled event + * @param {string} [reason] The reason for changing the description + * @returns {Promise} + * @example + * // Set description of a guild scheduled event + * guildScheduledEvent.setDescription('A virtual birthday party') + * .then(guildScheduledEvent => console.log(`Set the description to: ${guildScheduledEvent.description}`)) + * .catch(console.error); + */ + setDescription(description, reason) { + return this.edit({ description, reason }); + } + + /** + * Sets the new status of the guild scheduled event. + * If you're working with TypeScript, use this method in conjunction with status type-guards + * like {@link GuildScheduledEvent#isScheduled} to get only valid status as suggestion + * @param {GuildScheduledEventStatus|number} status The status of the guild scheduled event + * @param {string} [reason] The reason for changing the status + * @returns {Promise} + * @example + * // Set status of a guild scheduled event + * guildScheduledEvent.setStatus(GuildScheduledEventStatus.Active) + * .then(guildScheduledEvent => console.log(`Set the status to: ${guildScheduledEvent.status}`)) + * .catch(console.error); + */ + setStatus(status, reason) { + return this.edit({ status, reason }); + } + + /** + * Sets the new location of the guild scheduled event. + * @param {string} location The location of the guild scheduled event + * @param {string} [reason] The reason for changing the location + * @returns {Promise} + * @example + * // Set location of a guild scheduled event + * guildScheduledEvent.setLocation('Earth') + * .then(guildScheduledEvent => console.log(`Set the location to: ${guildScheduledEvent.entityMetadata.location}`)) + * .catch(console.error); + */ + setLocation(location, reason) { + return this.edit({ entityMetadata: { location }, reason }); + } + + /** + * Fetches subscribers of this guild scheduled event. + * @param {FetchGuildScheduledEventSubscribersOptions} [options] Options for fetching the subscribers + * @returns {Promise>} + */ + fetchSubscribers(options) { + return this.guild.scheduledEvents.fetchSubscribers(this.id, options); + } + + /** + * When concatenated with a string, this automatically concatenates the event's URL instead of the object. + * @returns {string} + * @example + * // Logs: Event: https://discord.com/events/412345678901234567/499876543211234567 + * console.log(`Event: ${guildScheduledEvent}`); + */ + toString() { + return this.url; + } + + /** + * Indicates whether this guild scheduled event has an {@link GuildScheduledEventStatus.Active} status. + * @returns {boolean} + */ + isActive() { + return this.status === GuildScheduledEventStatus.Active; + } + + /** + * Indicates whether this guild scheduled event has a {@link GuildScheduledEventStatus.Canceled} status. + * @returns {boolean} + */ + isCanceled() { + return this.status === GuildScheduledEventStatus.Canceled; + } + + /** + * Indicates whether this guild scheduled event has a {@link GuildScheduledEventStatus.Completed} status. + * @returns {boolean} + */ + isCompleted() { + return this.status === GuildScheduledEventStatus.Completed; + } + + /** + * Indicates whether this guild scheduled event has a {@link GuildScheduledEventStatus.Scheduled} status. + * @returns {boolean} + */ + isScheduled() { + return this.status === GuildScheduledEventStatus.Scheduled; + } +} + +exports.GuildScheduledEvent = GuildScheduledEvent; diff --git a/src/structures/GuildTemplate.js b/src/structures/GuildTemplate.js new file mode 100644 index 00000000..88b4e7b --- /dev/null +++ b/src/structures/GuildTemplate.js @@ -0,0 +1,237 @@ +'use strict'; + +const { setTimeout, clearTimeout } = require('node:timers'); +const { RouteBases, Routes } = require('discord-api-types/v9'); +const Base = require('./Base'); +const DataResolver = require('../util/DataResolver'); +const Events = require('../util/Events'); + +/** + * Represents the template for a guild. + * @extends {Base} + */ +class GuildTemplate extends Base { + constructor(client, data) { + super(client); + this._patch(data); + } + + _patch(data) { + if ('code' in data) { + /** + * The unique code of this template + * @type {string} + */ + this.code = data.code; + } + + if ('name' in data) { + /** + * The name of this template + * @type {string} + */ + this.name = data.name; + } + + if ('description' in data) { + /** + * The description of this template + * @type {?string} + */ + this.description = data.description; + } + + if ('usage_count' in data) { + /** + * The amount of times this template has been used + * @type {number} + */ + this.usageCount = data.usage_count; + } + + if ('creator_id' in data) { + /** + * The id of the user that created this template + * @type {Snowflake} + */ + this.creatorId = data.creator_id; + } + + if ('creator' in data) { + /** + * The user that created this template + * @type {User} + */ + this.creator = this.client.users._add(data.creator); + } + + if ('created_at' in data) { + /** + * The timestamp of when this template was created at + * @type {number} + */ + this.createdTimestamp = Date.parse(data.created_at); + } + + if ('updated_at' in data) { + /** + * The timestamp of when this template was last synced to the guild + * @type {number} + */ + this.updatedTimestamp = Date.parse(data.updated_at); + } + + if ('source_guild_id' in data) { + /** + * The id of the guild that this template belongs to + * @type {Snowflake} + */ + this.guildId = data.source_guild_id; + } + + if ('serialized_source_guild' in data) { + /** + * The data of the guild that this template would create + * @type {APIGuild} + */ + this.serializedGuild = data.serialized_source_guild; + } + + /** + * Whether this template has unsynced changes + * @type {?boolean} + */ + this.unSynced = 'is_dirty' in data ? Boolean(data.is_dirty) : null; + + return this; + } + + /** + * Creates a guild based on this template. + * This is only available to bots in fewer than 10 guilds. + * @param {string} name The name of the guild + * @param {BufferResolvable|Base64Resolvable} [icon] The icon for the guild + * @returns {Promise} + */ + async createGuild(name, icon) { + const { client } = this; + const data = await client.rest.post(Routes.template(this.code), { + body: { + name, + icon: await DataResolver.resolveImage(icon), + }, + }); + + if (client.guilds.cache.has(data.id)) return client.guilds.cache.get(data.id); + + return new Promise(resolve => { + const resolveGuild = guild => { + client.off(Events.GuildCreate, handleGuild); + client.decrementMaxListeners(); + resolve(guild); + }; + + const handleGuild = guild => { + if (guild.id === data.id) { + clearTimeout(timeout); + resolveGuild(guild); + } + }; + + client.incrementMaxListeners(); + client.on(Events.GuildCreate, handleGuild); + + const timeout = setTimeout(() => resolveGuild(client.guilds._add(data)), 10_000).unref(); + }); + } + + /** + * Options used to edit a guild template. + * @typedef {Object} EditGuildTemplateOptions + * @property {string} [name] The name of this template + * @property {string} [description] The description of this template + */ + + /** + * Updates the metadata of this template. + * @param {EditGuildTemplateOptions} [options] Options for editing the template + * @returns {Promise} + */ + async edit({ name, description } = {}) { + const data = await this.client.api.guilds(this.guildId).templates(this.code).patch({ body: { name, description } }); + return this._patch(data); + } + + /** + * Deletes this template. + * @returns {Promise} + */ + async delete() { + await this.client.api.guilds(this.guildId).templates(this.code).delete(); + return this; + } + + /** + * Syncs this template to the current state of the guild. + * @returns {Promise} + */ + async sync() { + const data = await this.client.api.guilds(this.guildId).templates(this.code).put(); + return this._patch(data); + } + + /** + * The time when this template was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The time when this template was last synced to the guild + * @type {Date} + * @readonly + */ + get updatedAt() { + return new Date(this.updatedTimestamp); + } + + /** + * The guild that this template belongs to + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.resolve(this.guildId); + } + + /** + * The URL of this template + * @type {string} + * @readonly + */ + get url() { + return `${RouteBases.template}/${this.code}`; + } + + /** + * When concatenated with a string, this automatically returns the template's code instead of the template object. + * @returns {string} + * @example + * // Logs: Template: FKvmczH2HyUf + * console.log(`Template: ${guildTemplate}!`); + */ + toString() { + return this.code; + } +} + +/** + * Regular expression that globally matches guild template links + * @type {RegExp} + */ +GuildTemplate.GUILD_TEMPLATES_PATTERN = /discord(?:app)?\.(?:com\/template|new)\/([\w-]{2,255})/gi; + +module.exports = GuildTemplate; diff --git a/src/structures/Integration.js b/src/structures/Integration.js new file mode 100644 index 00000000..fd68c41 --- /dev/null +++ b/src/structures/Integration.js @@ -0,0 +1,208 @@ +'use strict'; + +const { Routes } = require('discord-api-types/v9'); +const Base = require('./Base'); +const IntegrationApplication = require('./IntegrationApplication'); + +/** + * The information account for an integration + * @typedef {Object} IntegrationAccount + * @property {Snowflake|string} id The id of the account + * @property {string} name The name of the account + */ + +/** + * The type of an {@link Integration}. This can be: + * * `twitch` + * * `youtube` + * * `discord` + * @typedef {string} IntegrationType + */ + +/** + * Represents a guild integration. + */ +class Integration extends Base { + constructor(client, data, guild) { + super(client); + + /** + * The guild this integration belongs to + * @type {Guild} + */ + this.guild = guild; + + /** + * The integration id + * @type {Snowflake|string} + */ + this.id = data.id; + + /** + * The integration name + * @type {string} + */ + this.name = data.name; + + /** + * The integration type + * @type {IntegrationType} + */ + this.type = data.type; + + /** + * Whether this integration is enabled + * @type {boolean} + */ + this.enabled = data.enabled; + + if ('syncing' in data) { + /** + * Whether this integration is syncing + * @type {?boolean} + */ + this.syncing = data.syncing; + } else { + this.syncing ??= null; + } + + /** + * The role that this integration uses for subscribers + * @type {?Role} + */ + this.role = this.guild.roles.resolve(data.role_id); + + if ('enable_emoticons' in data) { + /** + * Whether emoticons should be synced for this integration (twitch only currently) + * @type {?boolean} + */ + this.enableEmoticons = data.enable_emoticons; + } else { + this.enableEmoticons ??= null; + } + + if (data.user) { + /** + * The user for this integration + * @type {?User} + */ + this.user = this.client.users._add(data.user); + } else { + this.user ??= null; + } + + /** + * The account integration information + * @type {IntegrationAccount} + */ + this.account = data.account; + + if ('synced_at' in data) { + /** + * The timestamp at which this integration was last synced at + * @type {?number} + */ + this.syncedTimestamp = Date.parse(data.synced_at); + } else { + this.syncedTimestamp ??= null; + } + + if ('subscriber_count' in data) { + /** + * How many subscribers this integration has + * @type {?number} + */ + this.subscriberCount = data.subscriber_count; + } else { + this.subscriberCount ??= null; + } + + if ('revoked' in data) { + /** + * Whether this integration has been revoked + * @type {?boolean} + */ + this.revoked = data.revoked; + } else { + this.revoked ??= null; + } + + this._patch(data); + } + + /** + * The date at which this integration was last synced at + * @type {?Date} + * @readonly + */ + get syncedAt() { + return this.syncedTimestamp && new Date(this.syncedTimestamp); + } + + /** + * All roles that are managed by this integration + * @type {Collection} + * @readonly + */ + get roles() { + const roles = this.guild.roles.cache; + return roles.filter(role => role.tags?.integrationId === this.id); + } + + _patch(data) { + if ('expire_behavior' in data) { + /** + * The behavior of expiring subscribers + * @type {?IntegrationExpireBehavior} + */ + this.expireBehavior = data.expire_behavior; + } else { + this.expireBehavior ??= null; + } + + if ('expire_grace_period' in data) { + /** + * The grace period (in days) before expiring subscribers + * @type {?number} + */ + this.expireGracePeriod = data.expire_grace_period; + } else { + this.expireGracePeriod ??= null; + } + + if ('application' in data) { + if (this.application) { + this.application._patch(data.application); + } else { + /** + * The application for this integration + * @type {?IntegrationApplication} + */ + this.application = new IntegrationApplication(this.client, data.application); + } + } else { + this.application ??= null; + } + } + + /** + * Deletes this integration. + * @returns {Promise} + * @param {string} [reason] Reason for deleting this integration + */ + async delete(reason) { + await this.client.api.guilds(this.guild.id).integrations(this.id).delete({ reason }); + return this; + } + + toJSON() { + return super.toJSON({ + role: 'roleId', + guild: 'guildId', + user: 'userId', + }); + } +} + +module.exports = Integration; diff --git a/src/structures/IntegrationApplication.js b/src/structures/IntegrationApplication.js new file mode 100644 index 00000000..1a81df1 --- /dev/null +++ b/src/structures/IntegrationApplication.js @@ -0,0 +1,95 @@ +'use strict'; + +const Application = require('./interfaces/Application'); + +/** + * Represents an Integration's OAuth2 Application. + * @extends {Application} + */ +class IntegrationApplication extends Application { + _patch(data) { + super._patch(data); + + if ('bot' in data) { + /** + * The bot user for this application + * @type {?User} + */ + this.bot = this.client.users._add(data.bot); + } else { + this.bot ??= null; + } + + if ('terms_of_service_url' in data) { + /** + * The URL of the application's terms of service + * @type {?string} + */ + this.termsOfServiceURL = data.terms_of_service_url; + } else { + this.termsOfServiceURL ??= null; + } + + if ('privacy_policy_url' in data) { + /** + * The URL of the application's privacy policy + * @type {?string} + */ + this.privacyPolicyURL = data.privacy_policy_url; + } else { + this.privacyPolicyURL ??= null; + } + + if ('rpc_origins' in data) { + /** + * The Array of RPC origin URLs + * @type {string[]} + */ + this.rpcOrigins = data.rpc_origins; + } else { + this.rpcOrigins ??= []; + } + + if ('summary' in data) { + /** + * The application's summary + * @type {?string} + */ + this.summary = data.summary; + } else { + this.summary ??= null; + } + + if ('hook' in data) { + /** + * Whether the application can be default hooked by the client + * @type {?boolean} + */ + this.hook = data.hook; + } else { + this.hook ??= null; + } + + if ('cover_image' in data) { + /** + * The hash of the application's cover image + * @type {?string} + */ + this.cover = data.cover_image; + } else { + this.cover ??= null; + } + + if ('verify_key' in data) { + /** + * The hex-encoded key for verification in interactions and the GameSDK's GetTicket + * @type {?string} + */ + this.verifyKey = data.verify_key; + } else { + this.verifyKey ??= null; + } + } +} + +module.exports = IntegrationApplication; diff --git a/src/structures/Interaction.js b/src/structures/Interaction.js new file mode 100644 index 00000000..a341708 --- /dev/null +++ b/src/structures/Interaction.js @@ -0,0 +1,235 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { InteractionType, ApplicationCommandType, ComponentType } = require('discord-api-types/v9'); +const Base = require('./Base'); +const PermissionsBitField = require('../util/PermissionsBitField'); + +/** + * Represents an interaction. + * @extends {Base} + */ +class Interaction extends Base { + constructor(client, data) { + super(client); + + /** + * The interaction's type + * @type {InteractionType} + */ + this.type = data.type; + + /** + * The interaction's id + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The interaction's token + * @type {string} + * @name Interaction#token + * @readonly + */ + Object.defineProperty(this, 'token', { value: data.token }); + + /** + * The application's id + * @type {Snowflake} + */ + this.applicationId = data.application_id; + + /** + * The id of the channel this interaction was sent in + * @type {?Snowflake} + */ + this.channelId = data.channel_id ?? null; + + /** + * The id of the guild this interaction was sent in + * @type {?Snowflake} + */ + this.guildId = data.guild_id ?? null; + + /** + * The user which sent this interaction + * @type {User} + */ + this.user = this.client.users._add(data.user ?? data.member.user); + + /** + * If this interaction was sent in a guild, the member which sent it + * @type {?(GuildMember|APIGuildMember)} + */ + this.member = data.member ? this.guild?.members._add(data.member) ?? data.member : null; + + /** + * The version + * @type {number} + */ + this.version = data.version; + + /** + * The permissions of the member, if one exists, in the channel this interaction was executed in + * @type {?Readonly} + */ + this.memberPermissions = data.member?.permissions + ? new PermissionsBitField(data.member.permissions).freeze() + : null; + + /** + * The locale of the user who invoked this interaction + * @type {string} + * @see {@link https://discord.com/developers/docs/reference#locales} + */ + this.locale = data.locale; + + /** + * The preferred locale from the guild this interaction was sent in + * @type {?string} + */ + this.guildLocale = data.guild_locale ?? null; + } + + /** + * The timestamp the interaction was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the interaction was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The channel this interaction was sent in + * @type {?TextBasedChannels} + * @readonly + */ + get channel() { + return this.client.channels.cache.get(this.channelId) ?? null; + } + + /** + * The guild this interaction was sent in + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.cache.get(this.guildId) ?? null; + } + + /** + * Indicates whether this interaction is received from a guild. + * @returns {boolean} + */ + inGuild() { + return Boolean(this.guildId && this.member); + } + + /** + * Indicates whether or not this interaction is both cached and received from a guild. + * @returns {boolean} + */ + inCachedGuild() { + return Boolean(this.guild && this.member); + } + + /** + * Indicates whether or not this interaction is received from an uncached guild. + * @returns {boolean} + */ + inRawGuild() { + return Boolean(this.guildId && !this.guild && this.member); + } + + /** + * Indicates whether this interaction is a {@link CommandInteraction}. + * @returns {boolean} + */ + isCommand() { + return this.type === InteractionType.ApplicationCommand; + } + + /** + * Indicates whether this interaction is a {@link ChatInputCommandInteraction}. + * @returns {boolean} + */ + isChatInputCommand() { + return this.isCommand() && this.commandType === ApplicationCommandType.ChatInput; + } + + /** + * Indicates whether this interaction is a {@link ContextMenuCommandInteraction} + * @returns {boolean} + */ + isContextMenuCommand() { + return this.isCommand() && [ApplicationCommandType.User, ApplicationCommandType.Message].includes(this.commandType); + } + + /** + * Indicates whether this interaction is a {@link UserContextMenuCommandInteraction} + * @returns {boolean} + */ + isUserContextMenuCommand() { + return this.isContextMenuCommand() && this.commandType === ApplicationCommandType.User; + } + + /** + * Indicates whether this interaction is a {@link MessageContextMenuCommandInteraction} + * @returns {boolean} + */ + isMessageContextMenuCommand() { + return this.isContextMenuCommand() && this.commandType === ApplicationCommandType.Message; + } + + /** + * Indicates whether this interaction is an {@link AutocompleteInteraction} + * @returns {boolean} + */ + isAutocomplete() { + return this.type === InteractionType.ApplicationCommandAutocomplete; + } + + /** + * Indicates whether this interaction is a {@link MessageComponentInteraction}. + * @returns {boolean} + */ + isMessageComponent() { + return this.type === InteractionType.MessageComponent; + } + + /** + * Indicates whether this interaction is a {@link ButtonInteraction}. + * @returns {boolean} + */ + isButton() { + return this.isMessageComponent() && this.componentType === ComponentType.Button; + } + + /** + * Indicates whether this interaction is a {@link SelectMenuInteraction}. + * @returns {boolean} + */ + isSelectMenu() { + return this.isMessageComponent() && this.componentType === ComponentType.SelectMenu; + } + + /** + * Indicates whether this interaction can be replied to. + * @returns {boolean} + */ + isRepliable() { + return ![InteractionType.Ping, InteractionType.ApplicationCommandAutocomplete].includes(this.type); + } +} + +module.exports = Interaction; diff --git a/src/structures/InteractionCollector.js b/src/structures/InteractionCollector.js new file mode 100644 index 00000000..56821f3 --- /dev/null +++ b/src/structures/InteractionCollector.js @@ -0,0 +1,241 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Collector = require('./interfaces/Collector'); +const Events = require('../util/Events'); + +/** + * @typedef {CollectorOptions} InteractionCollectorOptions + * @property {TextBasedChannelResolvable} [channel] The channel to listen to interactions from + * @property {ComponentType} [componentType] The type of component to listen for + * @property {GuildResolvable} [guild] The guild to listen to interactions from + * @property {InteractionType} [interactionType] The type of interaction to listen for + * @property {number} [max] The maximum total amount of interactions to collect + * @property {number} [maxComponents] The maximum number of components to collect + * @property {number} [maxUsers] The maximum number of users to interact + * @property {Message|APIMessage} [message] The message to listen to interactions from + */ + +/** + * Collects interactions. + * Will automatically stop if the message ({@link Client#event:messageDelete messageDelete} or + * {@link Client#event:messageDeleteBulk messageDeleteBulk}), + * channel ({@link Client#event:channelDelete channelDelete}), or + * guild ({@link Client#event:guildDelete guildDelete}) is deleted. + * Interaction collectors that do not specify `time` or `idle` may be prone to always running. + * Ensure your interaction collectors end via either of these options or manual cancellation. + * @extends {Collector} + */ +class InteractionCollector extends Collector { + /** + * @param {Client} client The client on which to collect interactions + * @param {InteractionCollectorOptions} [options={}] The options to apply to this collector + */ + constructor(client, options = {}) { + super(client, options); + + /** + * The message from which to collect interactions, if provided + * @type {?Snowflake} + */ + this.messageId = options.message?.id ?? null; + + /** + * The channel from which to collect interactions, if provided + * @type {?Snowflake} + */ + this.channelId = + this.client.channels.resolveId(options.message?.channel) ?? + options.message?.channel_id ?? + this.client.channels.resolveId(options.channel); + + /** + * The guild from which to collect interactions, if provided + * @type {?Snowflake} + */ + this.guildId = + this.client.guilds.resolveId(options.message?.guild) ?? + options.message?.guild_id ?? + this.client.guilds.resolveId(options.channel?.guild) ?? + this.client.guilds.resolveId(options.guild); + + /** + * The type of interaction to collect + * @type {?InteractionType} + */ + this.interactionType = options.interactionType ?? null; + + /** + * The type of component to collect + * @type {?ComponentType} + */ + this.componentType = options.componentType ?? null; + + /** + * The users that have interacted with this collector + * @type {Collection} + */ + this.users = new Collection(); + + /** + * The total number of interactions collected + * @type {number} + */ + this.total = 0; + + this.empty = this.empty.bind(this); + this.client.incrementMaxListeners(); + + const bulkDeleteListener = messages => { + if (messages.has(this.messageId)) this.stop('messageDelete'); + }; + + if (this.messageId) { + this._handleMessageDeletion = this._handleMessageDeletion.bind(this); + this.client.on(Events.MessageDelete, this._handleMessageDeletion); + this.client.on(Events.MessageBulkDelete, bulkDeleteListener); + } + + if (this.channelId) { + this._handleChannelDeletion = this._handleChannelDeletion.bind(this); + this._handleThreadDeletion = this._handleThreadDeletion.bind(this); + this.client.on(Events.ChannelDelete, this._handleChannelDeletion); + this.client.on(Events.ThreadDelete, this._handleThreadDeletion); + } + + if (this.guildId) { + this._handleGuildDeletion = this._handleGuildDeletion.bind(this); + this.client.on(Events.GuildDelete, this._handleGuildDeletion); + } + + this.client.on(Events.InteractionCreate, this.handleCollect); + + this.once('end', () => { + this.client.removeListener(Events.InteractionCreate, this.handleCollect); + this.client.removeListener(Events.MessageDelete, this._handleMessageDeletion); + this.client.removeListener(Events.MessageBulkDelete, bulkDeleteListener); + this.client.removeListener(Events.ChannelDelete, this._handleChannelDeletion); + this.client.removeListener(Events.ThreadDelete, this._handleThreadDeletion); + this.client.removeListener(Events.GuildDelete, this._handleGuildDeletion); + this.client.decrementMaxListeners(); + }); + + this.on('collect', interaction => { + this.total++; + this.users.set(interaction.user.id, interaction.user); + }); + } + + /** + * Handles an incoming interaction for possible collection. + * @param {Interaction} interaction The interaction to possibly collect + * @returns {?Snowflake} + * @private + */ + collect(interaction) { + /** + * Emitted whenever an interaction is collected. + * @event InteractionCollector#collect + * @param {Interaction} interaction The interaction that was collected + */ + if (this.interactionType && interaction.type !== this.interactionType) return null; + if (this.componentType && interaction.componentType !== this.componentType) return null; + if (this.messageId && interaction.message?.id !== this.messageId) return null; + if (this.channelId && interaction.channelId !== this.channelId) return null; + if (this.guildId && interaction.guildId !== this.guildId) return null; + + return interaction.id; + } + + /** + * Handles an interaction for possible disposal. + * @param {Interaction} interaction The interaction that could be disposed of + * @returns {?Snowflake} + */ + dispose(interaction) { + /** + * Emitted whenever an interaction is disposed of. + * @event InteractionCollector#dispose + * @param {Interaction} interaction The interaction that was disposed of + */ + if (this.type && interaction.type !== this.type) return null; + if (this.componentType && interaction.componentType !== this.componentType) return null; + if (this.messageId && interaction.message?.id !== this.messageId) return null; + if (this.channelId && interaction.channelId !== this.channelId) return null; + if (this.guildId && interaction.guildId !== this.guildId) return null; + + return interaction.id; + } + + /** + * Empties this interaction collector. + */ + empty() { + this.total = 0; + this.collected.clear(); + this.users.clear(); + this.checkEnd(); + } + + /** + * The reason this collector has ended with, or null if it hasn't ended yet + * @type {?string} + * @readonly + */ + get endReason() { + if (this.options.max && this.total >= this.options.max) return 'limit'; + if (this.options.maxComponents && this.collected.size >= this.options.maxComponents) return 'componentLimit'; + if (this.options.maxUsers && this.users.size >= this.options.maxUsers) return 'userLimit'; + return null; + } + + /** + * Handles checking if the message has been deleted, and if so, stops the collector with the reason 'messageDelete'. + * @private + * @param {Message} message The message that was deleted + * @returns {void} + */ + _handleMessageDeletion(message) { + if (message.id === this.messageId) { + this.stop('messageDelete'); + } + } + + /** + * Handles checking if the channel has been deleted, and if so, stops the collector with the reason 'channelDelete'. + * @private + * @param {GuildChannel} channel The channel that was deleted + * @returns {void} + */ + _handleChannelDeletion(channel) { + if (channel.id === this.channelId || channel.threads?.cache.has(this.channelId)) { + this.stop('channelDelete'); + } + } + + /** + * Handles checking if the thread has been deleted, and if so, stops the collector with the reason 'threadDelete'. + * @private + * @param {ThreadChannel} thread The thread that was deleted + * @returns {void} + */ + _handleThreadDeletion(thread) { + if (thread.id === this.channelId) { + this.stop('threadDelete'); + } + } + + /** + * Handles checking if the guild has been deleted, and if so, stops the collector with the reason 'guildDelete'. + * @private + * @param {Guild} guild The guild that was deleted + * @returns {void} + */ + _handleGuildDeletion(guild) { + if (guild.id === this.guildId) { + this.stop('guildDelete'); + } + } +} + +module.exports = InteractionCollector; diff --git a/src/structures/InteractionWebhook.js b/src/structures/InteractionWebhook.js new file mode 100644 index 00000000..ddafbf0 --- /dev/null +++ b/src/structures/InteractionWebhook.js @@ -0,0 +1,43 @@ +'use strict'; + +const Webhook = require('./Webhook'); + +/** + * Represents a webhook for an Interaction + * @implements {Webhook} + */ +class InteractionWebhook { + /** + * @param {Client} client The instantiating client + * @param {Snowflake} id The application's id + * @param {string} token The interaction's token + */ + constructor(client, id, token) { + /** + * The client that instantiated the interaction webhook + * @name InteractionWebhook#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + this.id = id; + Object.defineProperty(this, 'token', { value: token, writable: true, configurable: true }); + } + + // These are here only for documentation purposes - they are implemented by Webhook + /* eslint-disable no-empty-function, valid-jsdoc */ + /** + * Sends a message with this webhook. + * @param {string|MessagePayload|InteractionReplyOptions} options The content for the reply + * @returns {Promise} + */ + send() {} + fetchMessage() {} + editMessage() {} + deleteMessage() {} + get url() {} +} + +Webhook.applyToClass(InteractionWebhook, ['sendSlackMessage', 'edit', 'delete', 'createdTimestamp', 'createdAt']); + +module.exports = InteractionWebhook; diff --git a/src/structures/Invite.js b/src/structures/Invite.js new file mode 100644 index 00000000..7dcb613 --- /dev/null +++ b/src/structures/Invite.js @@ -0,0 +1,317 @@ +'use strict'; + +const { RouteBases, Routes, PermissionFlagsBits } = require('discord-api-types/v9'); +const Base = require('./Base'); +const { GuildScheduledEvent } = require('./GuildScheduledEvent'); +const IntegrationApplication = require('./IntegrationApplication'); +const InviteStageInstance = require('./InviteStageInstance'); +const { Error } = require('../errors'); + +/** + * Represents an invitation to a guild channel. + * @extends {Base} + */ +class Invite extends Base { + constructor(client, data) { + super(client); + this._patch(data); + } + + _patch(data) { + const InviteGuild = require('./InviteGuild'); + /** + * The guild the invite is for including welcome screen data if present + * @type {?(Guild|InviteGuild)} + */ + this.guild ??= null; + if (data.guild) { + this.guild = this.client.guilds.resolve(data.guild.id) ?? new InviteGuild(this.client, data.guild); + } + + if ('code' in data) { + /** + * The code for this invite + * @type {string} + */ + this.code = data.code; + } + + if ('approximate_presence_count' in data) { + /** + * The approximate number of online members of the guild this invite is for + * This is only available when the invite was fetched through {@link Client#fetchInvite}. + * @type {?number} + */ + this.presenceCount = data.approximate_presence_count; + } else { + this.presenceCount ??= null; + } + + if ('approximate_member_count' in data) { + /** + * The approximate total number of members of the guild this invite is for + * This is only available when the invite was fetched through {@link Client#fetchInvite}. + * @type {?number} + */ + this.memberCount = data.approximate_member_count; + } else { + this.memberCount ??= null; + } + + if ('temporary' in data) { + /** + * Whether or not this invite only grants temporary membership + * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} + * or created through {@link GuildInviteManager#create}. + * @type {?boolean} + */ + this.temporary = data.temporary ?? null; + } else { + this.temporary ??= null; + } + + if ('max_age' in data) { + /** + * The maximum age of the invite, in seconds, 0 if never expires + * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} + * or created through {@link GuildInviteManager#create}. + * @type {?number} + */ + this.maxAge = data.max_age; + } else { + this.maxAge ??= null; + } + + if ('uses' in data) { + /** + * How many times this invite has been used + * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} + * or created through {@link GuildInviteManager#create}. + * @type {?number} + */ + this.uses = data.uses; + } else { + this.uses ??= null; + } + + if ('max_uses' in data) { + /** + * The maximum uses of this invite + * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} + * or created through {@link GuildInviteManager#create}. + * @type {?number} + */ + this.maxUses = data.max_uses; + } else { + this.maxUses ??= null; + } + + if ('inviter_id' in data) { + /** + * The user's id who created this invite + * @type {?Snowflake} + */ + this.inviterId = data.inviter_id; + } else { + this.inviterId ??= null; + } + + if ('inviter' in data) { + this.client.users._add(data.inviter); + this.inviterId = data.inviter.id; + } + + if ('target_user' in data) { + /** + * The user whose stream to display for this voice channel stream invite + * @type {?User} + */ + this.targetUser = this.client.users._add(data.target_user); + } else { + this.targetUser ??= null; + } + + if ('target_application' in data) { + /** + * The embedded application to open for this voice channel embedded application invite + * @type {?IntegrationApplication} + */ + this.targetApplication = new IntegrationApplication(this.client, data.target_application); + } else { + this.targetApplication ??= null; + } + + if ('target_type' in data) { + /** + * The target type + * @type {?InviteTargetType} + */ + this.targetType = data.target_type; + } else { + this.targetType ??= null; + } + + if ('channel_id' in data) { + /** + * The id of the channel this invite is for + * @type {?Snowflake} + */ + this.channelId = data.channel_id; + } + + if ('channel' in data) { + /** + * The channel this invite is for + * @type {?Channel} + */ + this.channel = + this.client.channels._add(data.channel, this.guild, { cache: false }) ?? + this.client.channels.resolve(this.channelId); + + this.channelId ??= data.channel.id; + } + + if ('created_at' in data) { + /** + * The timestamp this invite was created at + * @type {?number} + */ + this.createdTimestamp = Date.parse(data.created_at); + } else { + this.createdTimestamp ??= null; + } + + if ('expires_at' in data) this._expiresTimestamp = Date.parse(data.expires_at); + else this._expiresTimestamp ??= null; + + if ('stage_instance' in data) { + /** + * The stage instance data if there is a public {@link StageInstance} in the stage channel this invite is for + * @type {?InviteStageInstance} + * @deprecated + */ + this.stageInstance = new InviteStageInstance(this.client, data.stage_instance, this.channel.id, this.guild.id); + } else { + this.stageInstance ??= null; + } + + if ('guild_scheduled_event' in data) { + /** + * The guild scheduled event data if there is a {@link GuildScheduledEvent} in the channel this invite is for + * @type {?GuildScheduledEvent} + */ + this.guildScheduledEvent = new GuildScheduledEvent(this.client, data.guild_scheduled_event); + } else { + this.guildScheduledEvent ??= null; + } + } + + /** + * The time the invite was created at + * @type {?Date} + * @readonly + */ + get createdAt() { + return this.createdTimestamp && new Date(this.createdTimestamp); + } + + /** + * Whether the invite is deletable by the client user + * @type {boolean} + * @readonly + */ + get deletable() { + const guild = this.guild; + if (!guild || !this.client.guilds.cache.has(guild.id)) return false; + if (!guild.me) throw new Error('GUILD_UNCACHED_ME'); + return Boolean( + this.channel?.permissionsFor(this.client.user).has(PermissionFlagsBits.ManageChannels, false) || + guild.me.permissions.has(PermissionFlagsBits.ManageGuild), + ); + } + + /** + * The timestamp the invite will expire at + * @type {?number} + * @readonly + */ + get expiresTimestamp() { + return ( + this._expiresTimestamp ?? + (this.createdTimestamp && this.maxAge ? this.createdTimestamp + this.maxAge * 1_000 : null) + ); + } + + /** + * The time the invite will expire at + * @type {?Date} + * @readonly + */ + get expiresAt() { + return this.expiresTimestamp && new Date(this.expiresTimestamp); + } + + /** + * The user who created this invite + * @type {?User} + * @readonly + */ + get inviter() { + return this.inviterId && this.client.users.resolve(this.inviterId); + } + + /** + * The URL to the invite + * @type {string} + * @readonly + */ + get url() { + return `${RouteBases.invite}/${this.code}`; + } + + /** + * Deletes this invite. + * @param {string} [reason] Reason for deleting this invite + * @returns {Promise} + */ + async delete(reason) { + await this.client.api.invites[this.code].delete({ reason }); + return this; + } + + /** + * When concatenated with a string, this automatically concatenates the invite's URL instead of the object. + * @returns {string} + * @example + * // Logs: Invite: https://discord.gg/A1b2C3 + * console.log(`Invite: ${invite}`); + */ + toString() { + return this.url; + } + + toJSON() { + return super.toJSON({ + url: true, + expiresTimestamp: true, + presenceCount: false, + memberCount: false, + uses: false, + channel: 'channelId', + inviter: 'inviterId', + guild: 'guildId', + }); + } + + valueOf() { + return this.code; + } +} + +/** + * Regular expression that globally matches Discord invite links + * @type {RegExp} + */ +Invite.INVITES_PATTERN = /discord(?:(?:app)?\.com\/invite|\.gg(?:\/invite)?)\/([\w-]{2,255})/gi; + +module.exports = Invite; diff --git a/src/structures/InviteGuild.js b/src/structures/InviteGuild.js new file mode 100644 index 00000000..ab1aed4 --- /dev/null +++ b/src/structures/InviteGuild.js @@ -0,0 +1,23 @@ +'use strict'; + +const AnonymousGuild = require('./AnonymousGuild'); +const WelcomeScreen = require('./WelcomeScreen'); + +/** + * Represents a guild received from an invite, includes welcome screen data if available. + * @extends {AnonymousGuild} + */ +class InviteGuild extends AnonymousGuild { + constructor(client, data) { + super(client, data); + + /** + * The welcome screen for this invite guild + * @type {?WelcomeScreen} + */ + this.welcomeScreen = + typeof data.welcome_screen !== 'undefined' ? new WelcomeScreen(this, data.welcome_screen) : null; + } +} + +module.exports = InviteGuild; diff --git a/src/structures/InviteStageInstance.js b/src/structures/InviteStageInstance.js new file mode 100644 index 00000000..21ede43 --- /dev/null +++ b/src/structures/InviteStageInstance.js @@ -0,0 +1,87 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Base = require('./Base'); + +/** + * Represents the data about a public {@link StageInstance} in an {@link Invite}. + * @extends {Base} + * @deprecated + */ +class InviteStageInstance extends Base { + constructor(client, data, channelId, guildId) { + super(client); + + /** + * The id of the stage channel this invite is for + * @type {Snowflake} + */ + this.channelId = channelId; + + /** + * The stage channel's guild id + * @type {Snowflake} + */ + this.guildId = guildId; + + /** + * The members speaking in the stage channel + * @type {Collection} + */ + this.members = new Collection(); + + this._patch(data); + } + + _patch(data) { + if ('topic' in data) { + /** + * The topic of the stage instance + * @type {string} + */ + this.topic = data.topic; + } + + if ('participant_count' in data) { + /** + * The number of users in the stage channel + * @type {number} + */ + this.participantCount = data.participant_count; + } + + if ('speaker_count' in data) { + /** + * The number of users speaking in the stage channel + * @type {number} + */ + this.speakerCount = data.speaker_count; + } + + this.members.clear(); + for (const rawMember of data.members) { + const member = this.guild.members._add(rawMember); + this.members.set(member.id, member); + } + } + + /** + * The stage channel this invite is for + * @type {?StageChannel} + * @readonly + */ + get channel() { + return this.client.channels.resolve(this.channelId); + } + + /** + * The guild of the stage channel this invite is for + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.resolve(this.guildId); + } +} + +module.exports = InviteStageInstance; diff --git a/src/structures/Message.js b/src/structures/Message.js new file mode 100644 index 00000000..dd8d287 --- /dev/null +++ b/src/structures/Message.js @@ -0,0 +1,948 @@ +'use strict'; + +const { createComponent, Embed } = require('@discordjs/builders'); +const { Collection } = require('@discordjs/collection'); +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { + InteractionType, + ChannelType, + MessageType, + MessageFlags, + PermissionFlagsBits, +} = require('discord-api-types/v9'); +const Base = require('./Base'); +const ClientApplication = require('./ClientApplication'); +const InteractionCollector = require('./InteractionCollector'); +const MessageAttachment = require('./MessageAttachment'); +const Mentions = require('./MessageMentions'); +const MessagePayload = require('./MessagePayload'); +const ReactionCollector = require('./ReactionCollector'); +const { Sticker } = require('./Sticker'); +const { Error } = require('../errors'); +const ReactionManager = require('../managers/ReactionManager'); +const { NonSystemMessageTypes } = require('../util/Constants'); +const MessageFlagsBitField = require('../util/MessageFlagsBitField'); +const PermissionsBitField = require('../util/PermissionsBitField'); +const Util = require('../util/Util'); + +/** + * Represents a message on Discord. + * @extends {Base} + */ +class Message extends Base { + constructor(client, data) { + super(client); + + /** + * The id of the channel the message was sent in + * @type {Snowflake} + */ + this.channelId = data.channel_id; + + /** + * The id of the guild the message was sent in, if any + * @type {?Snowflake} + */ + this.guildId = data.guild_id ?? this.channel?.guild?.id ?? null; + + this._patch(data); + } + + _patch(data) { + /** + * The message's id + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The timestamp the message was sent at + * @type {number} + */ + this.createdTimestamp = DiscordSnowflake.timestampFrom(this.id); + + if ('type' in data) { + /** + * The type of the message + * @type {?MessageType} + */ + this.type = data.type; + + /** + * Whether or not this message was sent by Discord, not actually a user (e.g. pin notifications) + * @type {?boolean} + */ + this.system = !NonSystemMessageTypes.includes(this.type); + } else { + this.system ??= null; + this.type ??= null; + } + + if ('content' in data) { + /** + * The content of the message + * @type {?string} + */ + this.content = data.content; + } else { + this.content ??= null; + } + + if ('author' in data) { + /** + * The author of the message + * @type {?User} + */ + this.author = this.client.users._add(data.author, !data.webhook_id); + } else { + this.author ??= null; + } + + if ('pinned' in data) { + /** + * Whether or not this message is pinned + * @type {?boolean} + */ + this.pinned = Boolean(data.pinned); + } else { + this.pinned ??= null; + } + + if ('tts' in data) { + /** + * Whether or not the message was Text-To-Speech + * @type {?boolean} + */ + this.tts = data.tts; + } else { + this.tts ??= null; + } + + if ('nonce' in data) { + /** + * A random number or string used for checking message delivery + * This is only received after the message was sent successfully, and + * lost if re-fetched + * @type {?string} + */ + this.nonce = data.nonce; + } else { + this.nonce ??= null; + } + + if ('embeds' in data) { + /** + * A list of embeds in the message - e.g. YouTube Player + * @type {Embed[]} + */ + this.embeds = data.embeds.map(e => new Embed(e)); + } else { + this.embeds = this.embeds?.slice() ?? []; + } + + if ('components' in data) { + /** + * A list of MessageActionRows in the message + * @type {ActionRow[]} + */ + this.components = data.components.map(c => createComponent(c)); + } else { + this.components = this.components?.slice() ?? []; + } + + if ('attachments' in data) { + /** + * A collection of attachments in the message - e.g. Pictures - mapped by their ids + * @type {Collection} + */ + this.attachments = new Collection(); + if (data.attachments) { + for (const attachment of data.attachments) { + this.attachments.set(attachment.id, new MessageAttachment(attachment.url, attachment.filename, attachment)); + } + } + } else { + this.attachments = new Collection(this.attachments); + } + + if ('sticker_items' in data || 'stickers' in data) { + /** + * A collection of stickers in the message + * @type {Collection} + */ + this.stickers = new Collection( + (data.sticker_items ?? data.stickers)?.map(s => [s.id, new Sticker(this.client, s)]), + ); + } else { + this.stickers = new Collection(this.stickers); + } + + // Discord sends null if the message has not been edited + if (data.edited_timestamp) { + /** + * The timestamp the message was last edited at (if applicable) + * @type {?number} + */ + this.editedTimestamp = Date.parse(data.edited_timestamp); + } else { + this.editedTimestamp ??= null; + } + + if ('reactions' in data) { + /** + * A manager of the reactions belonging to this message + * @type {ReactionManager} + */ + this.reactions = new ReactionManager(this); + if (data.reactions?.length > 0) { + for (const reaction of data.reactions) { + this.reactions._add(reaction); + } + } + } else { + this.reactions ??= new ReactionManager(this); + } + + if (!this.mentions) { + /** + * All valid mentions that the message contains + * @type {MessageMentions} + */ + this.mentions = new Mentions( + this, + data.mentions, + data.mention_roles, + data.mention_everyone, + data.mention_channels, + data.referenced_message?.author, + ); + } else { + this.mentions = new Mentions( + this, + data.mentions ?? this.mentions.users, + data.mention_roles ?? this.mentions.roles, + data.mention_everyone ?? this.mentions.everyone, + data.mention_channels ?? this.mentions.crosspostedChannels, + data.referenced_message?.author ?? this.mentions.repliedUser, + ); + } + + if ('webhook_id' in data) { + /** + * The id of the webhook that sent the message, if applicable + * @type {?Snowflake} + */ + this.webhookId = data.webhook_id; + } else { + this.webhookId ??= null; + } + + if ('application' in data) { + /** + * Supplemental application information for group activities + * @type {?ClientApplication} + */ + this.groupActivityApplication = new ClientApplication(this.client, data.application); + } else { + this.groupActivityApplication ??= null; + } + + if ('application_id' in data) { + /** + * The id of the application of the interaction that sent this message, if any + * @type {?Snowflake} + */ + this.applicationId = data.application_id; + } else { + this.applicationId ??= null; + } + + if ('activity' in data) { + /** + * Group activity + * @type {?MessageActivity} + */ + this.activity = { + partyId: data.activity.party_id, + type: data.activity.type, + }; + } else { + this.activity ??= null; + } + + if ('thread' in data) { + this.client.channels._add(data.thread, this.guild); + } + + if (this.member && data.member) { + this.member._patch(data.member); + } else if (data.member && this.guild && this.author) { + this.guild.members._add(Object.assign(data.member, { user: this.author })); + } + + if ('flags' in data) { + /** + * Flags that are applied to the message + * @type {Readonly} + */ + this.flags = new MessageFlagsBitField(data.flags).freeze(); + } else { + this.flags = new MessageFlagsBitField(this.flags).freeze(); + } + + /** + * Reference data sent in a message that contains ids identifying the referenced message. + * This can be present in the following types of message: + * * Crossposted messages (`MessageFlags.Crossposted`) + * * {@link MessageType.ChannelFollowAdd} + * * {@link MessageType.ChannelPinnedMessage} + * * {@link MessageType.Reply} + * * {@link MessageType.ThreadStarterMessage} + * @see {@link https://discord.com/developers/docs/resources/channel#message-types} + * @typedef {Object} MessageReference + * @property {Snowflake} channelId The channel's id the message was referenced + * @property {?Snowflake} guildId The guild's id the message was referenced + * @property {?Snowflake} messageId The message's id that was referenced + */ + + if ('message_reference' in data) { + /** + * Message reference data + * @type {?MessageReference} + */ + this.reference = { + channelId: data.message_reference.channel_id, + guildId: data.message_reference.guild_id, + messageId: data.message_reference.message_id, + }; + } else { + this.reference ??= null; + } + + if (data.referenced_message) { + this.channel?.messages._add({ guild_id: data.message_reference?.guild_id, ...data.referenced_message }); + } + + /** + * Partial data of the interaction that a message is a reply to + * @typedef {Object} MessageInteraction + * @property {Snowflake} id The interaction's id + * @property {InteractionType} type The type of the interaction + * @property {string} commandName The name of the interaction's application command + * @property {User} user The user that invoked the interaction + */ + + if (data.interaction) { + /** + * Partial data of the interaction that this message is a reply to + * @type {?MessageInteraction} + */ + this.interaction = { + id: data.interaction.id, + type: data.interaction.type, + commandName: data.interaction.name, + user: this.client.users._add(data.interaction.user), + }; + } else { + this.interaction ??= null; + } + } + + /** + * The channel that the message was sent in + * @type {TextChannel|DMChannel|NewsChannel|ThreadChannel} + * @readonly + */ + get channel() { + return this.client.channels.resolve(this.channelId); + } + + /** + * Whether or not this message is a partial + * @type {boolean} + * @readonly + */ + get partial() { + return typeof this.content !== 'string' || !this.author; + } + + /** + * Represents the author of the message as a guild member. + * Only available if the message comes from a guild where the author is still a member + * @type {?GuildMember} + * @readonly + */ + get member() { + return this.guild?.members.resolve(this.author) ?? null; + } + + /** + * The time the message was sent at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The time the message was last edited at (if applicable) + * @type {?Date} + * @readonly + */ + get editedAt() { + return this.editedTimestamp && new Date(this.editedTimestamp); + } + + /** + * The guild the message was sent in (if in a guild channel) + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.resolve(this.guildId) ?? this.channel?.guild ?? null; + } + + /** + * Whether this message has a thread associated with it + * @type {boolean} + * @readonly + */ + get hasThread() { + return this.flags.has(MessageFlags.HasThread); + } + + /** + * The thread started by this message + * This property is not suitable for checking whether a message has a thread, + * use {@link Message#hasThread} instead. + * @type {?ThreadChannel} + * @readonly + */ + get thread() { + return this.channel?.threads?.resolve(this.id) ?? null; + } + + /** + * The URL to jump to this message + * @type {string} + * @readonly + */ + get url() { + return `https://discord.com/channels/${this.guildId ?? '@me'}/${this.channelId}/${this.id}`; + } + + /** + * The message contents with all mentions replaced by the equivalent text. + * If mentions cannot be resolved to a name, the relevant mention in the message content will not be converted. + * @type {?string} + * @readonly + */ + get cleanContent() { + // eslint-disable-next-line eqeqeq + return this.content != null ? Util.cleanContent(this.content, this.channel) : null; + } + + /** + * Creates a reaction collector. + * @param {ReactionCollectorOptions} [options={}] Options to send to the collector + * @returns {ReactionCollector} + * @example + * // Create a reaction collector + * const filter = (reaction, user) => reaction.emoji.name === '👌' && user.id === 'someId'; + * const collector = message.createReactionCollector({ filter, time: 15_000 }); + * collector.on('collect', r => console.log(`Collected ${r.emoji.name}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createReactionCollector(options = {}) { + return new ReactionCollector(this, options); + } + + /** + * An object containing the same properties as CollectorOptions, but a few more: + * @typedef {ReactionCollectorOptions} AwaitReactionsOptions + * @property {string[]} [errors] Stop/end reasons that cause the promise to reject + */ + + /** + * Similar to createReactionCollector but in promise form. + * Resolves with a collection of reactions that pass the specified filter. + * @param {AwaitReactionsOptions} [options={}] Optional options to pass to the internal collector + * @returns {Promise>} + * @example + * // Create a reaction collector + * const filter = (reaction, user) => reaction.emoji.name === '👌' && user.id === 'someId' + * message.awaitReactions({ filter, time: 15_000 }) + * .then(collected => console.log(`Collected ${collected.size} reactions`)) + * .catch(console.error); + */ + awaitReactions(options = {}) { + return new Promise((resolve, reject) => { + const collector = this.createReactionCollector(options); + collector.once('end', (reactions, reason) => { + if (options.errors?.includes(reason)) reject(reactions); + else resolve(reactions); + }); + }); + } + + /** + * @typedef {CollectorOptions} MessageComponentCollectorOptions + * @property {ComponentType} [componentType] The type of component to listen for + * @property {number} [max] The maximum total amount of interactions to collect + * @property {number} [maxComponents] The maximum number of components to collect + * @property {number} [maxUsers] The maximum number of users to interact + */ + + /** + * Creates a message component interaction collector. + * @param {MessageComponentCollectorOptions} [options={}] Options to send to the collector + * @returns {InteractionCollector} + * @example + * // Create a message component interaction collector + * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; + * const collector = message.createMessageComponentCollector({ filter, time: 15_000 }); + * collector.on('collect', i => console.log(`Collected ${i.customId}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createMessageComponentCollector(options = {}) { + return new InteractionCollector(this.client, { + ...options, + interactionType: InteractionType.MessageComponent, + message: this, + }); + } + + /** + * An object containing the same properties as CollectorOptions, but a few more: + * @typedef {Object} AwaitMessageComponentOptions + * @property {CollectorFilter} [filter] The filter applied to this collector + * @property {number} [time] Time to wait for an interaction before rejecting + * @property {ComponentType} [componentType] The type of component interaction to collect + */ + + /** + * Collects a single component interaction that passes the filter. + * The Promise will reject if the time expires. + * @param {AwaitMessageComponentOptions} [options={}] Options to pass to the internal collector + * @returns {Promise} + * @example + * // Collect a message component interaction + * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; + * message.awaitMessageComponent({ filter, time: 15_000 }) + * .then(interaction => console.log(`${interaction.customId} was clicked!`)) + * .catch(console.error); + */ + awaitMessageComponent(options = {}) { + const _options = { ...options, max: 1 }; + return new Promise((resolve, reject) => { + const collector = this.createMessageComponentCollector(_options); + collector.once('end', (interactions, reason) => { + const interaction = interactions.first(); + if (interaction) resolve(interaction); + else reject(new Error('INTERACTION_COLLECTOR_ERROR', reason)); + }); + }); + } + + /** + * Whether the message is editable by the client user + * @type {boolean} + * @readonly + */ + get editable() { + const precheck = Boolean(this.author.id === this.client.user.id && (!this.guild || this.channel?.viewable)); + // Regardless of permissions thread messages cannot be edited if + // the thread is locked. + if (this.channel?.isThread()) { + return precheck && !this.channel.locked; + } + return precheck; + } + + /** + * Whether the message is deletable by the client user + * @type {boolean} + * @readonly + */ + get deletable() { + if (!this.guild) { + return this.author.id === this.client.user.id; + } + // DMChannel does not have viewable property, so check viewable after proved that message is on a guild. + if (!this.channel?.viewable) { + return false; + } + + const permissions = this.channel?.permissionsFor(this.client.user); + if (!permissions) return false; + // This flag allows deleting even if timed out + if (permissions.has(PermissionFlagsBits.Administrator, false)) return true; + + return Boolean( + this.author.id === this.client.user.id || + (permissions.has(PermissionFlagsBits.ManageMessages, false) && + this.guild.me.communicationDisabledUntilTimestamp < Date.now()), + ); + } + + /** + * Whether the message is pinnable by the client user + * @type {boolean} + * @readonly + */ + get pinnable() { + const { channel } = this; + return Boolean( + !this.system && + (!this.guild || + (channel?.viewable && + channel?.permissionsFor(this.client.user)?.has(PermissionFlagsBits.ManageMessages, false))), + ); + } + + /** + * Fetches the Message this crosspost/reply/pin-add references, if available to the client + * @returns {Promise} + */ + async fetchReference() { + if (!this.reference) throw new Error('MESSAGE_REFERENCE_MISSING'); + const { channelId, messageId } = this.reference; + const channel = this.client.channels.resolve(channelId); + if (!channel) throw new Error('GUILD_CHANNEL_RESOLVE'); + const message = await channel.messages.fetch(messageId); + return message; + } + + /** + * Whether the message is crosspostable by the client user + * @type {boolean} + * @readonly + */ + get crosspostable() { + const bitfield = + PermissionFlagsBits.SendMessages | + (this.author.id === this.client.user.id ? PermissionsBitField.defaultBit : PermissionFlagsBits.ManageMessages); + const { channel } = this; + return Boolean( + channel?.type === ChannelType.GuildNews && + !this.flags.has(MessageFlags.Crossposted) && + this.type === MessageType.Default && + channel.viewable && + channel.permissionsFor(this.client.user)?.has(bitfield, false), + ); + } + + /** + * Options that can be passed into {@link Message#edit}. + * @typedef {Object} MessageEditOptions + * @property {?string} [content] Content to be edited + * @property {Embed[]|APIEmbed[]} [embeds] Embeds to be added/edited + * @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content + * @property {MessageFlags} [flags] Which flags to set for the message. + * Only `MessageFlags.SuppressEmbeds` can be edited. + * @property {MessageAttachment[]} [attachments] An array of attachments to keep, + * all attachments will be kept if omitted + * @property {FileOptions[]|BufferResolvable[]|MessageAttachment[]} [files] Files to add to the message + * @property {ActionRow[]|ActionRowOptions[]} [components] + * Action rows containing interactive components for the message (buttons, select menus) + */ + + /** + * Edits the content of the message. + * @param {string|MessagePayload|MessageEditOptions} options The options to provide + * @returns {Promise} + * @example + * // Update the content of a message + * message.edit('This is my new content!') + * .then(msg => console.log(`Updated the content of a message to ${msg.content}`)) + * .catch(console.error); + */ + edit(options) { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + return this.channel.messages.edit(this, options); + } + + /** + * Publishes a message in an announcement channel to all channels following it. + * @returns {Promise} + * @example + * // Crosspost a message + * if (message.channel.type === ChannelType.GuildNews) { + * message.crosspost() + * .then(() => console.log('Crossposted message')) + * .catch(console.error); + * } + */ + crosspost() { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + return this.channel.messages.crosspost(this.id); + } + + /** + * Pins this message to the channel's pinned messages. + * @param {string} [reason] Reason for pinning + * @returns {Promise} + * @example + * // Pin a message + * message.pin() + * .then(console.log) + * .catch(console.error) + */ + async pin(reason) { + if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); + await this.channel.messages.pin(this.id, reason); + return this; + } + + /** + * Unpins this message from the channel's pinned messages. + * @param {string} [reason] Reason for unpinning + * @returns {Promise} + * @example + * // Unpin a message + * message.unpin() + * .then(console.log) + * .catch(console.error) + */ + async unpin(reason) { + if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); + await this.channel.messages.unpin(this.id, reason); + return this; + } + + /** + * Adds a reaction to the message. + * @param {EmojiIdentifierResolvable} emoji The emoji to react with + * @returns {Promise} + * @example + * // React to a message with a unicode emoji + * message.react('🤔') + * .then(console.log) + * .catch(console.error); + * @example + * // React to a message with a custom emoji + * message.react(message.guild.emojis.cache.get('123456789012345678')) + * .then(console.log) + * .catch(console.error); + */ + async react(emoji) { + if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); + await this.channel.messages.react(this.id, emoji); + + return this.client.actions.MessageReactionAdd.handle( + { + user: this.client.user, + channel: this.channel, + message: this, + emoji: Util.resolvePartialEmoji(emoji), + }, + true, + ).reaction; + } + + /** + * Deletes the message. + * @returns {Promise} + * @example + * // Delete a message + * message.delete() + * .then(msg => console.log(`Deleted message from ${msg.author.username}`)) + * .catch(console.error); + */ + async delete() { + if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); + await this.channel.messages.delete(this.id); + return this; + } + + /** + * Options provided when sending a message as an inline reply. + * @typedef {BaseMessageOptions} ReplyMessageOptions + * @property {boolean} [failIfNotExists=this.client.options.failIfNotExists] Whether to error if the referenced + * message does not exist (creates a standard message in this case when false) + * @property {StickerResolvable[]} [stickers=[]] Stickers to send in the message + */ + + /** + * Send an inline reply to this message. + * @param {string|MessagePayload|ReplyMessageOptions} options The options to provide + * @returns {Promise} + * @example + * // Reply to a message + * message.reply('This is a reply!') + * .then(() => console.log(`Replied to message "${message.content}"`)) + * .catch(console.error); + */ + reply(options) { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + let data; + + if (options instanceof MessagePayload) { + data = options; + } else { + data = MessagePayload.create(this, options, { + reply: { + messageReference: this, + failIfNotExists: options?.failIfNotExists ?? this.client.options.failIfNotExists, + }, + }); + } + return this.channel.send(data); + } + + /** + * A number that is allowed to be the duration (in minutes) of inactivity after which a thread is automatically + * archived. This can be: + * * `60` (1 hour) + * * `1440` (1 day) + * * `4320` (3 days) This is only available when the guild has the `THREE_DAY_THREAD_ARCHIVE` feature. + * * `10080` (7 days) This is only available when the guild has the `SEVEN_DAY_THREAD_ARCHIVE` feature. + * * `'MAX'` Based on the guild's features + * @typedef {number|string} ThreadAutoArchiveDuration + */ + + /** + * Options for starting a thread on a message. + * @typedef {Object} StartThreadOptions + * @property {string} name The name of the new thread + * @property {ThreadAutoArchiveDuration} [autoArchiveDuration=this.channel.defaultAutoArchiveDuration] The amount of + * time (in minutes) after which the thread should automatically archive in case of no recent activity + * @property {string} [reason] Reason for creating the thread + * @property {number} [rateLimitPerUser] The rate limit per user (slowmode) for the thread in seconds + */ + + /** + * Create a new public thread from this message + * @see ThreadManager#create + * @param {StartThreadOptions} [options] Options for starting a thread on this message + * @returns {Promise} + */ + startThread(options = {}) { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + if (![ChannelType.GuildText, ChannelType.GuildNews].includes(this.channel.type)) { + return Promise.reject(new Error('MESSAGE_THREAD_PARENT')); + } + if (this.hasThread) return Promise.reject(new Error('MESSAGE_EXISTING_THREAD')); + return this.channel.threads.create({ ...options, startMessage: this }); + } + + /** + * Fetch this message. + * @param {boolean} [force=true] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetch(force = true) { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + return this.channel.messages.fetch(this.id, { force }); + } + + /** + * Fetches the webhook used to create this message. + * @returns {Promise} + */ + fetchWebhook() { + if (!this.webhookId) return Promise.reject(new Error('WEBHOOK_MESSAGE')); + if (this.webhookId === this.applicationId) return Promise.reject(new Error('WEBHOOK_APPLICATION')); + return this.client.fetchWebhook(this.webhookId); + } + + /** + * Suppresses or unsuppresses embeds on a message. + * @param {boolean} [suppress=true] If the embeds should be suppressed or not + * @returns {Promise} + */ + suppressEmbeds(suppress = true) { + const flags = new MessageFlagsBitField(this.flags.bitfield); + + if (suppress) { + flags.add(MessageFlags.SuppressEmbeds); + } else { + flags.remove(MessageFlags.SuppressEmbeds); + } + + return this.edit({ flags }); + } + + /** + * Removes the attachments from this message. + * @returns {Promise} + */ + removeAttachments() { + return this.edit({ attachments: [] }); + } + + /** + * Resolves a component by a custom id. + * @param {string} customId The custom id to resolve against + * @returns {?MessageActionRowComponent} + */ + resolveComponent(customId) { + return this.components.flatMap(row => row.components).find(component => component.customId === customId) ?? null; + } + + /** + * Used mainly internally. Whether two messages are identical in properties. If you want to compare messages + * without checking all the properties, use `message.id === message2.id`, which is much more efficient. This + * method allows you to see if there are differences in content, embeds, attachments, nonce and tts properties. + * @param {Message} message The message to compare it to + * @param {APIMessage} rawData Raw data passed through the WebSocket about this message + * @returns {boolean} + */ + equals(message, rawData) { + if (!message) return false; + const embedUpdate = !message.author && !message.attachments; + if (embedUpdate) return this.id === message.id && this.embeds.length === message.embeds.length; + + let equal = + this.id === message.id && + this.author.id === message.author.id && + this.content === message.content && + this.tts === message.tts && + this.nonce === message.nonce && + this.embeds.length === message.embeds.length && + this.attachments.length === message.attachments.length; + + if (equal && rawData) { + equal = + this.mentions.everyone === message.mentions.everyone && + this.createdTimestamp === Date.parse(rawData.timestamp) && + this.editedTimestamp === Date.parse(rawData.edited_timestamp); + } + + return equal; + } + + /** + * Whether this message is from a guild. + * @returns {boolean} + */ + inGuild() { + return Boolean(this.guildId); + } + + /** + * When concatenated with a string, this automatically concatenates the message's content instead of the object. + * @returns {string} + * @example + * // Logs: Message: This is a message! + * console.log(`Message: ${message}`); + */ + toString() { + return this.content; + } + + toJSON() { + return super.toJSON({ + channel: 'channelId', + author: 'authorId', + groupActivityApplication: 'groupActivityApplicationId', + guild: 'guildId', + cleanContent: true, + member: false, + reactions: false, + }); + } +} + +exports.Message = Message; diff --git a/src/structures/MessageAttachment.js b/src/structures/MessageAttachment.js new file mode 100644 index 00000000..3426a17 --- /dev/null +++ b/src/structures/MessageAttachment.js @@ -0,0 +1,171 @@ +'use strict'; + +const Util = require('../util/Util'); + +/** + * Represents an attachment in a message. + */ +class MessageAttachment { + /** + * @param {BufferResolvable|Stream} attachment The file + * @param {string} [name=null] The name of the file, if any + * @param {APIAttachment} [data] Extra data + */ + constructor(attachment, name = null, data) { + this.attachment = attachment; + /** + * The name of this attachment + * @type {?string} + */ + this.name = name; + if (data) this._patch(data); + } + + /** + * Sets the description of this attachment. + * @param {string} description The description of the file + * @returns {MessageAttachment} This attachment + */ + setDescription(description) { + this.description = description; + return this; + } + + /** + * Sets the file of this attachment. + * @param {BufferResolvable|Stream} attachment The file + * @param {string} [name=null] The name of the file, if any + * @returns {MessageAttachment} This attachment + */ + setFile(attachment, name = null) { + this.attachment = attachment; + this.name = name; + return this; + } + + /** + * Sets the name of this attachment. + * @param {string} name The name of the file + * @returns {MessageAttachment} This attachment + */ + setName(name) { + this.name = name; + return this; + } + + /** + * Sets whether this attachment is a spoiler + * @param {boolean} [spoiler=true] Whether the attachment should be marked as a spoiler + * @returns {MessageAttachment} This attachment + */ + setSpoiler(spoiler = true) { + if (spoiler === this.spoiler) return this; + + if (!spoiler) { + while (this.spoiler) { + this.name = this.name.slice('SPOILER_'.length); + } + return this; + } + this.name = `SPOILER_${this.name}`; + return this; + } + + _patch(data) { + /** + * The attachment's id + * @type {Snowflake} + */ + this.id = data.id; + + if ('size' in data) { + /** + * The size of this attachment in bytes + * @type {number} + */ + this.size = data.size; + } + + if ('url' in data) { + /** + * The URL to this attachment + * @type {string} + */ + this.url = data.url; + } + + if ('proxy_url' in data) { + /** + * The Proxy URL to this attachment + * @type {string} + */ + this.proxyURL = data.proxy_url; + } + + if ('height' in data) { + /** + * The height of this attachment (if an image or video) + * @type {?number} + */ + this.height = data.height; + } else { + this.height ??= null; + } + + if ('width' in data) { + /** + * The width of this attachment (if an image or video) + * @type {?number} + */ + this.width = data.width; + } else { + this.width ??= null; + } + + if ('content_type' in data) { + /** + * The media type of this attachment + * @type {?string} + */ + this.contentType = data.content_type; + } else { + this.contentType ??= null; + } + + if ('description' in data) { + /** + * The description (alt text) of this attachment + * @type {?string} + */ + this.description = data.description; + } else { + this.description ??= null; + } + + /** + * Whether this attachment is ephemeral + * @type {boolean} + */ + this.ephemeral = data.ephemeral ?? false; + } + + /** + * Whether or not this attachment has been marked as a spoiler + * @type {boolean} + * @readonly + */ + get spoiler() { + return Util.basename(this.url ?? this.name).startsWith('SPOILER_'); + } + + toJSON() { + return Util.flatten(this); + } +} + +module.exports = MessageAttachment; + +/** + * @external APIAttachment + * @see {@link https://discord.com/developers/docs/resources/channel#attachment-object} + */ diff --git a/src/structures/MessageCollector.js b/src/structures/MessageCollector.js new file mode 100644 index 00000000..736bd68 --- /dev/null +++ b/src/structures/MessageCollector.js @@ -0,0 +1,146 @@ +'use strict'; + +const Collector = require('./interfaces/Collector'); +const Events = require('../util/Events'); + +/** + * @typedef {CollectorOptions} MessageCollectorOptions + * @property {number} max The maximum amount of messages to collect + * @property {number} maxProcessed The maximum amount of messages to process + */ + +/** + * Collects messages on a channel. + * Will automatically stop if the channel ({@link Client#event:channelDelete channelDelete}), + * thread ({@link Client#event:threadDelete threadDelete}), or + * guild ({@link Client#event:guildDelete guildDelete}) is deleted. + * @extends {Collector} + */ +class MessageCollector extends Collector { + /** + * @param {TextBasedChannels} channel The channel + * @param {MessageCollectorOptions} options The options to be applied to this collector + * @emits MessageCollector#message + */ + constructor(channel, options = {}) { + super(channel.client, options); + + /** + * The channel + * @type {TextBasedChannels} + */ + this.channel = channel; + + /** + * Total number of messages that were received in the channel during message collection + * @type {number} + */ + this.received = 0; + + const bulkDeleteListener = messages => { + for (const message of messages.values()) this.handleDispose(message); + }; + + this._handleChannelDeletion = this._handleChannelDeletion.bind(this); + this._handleThreadDeletion = this._handleThreadDeletion.bind(this); + this._handleGuildDeletion = this._handleGuildDeletion.bind(this); + + this.client.incrementMaxListeners(); + this.client.on(Events.MessageCreate, this.handleCollect); + this.client.on(Events.MessageDelete, this.handleDispose); + this.client.on(Events.MessageBulkDelete, bulkDeleteListener); + this.client.on(Events.ChannelDelete, this._handleChannelDeletion); + this.client.on(Events.ThreadDelete, this._handleThreadDeletion); + this.client.on(Events.GuildDelete, this._handleGuildDeletion); + + this.once('end', () => { + this.client.removeListener(Events.MessageCreate, this.handleCollect); + this.client.removeListener(Events.MessageDelete, this.handleDispose); + this.client.removeListener(Events.MessageBulkDelete, bulkDeleteListener); + this.client.removeListener(Events.ChannelDelete, this._handleChannelDeletion); + this.client.removeListener(Events.ThreadDelete, this._handleThreadDeletion); + this.client.removeListener(Events.GuildDelete, this._handleGuildDeletion); + this.client.decrementMaxListeners(); + }); + } + + /** + * Handles a message for possible collection. + * @param {Message} message The message that could be collected + * @returns {?Snowflake} + * @private + */ + collect(message) { + /** + * Emitted whenever a message is collected. + * @event MessageCollector#collect + * @param {Message} message The message that was collected + */ + if (message.channelId !== this.channel.id) return null; + this.received++; + return message.id; + } + + /** + * Handles a message for possible disposal. + * @param {Message} message The message that could be disposed of + * @returns {?Snowflake} + */ + dispose(message) { + /** + * Emitted whenever a message is disposed of. + * @event MessageCollector#dispose + * @param {Message} message The message that was disposed of + */ + return message.channelId === this.channel.id ? message.id : null; + } + + /** + * The reason this collector has ended with, or null if it hasn't ended yet + * @type {?string} + * @readonly + */ + get endReason() { + if (this.options.max && this.collected.size >= this.options.max) return 'limit'; + if (this.options.maxProcessed && this.received === this.options.maxProcessed) return 'processedLimit'; + return null; + } + + /** + * Handles checking if the channel has been deleted, and if so, stops the collector with the reason 'channelDelete'. + * @private + * @param {GuildChannel} channel The channel that was deleted + * @returns {void} + */ + _handleChannelDeletion(channel) { + if (channel.id === this.channel.id || channel.id === this.channel.parentId) { + this.stop('channelDelete'); + } + } + + /** + * Handles checking if the thread has been deleted, and if so, stops the collector with the reason 'threadDelete'. + * @private + * @param {ThreadChannel} thread The thread that was deleted + * @returns {void} + */ + _handleThreadDeletion(thread) { + if (thread.id === this.channel.id) { + this.stop('threadDelete'); + } + } + + /** + * Handles checking if the guild has been deleted, and if so, stops the collector with the reason 'guildDelete'. + * @private + * @param {Guild} guild The guild that was deleted + * @returns {void} + */ + _handleGuildDeletion(guild) { + if (guild.id === this.channel.guild?.id) { + this.stop('guildDelete'); + } + } +} + +module.exports = MessageCollector; diff --git a/src/structures/MessageComponentInteraction.js b/src/structures/MessageComponentInteraction.js new file mode 100644 index 00000000..1e393b3 --- /dev/null +++ b/src/structures/MessageComponentInteraction.js @@ -0,0 +1,132 @@ +'use strict'; + +const Interaction = require('./Interaction'); +const InteractionWebhook = require('./InteractionWebhook'); +const InteractionResponses = require('./interfaces/InteractionResponses'); + +/** + * Represents a message component interaction. + * @extends {Interaction} + * @implements {InteractionResponses} + */ +class MessageComponentInteraction extends Interaction { + constructor(client, data) { + super(client, data); + + /** + * The id of the channel this interaction was sent in + * @type {Snowflake} + * @name MessageComponentInteraction#channelId + */ + + /** + * The message to which the component was attached + * @type {Message|APIMessage} + */ + this.message = this.channel?.messages._add(data.message) ?? data.message; + + /** + * The custom id of the component which was interacted with + * @type {string} + */ + this.customId = data.data.custom_id; + + /** + * The type of component which was interacted with + * @type {ComponentType} + */ + this.componentType = data.data.component_type; + + /** + * Whether the reply to this interaction has been deferred + * @type {boolean} + */ + this.deferred = false; + + /** + * Whether the reply to this interaction is ephemeral + * @type {?boolean} + */ + this.ephemeral = null; + + /** + * Whether this interaction has already been replied to + * @type {boolean} + */ + this.replied = false; + + /** + * An associated interaction webhook, can be used to further interact with this interaction + * @type {InteractionWebhook} + */ + this.webhook = new InteractionWebhook(this.client, this.applicationId, this.token); + } + + /** + * Raw message components from the API + * * APIMessageButton + * * APIMessageSelectMenu + * @typedef {APIMessageButton|APIMessageSelectMenu} APIMessageActionRowComponent + */ + + /** + * The component which was interacted with + * @type {MessageActionRowComponent|APIMessageActionRowComponent} + * @readonly + */ + get component() { + return this.message.components + .flatMap(row => row.components) + .find(component => (component.customId ?? component.custom_id) === this.customId); + } + + // These are here only for documentation purposes - they are implemented by InteractionResponses + /* eslint-disable no-empty-function */ + deferReply() {} + reply() {} + fetchReply() {} + editReply() {} + deleteReply() {} + followUp() {} + deferUpdate() {} + update() {} +} + +InteractionResponses.applyToClass(MessageComponentInteraction); + +module.exports = MessageComponentInteraction; + +/** + * @external APIMessageSelectMenu + * @see {@link https://discord.com/developers/docs/interactions/message-components#select-menu-object} + */ + +/** + * @external APIMessageButton + * @see {@link https://discord.com/developers/docs/interactions/message-components#button-object} + */ + +/** + * @external ButtonComponent + * @see {@link https://discord.js.org/#/docs/builders/main/class/ButtonComponent} + */ + +/** + * @external SelectMenuComponent + * @see {@link https://discord.js.org/#/docs/builders/main/class/SelectMenuComponent} + */ + +/** + * @external SelectMenuOption + * @see {@link https://discord.js.org/#/docs/builders/main/class/SelectMenuComponent} + */ + +/** + * @external ActionRow + * @see {@link https://discord.js.org/#/docs/builders/main/class/ActionRow} + */ + +/** + * @external Embed + * @see {@link https://discord.js.org/#/docs/builders/main/class/Embed} + */ diff --git a/src/structures/MessageContextMenuCommandInteraction.js b/src/structures/MessageContextMenuCommandInteraction.js new file mode 100644 index 00000000..1100591 --- /dev/null +++ b/src/structures/MessageContextMenuCommandInteraction.js @@ -0,0 +1,20 @@ +'use strict'; + +const ContextMenuCommandInteraction = require('./ContextMenuCommandInteraction'); + +/** + * Represents a message context menu interaction. + * @extends {ContextMenuCommandInteraction} + */ +class MessageContextMenuCommandInteraction extends ContextMenuCommandInteraction { + /** + * The message this interaction was sent from + * @type {Message|APIMessage} + * @readonly + */ + get targetMessage() { + return this.options.getMessage('message'); + } +} + +module.exports = MessageContextMenuCommandInteraction; diff --git a/src/structures/MessageMentions.js b/src/structures/MessageMentions.js new file mode 100644 index 00000000..6f1588d --- /dev/null +++ b/src/structures/MessageMentions.js @@ -0,0 +1,239 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Util = require('../util/Util'); + +/** + * Keeps track of mentions in a {@link Message}. + */ +class MessageMentions { + constructor(message, users, roles, everyone, crosspostedChannels, repliedUser) { + /** + * The client the message is from + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: message.client }); + + /** + * The guild the message is in + * @type {?Guild} + * @readonly + */ + Object.defineProperty(this, 'guild', { value: message.guild }); + + /** + * The initial message content + * @type {string} + * @readonly + * @private + */ + Object.defineProperty(this, '_content', { value: message.content }); + + /** + * Whether `@everyone` or `@here` were mentioned + * @type {boolean} + */ + this.everyone = Boolean(everyone); + + if (users) { + if (users instanceof Collection) { + /** + * Any users that were mentioned + * Order as received from the API, not as they appear in the message content + * @type {Collection} + */ + this.users = new Collection(users); + } else { + this.users = new Collection(); + for (const mention of users) { + if (mention.member && message.guild) { + message.guild.members._add(Object.assign(mention.member, { user: mention })); + } + const user = message.client.users._add(mention); + this.users.set(user.id, user); + } + } + } else { + this.users = new Collection(); + } + + if (roles instanceof Collection) { + /** + * Any roles that were mentioned + * Order as received from the API, not as they appear in the message content + * @type {Collection} + */ + this.roles = new Collection(roles); + } else if (roles) { + this.roles = new Collection(); + const guild = message.guild; + if (guild) { + for (const mention of roles) { + const role = guild.roles.cache.get(mention); + if (role) this.roles.set(role.id, role); + } + } + } else { + this.roles = new Collection(); + } + + /** + * Cached members for {@link MessageMentions#members} + * @type {?Collection} + * @private + */ + this._members = null; + + /** + * Cached channels for {@link MessageMentions#channels} + * @type {?Collection} + * @private + */ + this._channels = null; + + /** + * Crossposted channel data. + * @typedef {Object} CrosspostedChannel + * @property {Snowflake} channelId The mentioned channel's id + * @property {Snowflake} guildId The id of the guild that has the channel + * @property {ChannelType} type The channel's type + * @property {string} name The channel's name + */ + + if (crosspostedChannels) { + if (crosspostedChannels instanceof Collection) { + /** + * A collection of crossposted channels + * Order as received from the API, not as they appear in the message content + * @type {Collection} + */ + this.crosspostedChannels = new Collection(crosspostedChannels); + } else { + this.crosspostedChannels = new Collection(); + for (const d of crosspostedChannels) { + this.crosspostedChannels.set(d.id, { + channelId: d.id, + guildId: d.guild_id, + type: d.type, + name: d.name, + }); + } + } + } else { + this.crosspostedChannels = new Collection(); + } + + /** + * The author of the message that this message is a reply to + * @type {?User} + */ + this.repliedUser = repliedUser ? this.client.users._add(repliedUser) : null; + } + + /** + * Any members that were mentioned (only in {@link Guild}s) + * Order as received from the API, not as they appear in the message content + * @type {?Collection} + * @readonly + */ + get members() { + if (this._members) return this._members; + if (!this.guild) return null; + this._members = new Collection(); + this.users.forEach(user => { + const member = this.guild.members.resolve(user); + if (member) this._members.set(member.user.id, member); + }); + return this._members; + } + + /** + * Any channels that were mentioned + * Order as they appear first in the message content + * @type {Collection} + * @readonly + */ + get channels() { + if (this._channels) return this._channels; + this._channels = new Collection(); + let matches; + while ((matches = this.constructor.CHANNELS_PATTERN.exec(this._content)) !== null) { + const chan = this.client.channels.cache.get(matches[1]); + if (chan) this._channels.set(chan.id, chan); + } + return this._channels; + } + + /** + * Options used to check for a mention. + * @typedef {Object} MessageMentionsHasOptions + * @property {boolean} [ignoreDirect=false] Whether to ignore direct mentions to the item + * @property {boolean} [ignoreRoles=false] Whether to ignore role mentions to a guild member + * @property {boolean} [ignoreRepliedUser=false] Whether to ignore replied user mention to an user + * @property {boolean} [ignoreEveryone=false] Whether to ignore `@everyone`/`@here` mentions + */ + + /** + * Checks if a user, guild member, thread member, role, or channel is mentioned. + * Takes into account user mentions, role mentions, channel mentions, + * replied user mention, and `@everyone`/`@here` mentions. + * @param {UserResolvable|RoleResolvable|ChannelResolvable} data The User/Role/Channel to check for + * @param {MessageMentionsHasOptions} [options] The options for the check + * @returns {boolean} + */ + has(data, { ignoreDirect = false, ignoreRoles = false, ignoreRepliedUser = false, ignoreEveryone = false } = {}) { + const user = this.client.users.resolve(data); + const role = this.guild?.roles.resolve(data); + const channel = this.client.channels.resolve(data); + + if (!ignoreRepliedUser && this.users.has(this.repliedUser?.id) && this.repliedUser?.id === user?.id) return true; + if (!ignoreDirect) { + if (this.users.has(user?.id)) return true; + if (this.roles.has(role?.id)) return true; + if (this.channels.has(channel?.id)) return true; + } + if (user && !ignoreEveryone && this.everyone) return true; + if (!ignoreRoles) { + const member = this.guild?.members.resolve(data); + if (member) { + for (const mentionedRole of this.roles.values()) if (member.roles.cache.has(mentionedRole.id)) return true; + } + } + + return false; + } + + toJSON() { + return Util.flatten(this, { + members: true, + channels: true, + }); + } +} + +/** + * Regular expression that globally matches `@everyone` and `@here` + * @type {RegExp} + */ +MessageMentions.EVERYONE_PATTERN = /@(everyone|here)/g; + +/** + * Regular expression that globally matches user mentions like `<@81440962496172032>` + * @type {RegExp} + */ +MessageMentions.USERS_PATTERN = /<@!?(\d{17,19})>/g; + +/** + * Regular expression that globally matches role mentions like `<@&297577916114403338>` + * @type {RegExp} + */ +MessageMentions.ROLES_PATTERN = /<@&(\d{17,19})>/g; + +/** + * Regular expression that globally matches channel mentions like `<#222079895583457280>` + * @type {RegExp} + */ +MessageMentions.CHANNELS_PATTERN = /<#(\d{17,19})>/g; + +module.exports = MessageMentions; diff --git a/src/structures/MessagePayload.js b/src/structures/MessagePayload.js new file mode 100644 index 00000000..b79feb6 --- /dev/null +++ b/src/structures/MessagePayload.js @@ -0,0 +1,292 @@ +'use strict'; + +const { Buffer } = require('node:buffer'); +const { isJSONEncodable } = require('@discordjs/builders'); +const { MessageFlags } = require('discord-api-types/v9'); +const { RangeError } = require('../errors'); +const DataResolver = require('../util/DataResolver'); +const MessageFlagsBitField = require('../util/MessageFlagsBitField'); +const Util = require('../util/Util'); + +/** + * Represents a message to be sent to the API. + */ +class MessagePayload { + /** + * @param {MessageTarget} target The target for this message to be sent to + * @param {MessageOptions|WebhookMessageOptions} options Options passed in from send + */ + constructor(target, options) { + /** + * The target for this message to be sent to + * @type {MessageTarget} + */ + this.target = target; + + /** + * Options passed in from send + * @type {MessageOptions|WebhookMessageOptions} + */ + this.options = options; + + /** + * Body sendable to the API + * @type {?APIMessage} + */ + this.body = null; + + /** + * Files sendable to the API + * @type {?RawFile[]} + */ + this.files = null; + } + + /** + * Whether or not the target is a {@link Webhook} or a {@link WebhookClient} + * @type {boolean} + * @readonly + */ + get isWebhook() { + const Webhook = require('./Webhook'); + const WebhookClient = require('../client/WebhookClient'); + return this.target instanceof Webhook || this.target instanceof WebhookClient; + } + + /** + * Whether or not the target is a {@link User} + * @type {boolean} + * @readonly + */ + get isUser() { + const User = require('./User'); + const { GuildMember } = require('./GuildMember'); + return this.target instanceof User || this.target instanceof GuildMember; + } + + /** + * Whether or not the target is a {@link Message} + * @type {boolean} + * @readonly + */ + get isMessage() { + const { Message } = require('./Message'); + return this.target instanceof Message; + } + + /** + * Whether or not the target is a {@link MessageManager} + * @type {boolean} + * @readonly + */ + get isMessageManager() { + const MessageManager = require('../managers/MessageManager'); + return this.target instanceof MessageManager; + } + + /** + * Whether or not the target is an {@link Interaction} or an {@link InteractionWebhook} + * @type {boolean} + * @readonly + */ + get isInteraction() { + const Interaction = require('./Interaction'); + const InteractionWebhook = require('./InteractionWebhook'); + return this.target instanceof Interaction || this.target instanceof InteractionWebhook; + } + + /** + * Makes the content of this message. + * @returns {?string} + */ + makeContent() { + let content; + if (this.options.content === null) { + content = ''; + } else if (typeof this.options.content !== 'undefined') { + content = Util.verifyString(this.options.content, RangeError, 'MESSAGE_CONTENT_TYPE', false); + } + + return content; + } + + /** + * Resolves the body. + * @returns {MessagePayload} + */ + resolveBody() { + if (this.data) return this; + const isInteraction = this.isInteraction; + const isWebhook = this.isWebhook; + + const content = this.makeContent(); + const tts = Boolean(this.options.tts); + + let nonce; + if (typeof this.options.nonce !== 'undefined') { + nonce = this.options.nonce; + // eslint-disable-next-line max-len + if (typeof nonce === 'number' ? !Number.isInteger(nonce) : typeof nonce !== 'string') { + throw new RangeError('MESSAGE_NONCE_TYPE'); + } + } + + const components = this.options.components?.map(c => + isJSONEncodable(c) ? c.toJSON() : this.target.client.options.jsonTransformer(c), + ); + + let username; + let avatarURL; + if (isWebhook) { + username = this.options.username ?? this.target.name; + if (this.options.avatarURL) avatarURL = this.options.avatarURL; + } + + let flags; + if ( + typeof this.options.flags !== 'undefined' || + (this.isMessage && typeof this.options.reply === 'undefined') || + this.isMessageManager + ) { + flags = + // eslint-disable-next-line eqeqeq + this.options.flags != null + ? new MessageFlagsBitField(this.options.flags).bitfield + : this.target.flags?.bitfield; + } + + if (isInteraction && this.options.ephemeral) { + flags |= MessageFlags.Ephemeral; + } + + let allowedMentions = + typeof this.options.allowedMentions === 'undefined' + ? this.target.client.options.allowedMentions + : this.options.allowedMentions; + + if (allowedMentions) { + allowedMentions = Util.cloneObject(allowedMentions); + allowedMentions.replied_user = allowedMentions.repliedUser; + delete allowedMentions.repliedUser; + } + + let message_reference; + if (typeof this.options.reply === 'object') { + const reference = this.options.reply.messageReference; + const message_id = this.isMessage ? reference.id ?? reference : this.target.messages.resolveId(reference); + if (message_id) { + message_reference = { + message_id, + fail_if_not_exists: this.options.reply.failIfNotExists ?? this.target.client.options.failIfNotExists, + }; + } + } + + const attachments = this.options.files?.map((file, index) => ({ + id: index.toString(), + description: file.description, + })); + if (Array.isArray(this.options.attachments)) { + this.options.attachments.push(...(attachments ?? [])); + } else { + this.options.attachments = attachments; + } + + this.body = { + content, + tts, + nonce, + embeds: this.options.embeds?.map(embed => + isJSONEncodable(embed) ? embed.toJSON() : this.target.client.options.jsonTransformer(embed), + ), + components, + username, + avatar_url: avatarURL, + allowed_mentions: + typeof content === 'undefined' && typeof message_reference === 'undefined' ? undefined : allowedMentions, + flags, + message_reference, + attachments: this.options.attachments, + sticker_ids: this.options.stickers?.map(sticker => sticker.id ?? sticker), + }; + return this; + } + + /** + * Resolves files. + * @returns {Promise} + */ + async resolveFiles() { + if (this.files) return this; + + this.files = await Promise.all(this.options.files?.map(file => this.constructor.resolveFile(file)) ?? []); + return this; + } + + /** + * Resolves a single file into an object sendable to the API. + * @param {BufferResolvable|Stream|FileOptions|MessageAttachment} fileLike Something that could be resolved to a file + * @returns {Promise} + */ + static async resolveFile(fileLike) { + let attachment; + let name; + + const findName = thing => { + if (typeof thing === 'string') { + return Util.basename(thing); + } + + if (thing.path) { + return Util.basename(thing.path); + } + + return 'file.jpg'; + }; + + const ownAttachment = + typeof fileLike === 'string' || fileLike instanceof Buffer || typeof fileLike.pipe === 'function'; + if (ownAttachment) { + attachment = fileLike; + name = findName(attachment); + } else { + attachment = fileLike.attachment; + name = fileLike.name ?? findName(attachment); + } + + const data = await DataResolver.resolveFile(attachment); + return { data, name }; + } + + /** + * Creates a {@link MessagePayload} from user-level arguments. + * @param {MessageTarget} target Target to send to + * @param {string|MessageOptions|WebhookMessageOptions} options Options or content to use + * @param {MessageOptions|WebhookMessageOptions} [extra={}] Extra options to add onto specified options + * @returns {MessagePayload} + */ + static create(target, options, extra = {}) { + return new this( + target, + typeof options !== 'object' || options === null ? { content: options, ...extra } : { ...options, ...extra }, + ); + } +} + +module.exports = MessagePayload; + +/** + * A target for a message. + * @typedef {TextChannel|DMChannel|User|GuildMember|Webhook|WebhookClient|Interaction|InteractionWebhook| + * Message|MessageManager} MessageTarget + */ + +/** + * @external APIMessage + * @see {@link https://discord.com/developers/docs/resources/channel#message-object} + */ + +/** + * @external RawFile + * @see {@link https://discord.js.org/#/docs/rest/main/typedef/RawFile} + */ diff --git a/src/structures/MessageReaction.js b/src/structures/MessageReaction.js new file mode 100644 index 00000000..729b284 --- /dev/null +++ b/src/structures/MessageReaction.js @@ -0,0 +1,128 @@ +'use strict'; + +const { Routes } = require('discord-api-types/v9'); +const GuildEmoji = require('./GuildEmoji'); +const ReactionEmoji = require('./ReactionEmoji'); +const ReactionUserManager = require('../managers/ReactionUserManager'); +const Util = require('../util/Util'); + +/** + * Represents a reaction to a message. + */ +class MessageReaction { + constructor(client, data, message) { + /** + * The client that instantiated this message reaction + * @name MessageReaction#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * The message that this reaction refers to + * @type {Message} + */ + this.message = message; + + /** + * Whether the client has given this reaction + * @type {boolean} + */ + this.me = data.me; + + /** + * A manager of the users that have given this reaction + * @type {ReactionUserManager} + */ + this.users = new ReactionUserManager(this, this.me ? [client.user] : []); + + this._emoji = new ReactionEmoji(this, data.emoji); + + this._patch(data); + } + + _patch(data) { + if ('count' in data) { + /** + * The number of people that have given the same reaction + * @type {?number} + */ + this.count ??= data.count; + } + } + + /** + * Removes all users from this reaction. + * @returns {Promise} + */ + async remove() { + await this.client.api.channels(this.message.channelId).messages(this.message.id).reactions(this._emoji.identifier).delete() + return this; + } + + /** + * The emoji of this reaction. Either a {@link GuildEmoji} object for known custom emojis, or a {@link ReactionEmoji} + * object which has fewer properties. Whatever the prototype of the emoji, it will still have + * `name`, `id`, `identifier` and `toString()` + * @type {GuildEmoji|ReactionEmoji} + * @readonly + */ + get emoji() { + if (this._emoji instanceof GuildEmoji) return this._emoji; + // Check to see if the emoji has become known to the client + if (this._emoji.id) { + const emojis = this.message.client.emojis.cache; + if (emojis.has(this._emoji.id)) { + const emoji = emojis.get(this._emoji.id); + this._emoji = emoji; + return emoji; + } + } + return this._emoji; + } + + /** + * Whether or not this reaction is a partial + * @type {boolean} + * @readonly + */ + get partial() { + return this.count === null; + } + + /** + * Fetch this reaction. + * @returns {Promise} + */ + async fetch() { + const message = await this.message.fetch(); + const existing = message.reactions.cache.get(this.emoji.id ?? this.emoji.name); + // The reaction won't get set when it has been completely removed + this._patch(existing ?? { count: 0 }); + return this; + } + + toJSON() { + return Util.flatten(this, { emoji: 'emojiId', message: 'messageId' }); + } + + _add(user) { + if (this.partial) return; + this.users.cache.set(user.id, user); + if (!this.me || user.id !== this.message.client.user.id || this.count === 0) this.count++; + this.me ||= user.id === this.message.client.user.id; + } + + _remove(user) { + if (this.partial) return; + this.users.cache.delete(user.id); + if (!this.me || user.id !== this.message.client.user.id) this.count--; + if (user.id === this.message.client.user.id) this.me = false; + if (this.count <= 0 && this.users.cache.size === 0) { + this.message.reactions.cache.delete(this.emoji.id ?? this.emoji.name); + } + } +} + +module.exports = MessageReaction; diff --git a/src/structures/NewsChannel.js b/src/structures/NewsChannel.js new file mode 100644 index 00000000..3833ef0 --- /dev/null +++ b/src/structures/NewsChannel.js @@ -0,0 +1,32 @@ +'use strict'; + +const { Routes } = require('discord-api-types/v9'); +const BaseGuildTextChannel = require('./BaseGuildTextChannel'); +const { Error } = require('../errors'); + +/** + * Represents a guild news channel on Discord. + * @extends {BaseGuildTextChannel} + */ +class NewsChannel extends BaseGuildTextChannel { + /** + * Adds the target to this channel's followers. + * @param {TextChannelResolvable} channel The channel where the webhook should be created + * @param {string} [reason] Reason for creating the webhook + * @returns {Promise} + * @example + * if (channel.type === ChannelType.GuildNews) { + * channel.addFollower('222197033908436994', 'Important announcements') + * .then(() => console.log('Added follower')) + * .catch(console.error); + * } + */ + async addFollower(channel, reason) { + const channelId = this.guild.channels.resolveId(channel); + if (!channelId) throw new Error('GUILD_CHANNEL_RESOLVE'); + await this.client.api.channels(this.id).followers.post({ body: { webhook_channel_id: channelId }, reason }); + return this; + } +} + +module.exports = NewsChannel; diff --git a/src/structures/OAuth2Guild.js b/src/structures/OAuth2Guild.js new file mode 100644 index 00000000..d5104ac --- /dev/null +++ b/src/structures/OAuth2Guild.js @@ -0,0 +1,28 @@ +'use strict'; + +const BaseGuild = require('./BaseGuild'); +const PermissionsBitField = require('../util/PermissionsBitField'); + +/** + * A partial guild received when using {@link GuildManager#fetch} to fetch multiple guilds. + * @extends {BaseGuild} + */ +class OAuth2Guild extends BaseGuild { + constructor(client, data) { + super(client, data); + + /** + * Whether the client user is the owner of the guild + * @type {boolean} + */ + this.owner = data.owner; + + /** + * The permissions that the client user has in this guild + * @type {Readonly} + */ + this.permissions = new PermissionsBitField(BigInt(data.permissions)).freeze(); + } +} + +module.exports = OAuth2Guild; diff --git a/src/structures/PartialGroupDMChannel.js b/src/structures/PartialGroupDMChannel.js new file mode 100644 index 00000000..f604e72 --- /dev/null +++ b/src/structures/PartialGroupDMChannel.js @@ -0,0 +1,57 @@ +'use strict'; + +const { Channel } = require('./Channel'); +const { Error } = require('../errors'); + +/** + * Represents a Partial Group DM Channel on Discord. + * @extends {Channel} + */ +class PartialGroupDMChannel extends Channel { + constructor(client, data) { + super(client, data); + + /** + * The name of this Group DM Channel + * @type {?string} + */ + this.name = data.name; + + /** + * The hash of the channel icon + * @type {?string} + */ + this.icon = data.icon; + + /** + * Recipient data received in a {@link PartialGroupDMChannel}. + * @typedef {Object} PartialRecipient + * @property {string} username The username of the recipient + */ + + /** + * The recipients of this Group DM Channel. + * @type {PartialRecipient[]} + */ + this.recipients = data.recipients; + } + + /** + * The URL to this channel's icon. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + iconURL(options = {}) { + return this.icon && this.client.rest.cdn.channelIcon(this.id, this.icon, options); + } + + delete() { + return Promise.reject(new Error('DELETE_GROUP_DM_CHANNEL')); + } + + fetch() { + return Promise.reject(new Error('FETCH_GROUP_DM_CHANNEL')); + } +} + +module.exports = PartialGroupDMChannel; diff --git a/src/structures/PermissionOverwrites.js b/src/structures/PermissionOverwrites.js new file mode 100644 index 00000000..01d141e --- /dev/null +++ b/src/structures/PermissionOverwrites.js @@ -0,0 +1,196 @@ +'use strict'; + +const { OverwriteType } = require('discord-api-types/v9'); +const Base = require('./Base'); +const { Role } = require('./Role'); +const { TypeError } = require('../errors'); +const PermissionsBitField = require('../util/PermissionsBitField'); + +/** + * Represents a permission overwrite for a role or member in a guild channel. + * @extends {Base} + */ +class PermissionOverwrites extends Base { + constructor(client, data, channel) { + super(client); + + /** + * The GuildChannel this overwrite is for + * @name PermissionOverwrites#channel + * @type {GuildChannel} + * @readonly + */ + Object.defineProperty(this, 'channel', { value: channel }); + + if (data) this._patch(data); + } + + _patch(data) { + /** + * The overwrite's id, either a {@link User} or a {@link Role} id + * @type {Snowflake} + */ + this.id = data.id; + + if ('type' in data) { + /** + * The type of this overwrite + * @type {OverwriteType} + */ + this.type = data.type; + } + + if ('deny' in data) { + /** + * The permissions that are denied for the user or role. + * @type {Readonly} + */ + this.deny = new PermissionsBitField(BigInt(data.deny)).freeze(); + } + + if ('allow' in data) { + /** + * The permissions that are allowed for the user or role. + * @type {Readonly} + */ + this.allow = new PermissionsBitField(BigInt(data.allow)).freeze(); + } + } + + /** + * Edits this Permission Overwrite. + * @param {PermissionOverwriteOptions} options The options for the update + * @param {string} [reason] Reason for creating/editing this overwrite + * @returns {Promise} + * @example + * // Update permission overwrites + * permissionOverwrites.edit({ + * SEND_MESSAGES: false + * }) + * .then(channel => console.log(channel.permissionOverwrites.get(message.author.id))) + * .catch(console.error); + */ + async edit(options, reason) { + await this.channel.permissionOverwrites.upsert(this.id, options, { type: this.type, reason }, this); + return this; + } + + /** + * Deletes this Permission Overwrite. + * @param {string} [reason] Reason for deleting this overwrite + * @returns {Promise} + */ + async delete(reason) { + await this.channel.permissionOverwrites.delete(this.id, reason); + return this; + } + + toJSON() { + return { + id: this.id, + type: this.type, + allow: this.allow, + deny: this.deny, + }; + } + + /** + * An object mapping permission flags to `true` (enabled), `null` (unset) or `false` (disabled). + * ```js + * { + * 'SendMessages': true, + * 'EmbedLinks': null, + * 'AttachFiles': false, + * } + * ``` + * @typedef {Object} PermissionOverwriteOptions + */ + + /** + * @typedef {Object} ResolvedOverwriteOptions + * @property {PermissionsBitField} allow The allowed permissions + * @property {PermissionsBitField} deny The denied permissions + */ + + /** + * Resolves bitfield permissions overwrites from an object. + * @param {PermissionOverwriteOptions} options The options for the update + * @param {ResolvedOverwriteOptions} initialPermissions The initial permissions + * @returns {ResolvedOverwriteOptions} + */ + static resolveOverwriteOptions(options, { allow, deny } = {}) { + allow = new PermissionsBitField(allow); + deny = new PermissionsBitField(deny); + + for (const [perm, value] of Object.entries(options)) { + if (value === true) { + allow.add(perm); + deny.remove(perm); + } else if (value === false) { + allow.remove(perm); + deny.add(perm); + } else if (value === null) { + allow.remove(perm); + deny.remove(perm); + } + } + + return { allow, deny }; + } + + /** + * The raw data for a permission overwrite + * @typedef {Object} RawOverwriteData + * @property {Snowflake} id The id of the {@link Role} or {@link User} this overwrite belongs to + * @property {string} allow The permissions to allow + * @property {string} deny The permissions to deny + * @property {number} type The type of this OverwriteData + */ + + /** + * Data that can be resolved into {@link RawOverwriteData}. This can be: + * * PermissionOverwrites + * * OverwriteData + * @typedef {PermissionOverwrites|OverwriteData} OverwriteResolvable + */ + + /** + * Data that can be used for a permission overwrite + * @typedef {Object} OverwriteData + * @property {GuildMemberResolvable|RoleResolvable} id Member or role this overwrite is for + * @property {PermissionResolvable} [allow] The permissions to allow + * @property {PermissionResolvable} [deny] The permissions to deny + * @property {OverwriteType} [type] The type of this OverwriteData + */ + + /** + * Resolves an overwrite into {@link RawOverwriteData}. + * @param {OverwriteResolvable} overwrite The overwrite-like data to resolve + * @param {Guild} [guild] The guild to resolve from + * @returns {RawOverwriteData} + */ + static resolve(overwrite, guild) { + if (overwrite instanceof this) return overwrite.toJSON(); + if (typeof overwrite.id === 'string' && overwrite.type in OverwriteType) { + return { + id: overwrite.id, + type: overwrite.type, + allow: PermissionsBitField.resolve(overwrite.allow ?? PermissionsBitField.defaultBit).toString(), + deny: PermissionsBitField.resolve(overwrite.deny ?? PermissionsBitField.defaultBit).toString(), + }; + } + + const userOrRole = guild.roles.resolve(overwrite.id) ?? guild.client.users.resolve(overwrite.id); + if (!userOrRole) throw new TypeError('INVALID_TYPE', 'parameter', 'User nor a Role'); + const type = userOrRole instanceof Role ? OverwriteType.Role : OverwriteType.Member; + + return { + id: userOrRole.id, + type, + allow: PermissionsBitField.resolve(overwrite.allow ?? PermissionsBitField.defaultBit).toString(), + deny: PermissionsBitField.resolve(overwrite.deny ?? PermissionsBitField.defaultBit).toString(), + }; + } +} + +module.exports = PermissionOverwrites; diff --git a/src/structures/Presence.js b/src/structures/Presence.js new file mode 100644 index 00000000..e7da989 --- /dev/null +++ b/src/structures/Presence.js @@ -0,0 +1,396 @@ +'use strict'; + +const Base = require('./Base'); +const { Emoji } = require('./Emoji'); +const ActivityFlagsBitField = require('../util/ActivityFlagsBitField'); +const Util = require('../util/Util'); + +/** + * Activity sent in a message. + * @typedef {Object} MessageActivity + * @property {string} [partyId] Id of the party represented in activity + * @property {number} [type] Type of activity sent + */ + +/** + * The status of this presence: + * * **`online`** - user is online + * * **`idle`** - user is AFK + * * **`offline`** - user is offline or invisible + * * **`dnd`** - user is in Do Not Disturb + * @typedef {string} PresenceStatus + */ + +/** + * The status of this presence: + * * **`online`** - user is online + * * **`idle`** - user is AFK + * * **`dnd`** - user is in Do Not Disturb + * @typedef {string} ClientPresenceStatus + */ + +/** + * Represents a user's presence. + * @extends {Base} + */ +class Presence extends Base { + constructor(client, data = {}) { + super(client); + + /** + * The presence's user id + * @type {Snowflake} + */ + this.userId = data.user.id; + + /** + * The guild this presence is in + * @type {?Guild} + */ + this.guild = data.guild ?? null; + + this._patch(data); + } + + /** + * The user of this presence + * @type {?User} + * @readonly + */ + get user() { + return this.client.users.resolve(this.userId); + } + + /** + * The member of this presence + * @type {?GuildMember} + * @readonly + */ + get member() { + return this.guild.members.resolve(this.userId); + } + + _patch(data) { + if ('status' in data) { + /** + * The status of this presence + * @type {PresenceStatus} + */ + this.status = data.status; + } else { + this.status ??= 'offline'; + } + + if ('activities' in data) { + /** + * The activities of this presence + * @type {Activity[]} + */ + this.activities = data.activities.map(activity => new Activity(this, activity)); + } else { + this.activities ??= []; + } + + if ('client_status' in data) { + /** + * The devices this presence is on + * @type {?Object} + * @property {?ClientPresenceStatus} web The current presence in the web application + * @property {?ClientPresenceStatus} mobile The current presence in the mobile application + * @property {?ClientPresenceStatus} desktop The current presence in the desktop application + */ + this.clientStatus = data.client_status; + } else { + this.clientStatus ??= null; + } + + return this; + } + + _clone() { + const clone = Object.assign(Object.create(this), this); + clone.activities = this.activities.map(activity => activity._clone()); + return clone; + } + + /** + * Whether this presence is equal to another. + * @param {Presence} presence The presence to compare with + * @returns {boolean} + */ + equals(presence) { + return ( + this === presence || + (presence && + this.status === presence.status && + this.activities.length === presence.activities.length && + this.activities.every((activity, index) => activity.equals(presence.activities[index])) && + this.clientStatus?.web === presence.clientStatus?.web && + this.clientStatus?.mobile === presence.clientStatus?.mobile && + this.clientStatus?.desktop === presence.clientStatus?.desktop) + ); + } + + toJSON() { + return Util.flatten(this); + } +} + +/** + * The platform of this activity: + * * **`desktop`** + * * **`samsung`** - playing on Samsung Galaxy + * * **`xbox`** - playing on Xbox Live + * @typedef {string} ActivityPlatform + */ + +/** + * Represents an activity that is part of a user's presence. + */ +class Activity { + constructor(presence, data) { + Object.defineProperty(this, 'presence', { value: presence }); + + /** + * The activity's id + * @type {string} + */ + this.id = data.id; + + /** + * The activity's name + * @type {string} + */ + this.name = data.name; + + /** + * The activity status's type + * @type {ActivityType} + */ + this.type = data.type; + + /** + * If the activity is being streamed, a link to the stream + * @type {?string} + */ + this.url = data.url ?? null; + + /** + * Details about the activity + * @type {?string} + */ + this.details = data.details ?? null; + + /** + * State of the activity + * @type {?string} + */ + this.state = data.state ?? null; + + /** + * The id of the application associated with this activity + * @type {?Snowflake} + */ + this.applicationId = data.application_id ?? null; + + /** + * Represents timestamps of an activity + * @typedef {Object} ActivityTimestamps + * @property {?Date} start When the activity started + * @property {?Date} end When the activity will end + */ + + /** + * Timestamps for the activity + * @type {?ActivityTimestamps} + */ + this.timestamps = data.timestamps + ? { + start: data.timestamps.start ? new Date(Number(data.timestamps.start)) : null, + end: data.timestamps.end ? new Date(Number(data.timestamps.end)) : null, + } + : null; + + /** + * The Spotify song's id + * @type {?string} + */ + this.syncId = data.sync_id ?? null; + + /** + * The platform the game is being played on + * @type {?ActivityPlatform} + */ + this.platform = data.platform ?? null; + + /** + * Represents a party of an activity + * @typedef {Object} ActivityParty + * @property {?string} id The party's id + * @property {number[]} size Size of the party as `[current, max]` + */ + + /** + * Party of the activity + * @type {?ActivityParty} + */ + this.party = data.party ?? null; + + /** + * Assets for rich presence + * @type {?RichPresenceAssets} + */ + this.assets = data.assets ? new RichPresenceAssets(this, data.assets) : null; + + /** + * Flags that describe the activity + * @type {Readonly} + */ + this.flags = new ActivityFlagsBitField(data.flags).freeze(); + + /** + * Emoji for a custom activity + * @type {?Emoji} + */ + this.emoji = data.emoji ? new Emoji(presence.client, data.emoji) : null; + + /** + * The game's or Spotify session's id + * @type {?string} + */ + this.sessionId = data.session_id ?? null; + + /** + * The labels of the buttons of this rich presence + * @type {string[]} + */ + this.buttons = data.buttons ?? []; + + /** + * Creation date of the activity + * @type {number} + */ + this.createdTimestamp = Date.parse(data.created_at); + } + + /** + * Whether this activity is equal to another activity. + * @param {Activity} activity The activity to compare with + * @returns {boolean} + */ + equals(activity) { + return ( + this === activity || + (activity && + this.name === activity.name && + this.type === activity.type && + this.url === activity.url && + this.state === activity.state && + this.details === activity.details) + ); + } + + /** + * The time the activity was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * When concatenated with a string, this automatically returns the activities' name instead of the Activity object. + * @returns {string} + */ + toString() { + return this.name; + } + + _clone() { + return Object.assign(Object.create(this), this); + } +} + +/** + * Assets for a rich presence + */ +class RichPresenceAssets { + constructor(activity, assets) { + Object.defineProperty(this, 'activity', { value: activity }); + + /** + * Hover text for the large image + * @type {?string} + */ + this.largeText = assets.large_text ?? null; + + /** + * Hover text for the small image + * @type {?string} + */ + this.smallText = assets.small_text ?? null; + + /** + * The large image asset's id + * @type {?Snowflake} + */ + this.largeImage = assets.large_image ?? null; + + /** + * The small image asset's id + * @type {?Snowflake} + */ + this.smallImage = assets.small_image ?? null; + } + + /** + * Gets the URL of the small image asset + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + smallImageURL(options = {}) { + if (!this.smallImage) return null; + if (this.smallImage.includes(':')) { + const [platform, id] = this.smallImage.split(':'); + switch (platform) { + case 'mp': + return `https://media.discordapp.net/${id}`; + default: + return null; + } + } + + return this.activity.presence.client.rest.cdn.appAsset(this.activity.applicationId, this.smallImage, options); + } + + /** + * Gets the URL of the large image asset + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + largeImageURL(options = {}) { + if (!this.largeImage) return null; + if (this.largeImage.includes(':')) { + const [platform, id] = this.largeImage.split(':'); + switch (platform) { + case 'mp': + return `https://media.discordapp.net/${id}`; + case 'spotify': + return `https://i.scdn.co/image/${id}`; + case 'youtube': + return `https://i.ytimg.com/vi/${id}/hqdefault_live.jpg`; + case 'twitch': + return `https://static-cdn.jtvnw.net/previews-ttv/live_user_${id}.png`; + default: + return null; + } + } + + return this.activity.presence.client.rest.cdn.appAsset(this.activity.applicationId, this.largeImage, options); + } +} + +exports.Presence = Presence; +exports.Activity = Activity; +exports.RichPresenceAssets = RichPresenceAssets; diff --git a/src/structures/ReactionCollector.js b/src/structures/ReactionCollector.js new file mode 100644 index 00000000..0c0b9e0 --- /dev/null +++ b/src/structures/ReactionCollector.js @@ -0,0 +1,229 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Collector = require('./interfaces/Collector'); +const Events = require('../util/Events'); + +/** + * @typedef {CollectorOptions} ReactionCollectorOptions + * @property {number} max The maximum total amount of reactions to collect + * @property {number} maxEmojis The maximum number of emojis to collect + * @property {number} maxUsers The maximum number of users to react + */ + +/** + * Collects reactions on messages. + * Will automatically stop if the message ({@link Client#event:messageDelete messageDelete} or + * {@link Client#event:messageDeleteBulk messageDeleteBulk}), + * channel ({@link Client#event:channelDelete channelDelete}), + * thread ({@link Client#event:threadDelete threadDelete}), or + * guild ({@link Client#event:guildDelete guildDelete}) is deleted. + * @extends {Collector} + */ +class ReactionCollector extends Collector { + /** + * @param {Message} message The message upon which to collect reactions + * @param {ReactionCollectorOptions} [options={}] The options to apply to this collector + */ + constructor(message, options = {}) { + super(message.client, options); + + /** + * The message upon which to collect reactions + * @type {Message} + */ + this.message = message; + + /** + * The users that have reacted to this message + * @type {Collection} + */ + this.users = new Collection(); + + /** + * The total number of reactions collected + * @type {number} + */ + this.total = 0; + + this.empty = this.empty.bind(this); + this._handleChannelDeletion = this._handleChannelDeletion.bind(this); + this._handleThreadDeletion = this._handleThreadDeletion.bind(this); + this._handleGuildDeletion = this._handleGuildDeletion.bind(this); + this._handleMessageDeletion = this._handleMessageDeletion.bind(this); + + const bulkDeleteListener = messages => { + if (messages.has(this.message.id)) this.stop('messageDelete'); + }; + + this.client.incrementMaxListeners(); + this.client.on(Events.MessageReactionAdd, this.handleCollect); + this.client.on(Events.MessageReactionRemove, this.handleDispose); + this.client.on(Events.MessageReactionRemoveAll, this.empty); + this.client.on(Events.MessageDelete, this._handleMessageDeletion); + this.client.on(Events.MessageBulkDelete, bulkDeleteListener); + this.client.on(Events.ChannelDelete, this._handleChannelDeletion); + this.client.on(Events.ThreadDelete, this._handleThreadDeletion); + this.client.on(Events.GuildDelete, this._handleGuildDeletion); + + this.once('end', () => { + this.client.removeListener(Events.MessageReactionAdd, this.handleCollect); + this.client.removeListener(Events.MessageReactionRemove, this.handleDispose); + this.client.removeListener(Events.MessageReactionRemoveAll, this.empty); + this.client.removeListener(Events.MessageDelete, this._handleMessageDeletion); + this.client.removeListener(Events.MessageBulkDelete, bulkDeleteListener); + this.client.removeListener(Events.ChannelDelete, this._handleChannelDeletion); + this.client.removeListener(Events.ThreadDelete, this._handleThreadDeletion); + this.client.removeListener(Events.GuildDelete, this._handleGuildDeletion); + this.client.decrementMaxListeners(); + }); + + this.on('collect', (reaction, user) => { + /** + * Emitted whenever a reaction is newly created on a message. Will emit only when a new reaction is + * added to the message, as opposed to {@link Collector#collect} which will + * be emitted even when a reaction has already been added to the message. + * @event ReactionCollector#create + * @param {MessageReaction} reaction The reaction that was added + * @param {User} user The user that added the reaction + */ + if (reaction.count === 1) { + this.emit('create', reaction, user); + } + this.total++; + this.users.set(user.id, user); + }); + + this.on('remove', (reaction, user) => { + this.total--; + if (!this.collected.some(r => r.users.cache.has(user.id))) this.users.delete(user.id); + }); + } + + /** + * Handles an incoming reaction for possible collection. + * @param {MessageReaction} reaction The reaction to possibly collect + * @param {User} user The user that added the reaction + * @returns {?(Snowflake|string)} + * @private + */ + collect(reaction) { + /** + * Emitted whenever a reaction is collected. + * @event ReactionCollector#collect + * @param {MessageReaction} reaction The reaction that was collected + * @param {User} user The user that added the reaction + */ + if (reaction.message.id !== this.message.id) return null; + + return ReactionCollector.key(reaction); + } + + /** + * Handles a reaction deletion for possible disposal. + * @param {MessageReaction} reaction The reaction to possibly dispose of + * @param {User} user The user that removed the reaction + * @returns {?(Snowflake|string)} + */ + dispose(reaction, user) { + /** + * Emitted when the reaction had all the users removed and the `dispose` option is set to true. + * @event ReactionCollector#dispose + * @param {MessageReaction} reaction The reaction that was disposed of + * @param {User} user The user that removed the reaction + */ + if (reaction.message.id !== this.message.id) return null; + + /** + * Emitted when the reaction had one user removed and the `dispose` option is set to true. + * @event ReactionCollector#remove + * @param {MessageReaction} reaction The reaction that was removed + * @param {User} user The user that removed the reaction + */ + if (this.collected.has(ReactionCollector.key(reaction)) && this.users.has(user.id)) { + this.emit('remove', reaction, user); + } + return reaction.count ? null : ReactionCollector.key(reaction); + } + + /** + * Empties this reaction collector. + */ + empty() { + this.total = 0; + this.collected.clear(); + this.users.clear(); + this.checkEnd(); + } + + /** + * The reason this collector has ended with, or null if it hasn't ended yet + * @type {?string} + * @readonly + */ + get endReason() { + if (this.options.max && this.total >= this.options.max) return 'limit'; + if (this.options.maxEmojis && this.collected.size >= this.options.maxEmojis) return 'emojiLimit'; + if (this.options.maxUsers && this.users.size >= this.options.maxUsers) return 'userLimit'; + return null; + } + + /** + * Handles checking if the message has been deleted, and if so, stops the collector with the reason 'messageDelete'. + * @private + * @param {Message} message The message that was deleted + * @returns {void} + */ + _handleMessageDeletion(message) { + if (message.id === this.message.id) { + this.stop('messageDelete'); + } + } + + /** + * Handles checking if the channel has been deleted, and if so, stops the collector with the reason 'channelDelete'. + * @private + * @param {GuildChannel} channel The channel that was deleted + * @returns {void} + */ + _handleChannelDeletion(channel) { + if (channel.id === this.message.channelId || channel.threads?.cache.has(this.message.channelId)) { + this.stop('channelDelete'); + } + } + + /** + * Handles checking if the thread has been deleted, and if so, stops the collector with the reason 'threadDelete'. + * @private + * @param {ThreadChannel} thread The thread that was deleted + * @returns {void} + */ + _handleThreadDeletion(thread) { + if (thread.id === this.message.channelId) { + this.stop('threadDelete'); + } + } + + /** + * Handles checking if the guild has been deleted, and if so, stops the collector with the reason 'guildDelete'. + * @private + * @param {Guild} guild The guild that was deleted + * @returns {void} + */ + _handleGuildDeletion(guild) { + if (guild.id === this.message.guild?.id) { + this.stop('guildDelete'); + } + } + + /** + * Gets the collector key for a reaction. + * @param {MessageReaction} reaction The message reaction to get the key for + * @returns {Snowflake|string} + */ + static key(reaction) { + return reaction.emoji.id ?? reaction.emoji.name; + } +} + +module.exports = ReactionCollector; diff --git a/src/structures/ReactionEmoji.js b/src/structures/ReactionEmoji.js new file mode 100644 index 00000000..bcd2470 --- /dev/null +++ b/src/structures/ReactionEmoji.js @@ -0,0 +1,31 @@ +'use strict'; + +const { Emoji } = require('./Emoji'); +const Util = require('../util/Util'); + +/** + * Represents a limited emoji set used for both custom and unicode emojis. Custom emojis + * will use this class opposed to the Emoji class when the client doesn't know enough + * information about them. + * @extends {Emoji} + */ +class ReactionEmoji extends Emoji { + constructor(reaction, emoji) { + super(reaction.message.client, emoji); + /** + * The message reaction this emoji refers to + * @type {MessageReaction} + */ + this.reaction = reaction; + } + + toJSON() { + return Util.flatten(this, { identifier: true }); + } + + valueOf() { + return this.id; + } +} + +module.exports = ReactionEmoji; diff --git a/src/structures/Role.js b/src/structures/Role.js new file mode 100644 index 00000000..5325973 --- /dev/null +++ b/src/structures/Role.js @@ -0,0 +1,436 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { PermissionFlagsBits } = require('discord-api-types/v9'); +const Base = require('./Base'); +const { Error } = require('../errors'); +const PermissionsBitField = require('../util/PermissionsBitField'); + +/** + * Represents a role on Discord. + * @extends {Base} + */ +class Role extends Base { + constructor(client, data, guild) { + super(client); + + /** + * The guild that the role belongs to + * @type {Guild} + */ + this.guild = guild; + + /** + * The icon hash of the role + * @type {?string} + */ + this.icon = null; + + /** + * The unicode emoji for the role + * @type {?string} + */ + this.unicodeEmoji = null; + + if (data) this._patch(data); + } + + _patch(data) { + /** + * The role's id (unique to the guild it is part of) + * @type {Snowflake} + */ + this.id = data.id; + if ('name' in data) { + /** + * The name of the role + * @type {string} + */ + this.name = data.name; + } + + if ('color' in data) { + /** + * The base 10 color of the role + * @type {number} + */ + this.color = data.color; + } + + if ('hoist' in data) { + /** + * If true, users that are part of this role will appear in a separate category in the users list + * @type {boolean} + */ + this.hoist = data.hoist; + } + + if ('position' in data) { + /** + * The raw position of the role from the API + * @type {number} + */ + this.rawPosition = data.position; + } + + if ('permissions' in data) { + /** + * The permissions of the role + * @type {Readonly} + */ + this.permissions = new PermissionsBitField(BigInt(data.permissions)).freeze(); + } + + if ('managed' in data) { + /** + * Whether or not the role is managed by an external service + * @type {boolean} + */ + this.managed = data.managed; + } + + if ('mentionable' in data) { + /** + * Whether or not the role can be mentioned by anyone + * @type {boolean} + */ + this.mentionable = data.mentionable; + } + + if ('icon' in data) this.icon = data.icon; + + if ('unicode_emoji' in data) this.unicodeEmoji = data.unicode_emoji; + + /** + * The tags this role has + * @type {?Object} + * @property {Snowflake} [botId] The id of the bot this role belongs to + * @property {Snowflake|string} [integrationId] The id of the integration this role belongs to + * @property {true} [premiumSubscriberRole] Whether this is the guild's premium subscription role + */ + this.tags = data.tags ? {} : null; + if (data.tags) { + if ('bot_id' in data.tags) { + this.tags.botId = data.tags.bot_id; + } + if ('integration_id' in data.tags) { + this.tags.integrationId = data.tags.integration_id; + } + if ('premium_subscriber' in data.tags) { + this.tags.premiumSubscriberRole = true; + } + } + } + + /** + * The timestamp the role was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the role was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The hexadecimal version of the role color, with a leading hashtag + * @type {string} + * @readonly + */ + get hexColor() { + return `#${this.color.toString(16).padStart(6, '0')}`; + } + + /** + * The cached guild members that have this role + * @type {Collection} + * @readonly + */ + get members() { + return this.guild.members.cache.filter(m => m.roles.cache.has(this.id)); + } + + /** + * Whether the role is editable by the client user + * @type {boolean} + * @readonly + */ + get editable() { + if (this.managed) return false; + const clientMember = this.guild.members.resolve(this.client.user); + if (!clientMember.permissions.has(PermissionFlagsBits.ManageRoles)) return false; + return clientMember.roles.highest.comparePositionTo(this) > 0; + } + + /** + * The position of the role in the role manager + * @type {number} + * @readonly + */ + get position() { + const sorted = this.guild._sortedRoles(); + return [...sorted.values()].indexOf(sorted.get(this.id)); + } + + /** + * Compares this role's position to another role's. + * @param {RoleResolvable} role Role to compare to this one + * @returns {number} Negative number if this role's position is lower (other role's is higher), + * positive number if this one is higher (other's is lower), 0 if equal + */ + comparePositionTo(role) { + return this.guild.roles.comparePositions(this, role); + } + + /** + * The data for a role. + * @typedef {Object} RoleData + * @property {string} [name] The name of the role + * @property {ColorResolvable} [color] The color of the role, either a hex string or a base 10 number + * @property {boolean} [hoist] Whether or not the role should be hoisted + * @property {number} [position] The position of the role + * @property {PermissionResolvable} [permissions] The permissions of the role + * @property {boolean} [mentionable] Whether or not the role should be mentionable + * @property {?(BufferResolvable|Base64Resolvable|EmojiResolvable)} [icon] The icon for the role + * The `EmojiResolvable` should belong to the same guild as the role. + * If not, pass the emoji's URL directly + * @property {?string} [unicodeEmoji] The unicode emoji for the role + */ + + /** + * Edits the role. + * @param {RoleData} data The new data for the role + * @param {string} [reason] Reason for editing this role + * @returns {Promise} + * @example + * // Edit a role + * role.edit({ name: 'new role' }) + * .then(updated => console.log(`Edited role name to ${updated.name}`)) + * .catch(console.error); + */ + edit(data, reason) { + return this.guild.roles.edit(this, data, reason); + } + + /** + * Returns `channel.permissionsFor(role)`. Returns permissions for a role in a guild channel, + * taking into account permission overwrites. + * @param {GuildChannel|Snowflake} channel The guild channel to use as context + * @param {boolean} [checkAdmin=true] Whether having `ADMINISTRATOR` will return all permissions + * @returns {Readonly} + */ + permissionsIn(channel, checkAdmin = true) { + channel = this.guild.channels.resolve(channel); + if (!channel) throw new Error('GUILD_CHANNEL_RESOLVE'); + return channel.rolePermissions(this, checkAdmin); + } + + /** + * Sets a new name for the role. + * @param {string} name The new name of the role + * @param {string} [reason] Reason for changing the role's name + * @returns {Promise} + * @example + * // Set the name of the role + * role.setName('new role') + * .then(updated => console.log(`Updated role name to ${updated.name}`)) + * .catch(console.error); + */ + setName(name, reason) { + return this.edit({ name }, reason); + } + + /** + * Sets a new color for the role. + * @param {ColorResolvable} color The color of the role + * @param {string} [reason] Reason for changing the role's color + * @returns {Promise} + * @example + * // Set the color of a role + * role.setColor('#FF0000') + * .then(updated => console.log(`Set color of role to ${updated.color}`)) + * .catch(console.error); + */ + setColor(color, reason) { + return this.edit({ color }, reason); + } + + /** + * Sets whether or not the role should be hoisted. + * @param {boolean} [hoist=true] Whether or not to hoist the role + * @param {string} [reason] Reason for setting whether or not the role should be hoisted + * @returns {Promise} + * @example + * // Set the hoist of the role + * role.setHoist(true) + * .then(updated => console.log(`Role hoisted: ${updated.hoist}`)) + * .catch(console.error); + */ + setHoist(hoist = true, reason) { + return this.edit({ hoist }, reason); + } + + /** + * Sets the permissions of the role. + * @param {PermissionResolvable} permissions The permissions of the role + * @param {string} [reason] Reason for changing the role's permissions + * @returns {Promise} + * @example + * // Set the permissions of the role + * role.setPermissions([PermissionFlagsBits.KickMembers, PermissionFlagsBits.BanMembers]) + * .then(updated => console.log(`Updated permissions to ${updated.permissions.bitfield}`)) + * .catch(console.error); + * @example + * // Remove all permissions from a role + * role.setPermissions(0n) + * .then(updated => console.log(`Updated permissions to ${updated.permissions.bitfield}`)) + * .catch(console.error); + */ + setPermissions(permissions, reason) { + return this.edit({ permissions }, reason); + } + + /** + * Sets whether this role is mentionable. + * @param {boolean} [mentionable=true] Whether this role should be mentionable + * @param {string} [reason] Reason for setting whether or not this role should be mentionable + * @returns {Promise} + * @example + * // Make the role mentionable + * role.setMentionable(true) + * .then(updated => console.log(`Role updated ${updated.name}`)) + * .catch(console.error); + */ + setMentionable(mentionable = true, reason) { + return this.edit({ mentionable }, reason); + } + + /** + * Sets a new icon for the role. + * @param {?(BufferResolvable|Base64Resolvable|EmojiResolvable)} icon The icon for the role + * The `EmojiResolvable` should belong to the same guild as the role. + * If not, pass the emoji's URL directly + * @param {string} [reason] Reason for changing the role's icon + * @returns {Promise} + */ + setIcon(icon, reason) { + return this.edit({ icon }, reason); + } + + /** + * Sets a new unicode emoji for the role. + * @param {?string} unicodeEmoji The new unicode emoji for the role + * @param {string} [reason] Reason for changing the role's unicode emoji + * @returns {Promise} + * @example + * // Set a new unicode emoji for the role + * role.setUnicodeEmoji('🤖') + * .then(updated => console.log(`Set unicode emoji for the role to ${updated.unicodeEmoji}`)) + * .catch(console.error); + */ + setUnicodeEmoji(unicodeEmoji, reason) { + return this.edit({ unicodeEmoji }, reason); + } + + /** + * Options used to set the position of a role. + * @typedef {Object} SetRolePositionOptions + * @property {boolean} [relative=false] Whether to change the position relative to its current value or not + * @property {string} [reason] The reason for changing the position + */ + + /** + * Sets the new position of the role. + * @param {number} position The new position for the role + * @param {SetRolePositionOptions} [options] Options for setting the position + * @returns {Promise} + * @example + * // Set the position of the role + * role.setPosition(1) + * .then(updated => console.log(`Role position: ${updated.position}`)) + * .catch(console.error); + */ + setPosition(position, options = {}) { + return this.guild.roles.setPosition(this, position, options); + } + + /** + * Deletes the role. + * @param {string} [reason] Reason for deleting this role + * @returns {Promise} + * @example + * // Delete a role + * role.delete('The role needed to go') + * .then(deleted => console.log(`Deleted role ${deleted.name}`)) + * .catch(console.error); + */ + async delete(reason) { + await this.guild.roles.delete(this.id, reason); + return this; + } + + /** + * A link to the role's icon + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + iconURL(options = {}) { + return this.icon && this.client.rest.cdn.roleIcon(this.id, this.icon, options); + } + + /** + * Whether this role equals another role. It compares all properties, so for most operations + * it is advisable to just compare `role.id === role2.id` as it is much faster and is often + * what most users need. + * @param {Role} role Role to compare with + * @returns {boolean} + */ + equals(role) { + return ( + role && + this.id === role.id && + this.name === role.name && + this.color === role.color && + this.hoist === role.hoist && + this.position === role.position && + this.permissions.bitfield === role.permissions.bitfield && + this.managed === role.managed && + this.icon === role.icon && + this.unicodeEmoji === role.unicodeEmoji + ); + } + + /** + * When concatenated with a string, this automatically returns the role's mention instead of the Role object. + * @returns {string} + * @example + * // Logs: Role: <@&123456789012345678> + * console.log(`Role: ${role}`); + */ + toString() { + if (this.id === this.guild.id) return '@everyone'; + return `<@&${this.id}>`; + } + + toJSON() { + return { + ...super.toJSON({ createdTimestamp: true }), + permissions: this.permissions.toJSON(), + }; + } +} + +exports.Role = Role; + +/** + * @external APIRole + * @see {@link https://discord.com/developers/docs/topics/permissions#role-object} + */ diff --git a/src/structures/SelectMenuComponent.js b/src/structures/SelectMenuComponent.js new file mode 100644 index 00000000..7758962 --- /dev/null +++ b/src/structures/SelectMenuComponent.js @@ -0,0 +1,12 @@ +'use strict'; + +const { SelectMenuComponent: BuildersSelectMenuComponent } = require('@discordjs/builders'); +const Transformers = require('../util/Transformers'); + +class SelectMenuComponent extends BuildersSelectMenuComponent { + constructor(data) { + super(Transformers.toSnakeCase(data)); + } +} + +module.exports = SelectMenuComponent; diff --git a/src/structures/SelectMenuInteraction.js b/src/structures/SelectMenuInteraction.js new file mode 100644 index 00000000..42ef0c1 --- /dev/null +++ b/src/structures/SelectMenuInteraction.js @@ -0,0 +1,21 @@ +'use strict'; + +const MessageComponentInteraction = require('./MessageComponentInteraction'); + +/** + * Represents a select menu interaction. + * @extends {MessageComponentInteraction} + */ +class SelectMenuInteraction extends MessageComponentInteraction { + constructor(client, data) { + super(client, data); + + /** + * The values selected, if the component which was interacted with was a select menu + * @type {string[]} + */ + this.values = data.data.values ?? []; + } +} + +module.exports = SelectMenuInteraction; diff --git a/src/structures/StageChannel.js b/src/structures/StageChannel.js new file mode 100644 index 00000000..f5fb4d8 --- /dev/null +++ b/src/structures/StageChannel.js @@ -0,0 +1,69 @@ +'use strict'; + +const BaseGuildVoiceChannel = require('./BaseGuildVoiceChannel'); + +/** + * Represents a guild stage channel on Discord. + * @extends {BaseGuildVoiceChannel} + */ +class StageChannel extends BaseGuildVoiceChannel { + _patch(data) { + super._patch(data); + + if ('topic' in data) { + /** + * The topic of the stage channel + * @type {?string} + */ + this.topic = data.topic; + } + } + + /** + * The stage instance of this stage channel, if it exists + * @type {?StageInstance} + * @readonly + */ + get stageInstance() { + return this.guild.stageInstances.cache.find(stageInstance => stageInstance.channelId === this.id) ?? null; + } + + /** + * Creates a stage instance associated with this stage channel. + * @param {StageInstanceCreateOptions} options The options to create the stage instance + * @returns {Promise} + */ + createStageInstance(options) { + return this.guild.stageInstances.create(this.id, options); + } + + /** + * Sets a new topic for the guild channel. + * @param {?string} topic The new topic for the guild channel + * @param {string} [reason] Reason for changing the guild channel's topic + * @returns {Promise} + * @example + * // Set a new channel topic + * channel.setTopic('needs more rate limiting') + * .then(newChannel => console.log(`Channel's new topic is ${newChannel.topic}`)) + * .catch(console.error); + */ + setTopic(topic, reason) { + return this.edit({ topic }, reason); + } + + /** + * Sets the RTC region of the channel. + * @name StageChannel#setRTCRegion + * @param {?string} region The new region of the channel. Set to `null` to remove a specific region for the channel + * @returns {Promise} + * @example + * // Set the RTC region to europe + * stageChannel.setRTCRegion('europe'); + * @example + * // Remove a fixed region for this channel - let Discord decide automatically + * stageChannel.setRTCRegion(null); + */ +} + +module.exports = StageChannel; diff --git a/src/structures/StageInstance.js b/src/structures/StageInstance.js new file mode 100644 index 00000000..883ee54 --- /dev/null +++ b/src/structures/StageInstance.js @@ -0,0 +1,148 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const Base = require('./Base'); + +/** + * Represents a stage instance. + * @extends {Base} + */ +class StageInstance extends Base { + constructor(client, data) { + super(client); + + /** + * The stage instance's id + * @type {Snowflake} + */ + this.id = data.id; + + this._patch(data); + } + + _patch(data) { + if ('guild_id' in data) { + /** + * The id of the guild associated with the stage channel + * @type {Snowflake} + */ + this.guildId = data.guild_id; + } + + if ('channel_id' in data) { + /** + * The id of the channel associated with the stage channel + * @type {Snowflake} + */ + this.channelId = data.channel_id; + } + + if ('topic' in data) { + /** + * The topic of the stage instance + * @type {string} + */ + this.topic = data.topic; + } + + if ('privacy_level' in data) { + /** + * The privacy level of the stage instance + * @type {StageInstancePrivacyLevel} + */ + this.privacyLevel = data.privacy_level; + } + + if ('discoverable_disabled' in data) { + /** + * Whether or not stage discovery is disabled + * @type {?boolean} + * @deprecated See https://github.com/discord/discord-api-docs/pull/4296 for more information + */ + this.discoverableDisabled = data.discoverable_disabled; + } else { + this.discoverableDisabled ??= null; + } + } + + /** + * The stage channel associated with this stage instance + * @type {?StageChannel} + * @readonly + */ + get channel() { + return this.client.channels.resolve(this.channelId); + } + + /** + * The guild this stage instance belongs to + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.resolve(this.guildId); + } + + /** + * Edits this stage instance. + * @param {StageInstanceEditOptions} options The options to edit the stage instance + * @returns {Promise} + * @example + * // Edit a stage instance + * stageInstance.edit({ topic: 'new topic' }) + * .then(stageInstance => console.log(stageInstance)) + * .catch(console.error) + */ + edit(options) { + return this.guild.stageInstances.edit(this.channelId, options); + } + + /** + * Deletes this stage instance. + * @returns {Promise} + * @example + * // Delete a stage instance + * stageInstance.delete() + * .then(stageInstance => console.log(stageInstance)) + * .catch(console.error); + */ + async delete() { + await this.guild.stageInstances.delete(this.channelId); + const clone = this._clone(); + return clone; + } + + /** + * Sets the topic of this stage instance. + * @param {string} topic The topic for the stage instance + * @returns {Promise} + * @example + * // Set topic of a stage instance + * stageInstance.setTopic('new topic') + * .then(stageInstance => console.log(`Set the topic to: ${stageInstance.topic}`)) + * .catch(console.error); + */ + setTopic(topic) { + return this.guild.stageInstances.edit(this.channelId, { topic }); + } + + /** + * The timestamp this stage instances was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time this stage instance was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } +} + +exports.StageInstance = StageInstance; diff --git a/src/structures/Sticker.js b/src/structures/Sticker.js new file mode 100644 index 00000000..c58fafd --- /dev/null +++ b/src/structures/Sticker.js @@ -0,0 +1,271 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { Routes, StickerFormatType } = require('discord-api-types/v9'); +const Base = require('./Base'); + +/** + * Represents a Sticker. + * @extends {Base} + */ +class Sticker extends Base { + constructor(client, sticker) { + super(client); + + this._patch(sticker); + } + + _patch(sticker) { + /** + * The sticker's id + * @type {Snowflake} + */ + this.id = sticker.id; + + if ('description' in sticker) { + /** + * The description of the sticker + * @type {?string} + */ + this.description = sticker.description; + } else { + this.description ??= null; + } + + if ('type' in sticker) { + /** + * The type of the sticker + * @type {?StickerType} + */ + this.type = sticker.type; + } else { + this.type ??= null; + } + + if ('format_type' in sticker) { + /** + * The format of the sticker + * @type {StickerFormatType} + */ + this.format = sticker.format_type; + } + + if ('name' in sticker) { + /** + * The name of the sticker + * @type {string} + */ + this.name = sticker.name; + } + + if ('pack_id' in sticker) { + /** + * The id of the pack the sticker is from, for standard stickers + * @type {?Snowflake} + */ + this.packId = sticker.pack_id; + } else { + this.packId ??= null; + } + + if ('tags' in sticker) { + /** + * An array of tags for the sticker + * @type {?string[]} + */ + this.tags = sticker.tags.split(', '); + } else { + this.tags ??= null; + } + + if ('available' in sticker) { + /** + * Whether or not the guild sticker is available + * @type {?boolean} + */ + this.available = sticker.available; + } else { + this.available ??= null; + } + + if ('guild_id' in sticker) { + /** + * The id of the guild that owns this sticker + * @type {?Snowflake} + */ + this.guildId = sticker.guild_id; + } else { + this.guildId ??= null; + } + + if ('user' in sticker) { + /** + * The user that uploaded the guild sticker + * @type {?User} + */ + this.user = this.client.users._add(sticker.user); + } else { + this.user ??= null; + } + + if ('sort_value' in sticker) { + /** + * The standard sticker's sort order within its pack + * @type {?number} + */ + this.sortValue = sticker.sort_value; + } else { + this.sortValue ??= null; + } + } + + /** + * The timestamp the sticker was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the sticker was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * Whether this sticker is partial + * @type {boolean} + * @readonly + */ + get partial() { + return !this.type; + } + + /** + * The guild that owns this sticker + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.resolve(this.guildId); + } + + /** + * A link to the sticker + * If the sticker's format is {@link StickerFormatType.Lottie}, it returns + * the URL of the Lottie JSON file. + * @type {string} + * @readonly + */ + get url() { + return this.client.rest.cdn.sticker(this.id, this.format === StickerFormatType.Lottie ? 'json' : 'png'); + } + + /** + * Fetches this sticker. + * @returns {Promise} + */ + async fetch() { + const data = await this.client.api.stickers(this.id).get(); + this._patch(data); + return this; + } + + /** + * Fetches the pack this sticker is part of from Discord, if this is a Nitro sticker. + * @returns {Promise} + */ + async fetchPack() { + return (this.packId && (await this.client.fetchPremiumStickerPacks()).get(this.packId)) ?? null; + } + + /** + * Fetches the user who uploaded this sticker, if this is a guild sticker. + * @returns {Promise} + */ + async fetchUser() { + if (this.partial) await this.fetch(); + if (!this.guildId) throw new Error('NOT_GUILD_STICKER'); + return this.guild.stickers.fetchUser(this); + } + + /** + * Data for editing a sticker. + * @typedef {Object} GuildStickerEditData + * @property {string} [name] The name of the sticker + * @property {?string} [description] The description of the sticker + * @property {string} [tags] The Discord name of a unicode emoji representing the sticker's expression + */ + + /** + * Edits the sticker. + * @param {GuildStickerEditData} [data] The new data for the sticker + * @param {string} [reason] Reason for editing this sticker + * @returns {Promise} + * @example + * // Update the name of a sticker + * sticker.edit({ name: 'new name' }) + * .then(s => console.log(`Updated the name of the sticker to ${s.name}`)) + * .catch(console.error); + */ + edit(data, reason) { + return this.guild.stickers.edit(this, data, reason); + } + + /** + * Deletes the sticker. + * @returns {Promise} + * @param {string} [reason] Reason for deleting this sticker + * @example + * // Delete a message + * sticker.delete() + * .then(s => console.log(`Deleted sticker ${s.name}`)) + * .catch(console.error); + */ + async delete(reason) { + await this.guild.stickers.delete(this, reason); + return this; + } + + /** + * Whether this sticker is the same as another one. + * @param {Sticker|APISticker} other The sticker to compare it to + * @returns {boolean} + */ + equals(other) { + if (other instanceof Sticker) { + return ( + other.id === this.id && + other.description === this.description && + other.type === this.type && + other.format === this.format && + other.name === this.name && + other.packId === this.packId && + other.tags.length === this.tags.length && + other.tags.every(tag => this.tags.includes(tag)) && + other.available === this.available && + other.guildId === this.guildId && + other.sortValue === this.sortValue + ); + } else { + return ( + other.id === this.id && + other.description === this.description && + other.name === this.name && + other.tags === this.tags.join(', ') + ); + } + } +} + +exports.Sticker = Sticker; + +/** + * @external APISticker + * @see {@link https://discord.com/developers/docs/resources/sticker#sticker-object} + */ diff --git a/src/structures/StickerPack.js b/src/structures/StickerPack.js new file mode 100644 index 00000000..7e599b7 --- /dev/null +++ b/src/structures/StickerPack.js @@ -0,0 +1,95 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const Base = require('./Base'); +const { Sticker } = require('./Sticker'); + +/** + * Represents a pack of standard stickers. + * @extends {Base} + */ +class StickerPack extends Base { + constructor(client, pack) { + super(client); + /** + * The Sticker pack's id + * @type {Snowflake} + */ + this.id = pack.id; + + /** + * The stickers in the pack + * @type {Collection} + */ + this.stickers = new Collection(pack.stickers.map(s => [s.id, new Sticker(client, s)])); + + /** + * The name of the sticker pack + * @type {string} + */ + this.name = pack.name; + + /** + * The id of the pack's SKU + * @type {Snowflake} + */ + this.skuId = pack.sku_id; + + /** + * The id of a sticker in the pack which is shown as the pack's icon + * @type {?Snowflake} + */ + this.coverStickerId = pack.cover_sticker_id ?? null; + + /** + * The description of the sticker pack + * @type {string} + */ + this.description = pack.description; + + /** + * The id of the sticker pack's banner image + * @type {?Snowflake} + */ + this.bannerId = pack.banner_asset_id ?? null; + } + + /** + * The timestamp the sticker was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the sticker was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The sticker which is shown as the pack's icon + * @type {?Sticker} + * @readonly + */ + get coverSticker() { + return this.coverStickerId && this.stickers.get(this.coverStickerId); + } + + /** + * The URL to this sticker pack's banner. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + bannerURL(options = {}) { + return this.bannerId && this.client.rest.cdn.stickerPackBanner(this.bannerId, options); + } +} + +module.exports = StickerPack; diff --git a/src/structures/StoreChannel.js b/src/structures/StoreChannel.js new file mode 100644 index 00000000..e8bd745 --- /dev/null +++ b/src/structures/StoreChannel.js @@ -0,0 +1,56 @@ +'use strict'; + +const GuildChannel = require('./GuildChannel'); + +/** + * Represents a guild store channel on Discord. + * Store channels are deprecated and will be removed from Discord in March 2022. See + * [Self-serve Game Selling Deprecation](https://support-dev.discord.com/hc/en-us/articles/4414590563479) + * for more information. + * @extends {GuildChannel} + */ +class StoreChannel extends GuildChannel { + constructor(guild, data, client) { + super(guild, data, client); + + /** + * If the guild considers this channel NSFW + * @type {boolean} + */ + this.nsfw = Boolean(data.nsfw); + } + + _patch(data) { + super._patch(data); + + if ('nsfw' in data) { + this.nsfw = Boolean(data.nsfw); + } + } + + /** + * Creates an invite to this guild channel. + * @param {CreateInviteOptions} [options={}] The options for creating the invite + * @returns {Promise} + * @example + * // Create an invite to a channel + * channel.createInvite() + * .then(invite => console.log(`Created an invite with a code of ${invite.code}`)) + * .catch(console.error); + */ + createInvite(options) { + return this.guild.invites.create(this.id, options); + } + + /** + * Fetches a collection of invites to this guild channel. + * Resolves with a collection mapping invites by their codes. + * @param {boolean} [cache=true] Whether or not to cache the fetched invites + * @returns {Promise>} + */ + fetchInvites(cache = true) { + return this.guild.invites.fetch({ channelId: this.id, cache }); + } +} + +module.exports = StoreChannel; diff --git a/src/structures/Team.js b/src/structures/Team.js new file mode 100644 index 00000000..98eb199 --- /dev/null +++ b/src/structures/Team.js @@ -0,0 +1,117 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const Base = require('./Base'); +const TeamMember = require('./TeamMember'); + +/** + * Represents a Client OAuth2 Application Team. + * @extends {Base} + */ +class Team extends Base { + constructor(client, data) { + super(client); + this._patch(data); + } + + _patch(data) { + /** + * The Team's id + * @type {Snowflake} + */ + this.id = data.id; + + if ('name' in data) { + /** + * The name of the Team + * @type {string} + */ + this.name = data.name; + } + + if ('icon' in data) { + /** + * The Team's icon hash + * @type {?string} + */ + this.icon = data.icon; + } else { + this.icon ??= null; + } + + if ('owner_user_id' in data) { + /** + * The Team's owner id + * @type {?Snowflake} + */ + this.ownerId = data.owner_user_id; + } else { + this.ownerId ??= null; + } + /** + * The Team's members + * @type {Collection} + */ + this.members = new Collection(); + + for (const memberData of data.members) { + const member = new TeamMember(this, memberData); + this.members.set(member.id, member); + } + } + + /** + * The owner of this team + * @type {?TeamMember} + * @readonly + */ + get owner() { + return this.members.get(this.ownerId) ?? null; + } + + /** + * The timestamp the team was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the team was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * A link to the team's icon. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + iconURL(options = {}) { + return this.icon && this.client.rest.cdn.teamIcon(this.id, this.icon, options); + } + + /** + * When concatenated with a string, this automatically returns the Team's name instead of the + * Team object. + * @returns {string} + * @example + * // Logs: Team name: My Team + * console.log(`Team name: ${team}`); + */ + toString() { + return this.name; + } + + toJSON() { + return super.toJSON({ createdTimestamp: true }); + } +} + +module.exports = Team; diff --git a/src/structures/TeamMember.js b/src/structures/TeamMember.js new file mode 100644 index 00000000..9270418 --- /dev/null +++ b/src/structures/TeamMember.js @@ -0,0 +1,70 @@ +'use strict'; + +const Base = require('./Base'); + +/** + * Represents a Client OAuth2 Application Team Member. + * @extends {Base} + */ +class TeamMember extends Base { + constructor(team, data) { + super(team.client); + + /** + * The Team this member is part of + * @type {Team} + */ + this.team = team; + + this._patch(data); + } + + _patch(data) { + if ('permissions' in data) { + /** + * The permissions this Team Member has with regard to the team + * @type {string[]} + */ + this.permissions = data.permissions; + } + + if ('membership_state' in data) { + /** + * The permissions this Team Member has with regard to the team + * @type {TeamMemberMembershipState} + */ + this.membershipState = data.membership_state; + } + + if ('user' in data) { + /** + * The user for this Team Member + * @type {User} + */ + this.user = this.client.users._add(data.user); + } + } + + /** + * The Team Member's id + * @type {Snowflake} + * @readonly + */ + get id() { + return this.user.id; + } + + /** + * When concatenated with a string, this automatically returns the team member's mention instead of the + * TeamMember object. + * @returns {string} + * @example + * // Logs: Team Member's mention: <@123456789012345678> + * console.log(`Team Member's mention: ${teamMember}`); + */ + toString() { + return this.user.toString(); + } +} + +module.exports = TeamMember; diff --git a/src/structures/TextChannel.js b/src/structures/TextChannel.js new file mode 100644 index 00000000..97040a2 --- /dev/null +++ b/src/structures/TextChannel.js @@ -0,0 +1,33 @@ +'use strict'; + +const BaseGuildTextChannel = require('./BaseGuildTextChannel'); + +/** + * Represents a guild text channel on Discord. + * @extends {BaseGuildTextChannel} + */ +class TextChannel extends BaseGuildTextChannel { + _patch(data) { + super._patch(data); + + if ('rate_limit_per_user' in data) { + /** + * The rate limit per user (slowmode) for this channel in seconds + * @type {number} + */ + this.rateLimitPerUser = data.rate_limit_per_user; + } + } + + /** + * Sets the rate limit per user (slowmode) for this channel. + * @param {number} rateLimitPerUser The new rate limit in seconds + * @param {string} [reason] Reason for changing the channel's rate limit + * @returns {Promise} + */ + setRateLimitPerUser(rateLimitPerUser, reason) { + return this.edit({ rateLimitPerUser }, reason); + } +} + +module.exports = TextChannel; diff --git a/src/structures/ThreadChannel.js b/src/structures/ThreadChannel.js new file mode 100644 index 00000000..397f646 --- /dev/null +++ b/src/structures/ThreadChannel.js @@ -0,0 +1,561 @@ +'use strict'; + +const { ChannelType, PermissionFlagsBits, Routes } = require('discord-api-types/v9'); +const { Channel } = require('./Channel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); +const { RangeError } = require('../errors'); +const MessageManager = require('../managers/MessageManager'); +const ThreadMemberManager = require('../managers/ThreadMemberManager'); + +/** + * Represents a thread channel on Discord. + * @extends {Channel} + * @implements {TextBasedChannel} + */ +class ThreadChannel extends Channel { + constructor(guild, data, client, fromInteraction = false) { + super(guild?.client ?? client, data, false); + + /** + * The guild the thread is in + * @type {Guild} + */ + this.guild = guild; + + /** + * The id of the guild the channel is in + * @type {Snowflake} + */ + this.guildId = guild?.id ?? data.guild_id; + + /** + * A manager of the messages sent to this thread + * @type {MessageManager} + */ + this.messages = new MessageManager(this); + + /** + * A manager of the members that are part of this thread + * @type {ThreadMemberManager} + */ + this.members = new ThreadMemberManager(this); + if (data) this._patch(data, fromInteraction); + } + + _patch(data, partial = false) { + super._patch(data); + + if ('name' in data) { + /** + * The name of the thread + * @type {string} + */ + this.name = data.name; + } + + if ('guild_id' in data) { + this.guildId = data.guild_id; + } + + if ('parent_id' in data) { + /** + * The id of the parent channel of this thread + * @type {?Snowflake} + */ + this.parentId = data.parent_id; + } else { + this.parentId ??= null; + } + + if ('thread_metadata' in data) { + /** + * Whether the thread is locked + * @type {?boolean} + */ + this.locked = data.thread_metadata.locked ?? false; + + /** + * Whether members without `MANAGE_THREADS` can invite other members without `MANAGE_THREADS` + * Always `null` in public threads + * @type {?boolean} + */ + this.invitable = this.type === ChannelType.GuildPrivateThread ? data.thread_metadata.invitable ?? false : null; + + /** + * Whether the thread is archived + * @type {?boolean} + */ + this.archived = data.thread_metadata.archived; + + /** + * The amount of time (in minutes) after which the thread will automatically archive in case of no recent activity + * @type {?number} + */ + this.autoArchiveDuration = data.thread_metadata.auto_archive_duration; + + /** + * The timestamp when the thread's archive status was last changed + * If the thread was never archived or unarchived, this is the timestamp at which the thread was + * created + * @type {?number} + */ + this.archiveTimestamp = Date.parse(data.thread_metadata.archive_timestamp); + + if ('create_timestamp' in data.thread_metadata) { + // Note: this is needed because we can't assign directly to getters + this._createdTimestamp = Date.parse(data.thread_metadata.create_timestamp); + } + } else { + this.locked ??= null; + this.archived ??= null; + this.autoArchiveDuration ??= null; + this.archiveTimestamp ??= null; + this.invitable ??= null; + } + + this._createdTimestamp ??= this.type === ChannelType.GuildPrivateThread ? super.createdTimestamp : null; + + if ('owner_id' in data) { + /** + * The id of the member who created this thread + * @type {?Snowflake} + */ + this.ownerId = data.owner_id; + } else { + this.ownerId ??= null; + } + + if ('last_message_id' in data) { + /** + * The last message id sent in this thread, if one was sent + * @type {?Snowflake} + */ + this.lastMessageId = data.last_message_id; + } else { + this.lastMessageId ??= null; + } + + if ('last_pin_timestamp' in data) { + /** + * The timestamp when the last pinned message was pinned, if there was one + * @type {?number} + */ + this.lastPinTimestamp = data.last_pin_timestamp ? Date.parse(data.last_pin_timestamp) : null; + } else { + this.lastPinTimestamp ??= null; + } + + if ('rate_limit_per_user' in data || !partial) { + /** + * The rate limit per user (slowmode) for this thread in seconds + * @type {?number} + */ + this.rateLimitPerUser = data.rate_limit_per_user ?? 0; + } else { + this.rateLimitPerUser ??= null; + } + + if ('message_count' in data) { + /** + * The approximate count of messages in this thread + * This stops counting at 50. If you need an approximate value higher than that, use + * `ThreadChannel#messages.cache.size` + * @type {?number} + */ + this.messageCount = data.message_count; + } else { + this.messageCount ??= null; + } + + if ('member_count' in data) { + /** + * The approximate count of users in this thread + * This stops counting at 50. If you need an approximate value higher than that, use + * `ThreadChannel#members.cache.size` + * @type {?number} + */ + this.memberCount = data.member_count; + } else { + this.memberCount ??= null; + } + + if (data.member && this.client.user) this.members._add({ user_id: this.client.user.id, ...data.member }); + if (data.messages) for (const message of data.messages) this.messages._add(message); + } + + /** + * The timestamp when this thread was created. This isn't available for threads + * created before 2022-01-09 + * @type {?number} + * @readonly + */ + get createdTimestamp() { + return this._createdTimestamp; + } + + /** + * A collection of associated guild member objects of this thread's members + * @type {Collection} + * @readonly + */ + get guildMembers() { + return this.members.cache.mapValues(member => member.guildMember); + } + + /** + * The time at which this thread's archive status was last changed + * If the thread was never archived or unarchived, this is the time at which the thread was created + * @type {?Date} + * @readonly + */ + get archivedAt() { + return this.archiveTimestamp && new Date(this.archiveTimestamp); + } + + /** + * The time the thread was created at + * @type {?Date} + * @readonly + */ + get createdAt() { + return this.createdTimestamp && new Date(this.createdTimestamp); + } + + /** + * The parent channel of this thread + * @type {?(NewsChannel|TextChannel)} + * @readonly + */ + get parent() { + return this.guild.channels.resolve(this.parentId); + } + + /** + * Makes the client user join the thread. + * @returns {Promise} + */ + async join() { + await this.members.add('@me'); + return this; + } + + /** + * Makes the client user leave the thread. + * @returns {Promise} + */ + async leave() { + await this.members.remove('@me'); + return this; + } + + /** + * Gets the overall set of permissions for a member or role in this thread's parent channel, taking overwrites into + * account. + * @param {GuildMemberResolvable|RoleResolvable} memberOrRole The member or role to obtain the overall permissions for + * @param {boolean} [checkAdmin=true] Whether having `ADMINISTRATOR` will return all permissions + * @returns {?Readonly} + */ + permissionsFor(memberOrRole, checkAdmin) { + return this.parent?.permissionsFor(memberOrRole, checkAdmin) ?? null; + } + + /** + * Fetches the owner of this thread. If the thread member object isn't needed, + * use {@link ThreadChannel#ownerId} instead. + * @param {BaseFetchOptions} [options] The options for fetching the member + * @returns {Promise} + */ + async fetchOwner({ cache = true, force = false } = {}) { + if (!force) { + const existing = this.members.cache.get(this.ownerId); + if (existing) return existing; + } + + // We cannot fetch a single thread member, as of this commit's date, Discord API responds with 405 + const members = await this.members.fetch(cache); + return members.get(this.ownerId) ?? null; + } + + /** + * Fetches the message that started this thread, if any. + * This only works when the thread started from a message in the parent channel, otherwise the promise will + * reject. If you just need the id of that message, use {@link ThreadChannel#id} instead. + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise} + */ + fetchStarterMessage(options) { + return this.parent.messages.fetch(this.id, options); + } + + /** + * The options used to edit a thread channel + * @typedef {Object} ThreadEditData + * @property {string} [name] The new name for the thread + * @property {boolean} [archived] Whether the thread is archived + * @property {ThreadAutoArchiveDuration} [autoArchiveDuration] The amount of time (in minutes) after which the thread + * should automatically archive in case of no recent activity + * @property {number} [rateLimitPerUser] The rate limit per user (slowmode) for the thread in seconds + * @property {boolean} [locked] Whether the thread is locked + * @property {boolean} [invitable] Whether non-moderators can add other non-moderators to a thread + * Can only be edited on {@link ChannelType.GuildPrivateThread} + */ + + /** + * Edits this thread. + * @param {ThreadEditData} data The new data for this thread + * @param {string} [reason] Reason for editing this thread + * @returns {Promise} + * @example + * // Edit a thread + * thread.edit({ name: 'new-thread' }) + * .then(editedThread => console.log(editedThread)) + * .catch(console.error); + */ + async edit(data, reason) { + let autoArchiveDuration = data.autoArchiveDuration; + if (data.autoArchiveDuration === 'MAX') { + autoArchiveDuration = 1440; + if (this.guild.features.includes('SEVEN_DAY_THREAD_ARCHIVE')) { + autoArchiveDuration = 10080; + } else if (this.guild.features.includes('THREE_DAY_THREAD_ARCHIVE')) { + autoArchiveDuration = 4320; + } + } + const newData = await this.client.api.channels(this.id).patch({ + body: { + name: (data.name ?? this.name).trim(), + archived: data.archived, + auto_archive_duration: autoArchiveDuration, + rate_limit_per_user: data.rateLimitPerUser, + locked: data.locked, + invitable: this.type === ChannelType.GuildPrivateThread ? data.invitable : undefined, + }, + reason, + }); + + return this.client.actions.ChannelUpdate.handle(newData).updated; + } + + /** + * Sets whether the thread is archived. + * @param {boolean} [archived=true] Whether the thread is archived + * @param {string} [reason] Reason for archiving or unarchiving + * @returns {Promise} + * @example + * // Archive the thread + * thread.setArchived(true) + * .then(newThread => console.log(`Thread is now ${newThread.archived ? 'archived' : 'active'}`)) + * .catch(console.error); + */ + setArchived(archived = true, reason) { + return this.edit({ archived }, reason); + } + + /** + * Sets the duration after which the thread will automatically archive in case of no recent activity. + * @param {ThreadAutoArchiveDuration} autoArchiveDuration The amount of time (in minutes) after which the thread + * should automatically archive in case of no recent activity + * @param {string} [reason] Reason for changing the auto archive duration + * @returns {Promise} + * @example + * // Set the thread's auto archive time to 1 hour + * thread.setAutoArchiveDuration(60) + * .then(newThread => { + * console.log(`Thread will now archive after ${newThread.autoArchiveDuration} minutes of inactivity`); + * }); + * .catch(console.error); + */ + setAutoArchiveDuration(autoArchiveDuration, reason) { + return this.edit({ autoArchiveDuration }, reason); + } + + /** + * Sets whether members without the `MANAGE_THREADS` permission can invite other members without the + * `MANAGE_THREADS` permission to this thread. + * @param {boolean} [invitable=true] Whether non-moderators can invite non-moderators to this thread + * @param {string} [reason] Reason for changing invite + * @returns {Promise} + */ + setInvitable(invitable = true, reason) { + if (this.type !== ChannelType.GuildPrivateThread) { + return Promise.reject(new RangeError('THREAD_INVITABLE_TYPE', this.type)); + } + return this.edit({ invitable }, reason); + } + + /** + * Sets whether the thread can be **unarchived** by anyone with `SEND_MESSAGES` permission. + * When a thread is locked only members with `MANAGE_THREADS` can unarchive it. + * @param {boolean} [locked=true] Whether the thread is locked + * @param {string} [reason] Reason for locking or unlocking the thread + * @returns {Promise} + * @example + * // Set the thread to locked + * thread.setLocked(true) + * .then(newThread => console.log(`Thread is now ${newThread.locked ? 'locked' : 'unlocked'}`)) + * .catch(console.error); + */ + setLocked(locked = true, reason) { + return this.edit({ locked }, reason); + } + + /** + * Sets a new name for this thread. + * @param {string} name The new name for the thread + * @param {string} [reason] Reason for changing the thread's name + * @returns {Promise} + * @example + * // Change the thread's name + * thread.setName('not_general') + * .then(newThread => console.log(`Thread's new name is ${newThread.name}`)) + * .catch(console.error); + */ + setName(name, reason) { + return this.edit({ name }, reason); + } + + /** + * Sets the rate limit per user (slowmode) for this thread. + * @param {number} rateLimitPerUser The new rate limit in seconds + * @param {string} [reason] Reason for changing the thread's rate limit + * @returns {Promise} + */ + setRateLimitPerUser(rateLimitPerUser, reason) { + return this.edit({ rateLimitPerUser }, reason); + } + + /** + * Whether the client user is a member of the thread. + * @type {boolean} + * @readonly + */ + get joined() { + return this.members.cache.has(this.client.user?.id); + } + + /** + * Whether the thread is editable by the client user (name, archived, autoArchiveDuration) + * @type {boolean} + * @readonly + */ + get editable() { + return ( + (this.ownerId === this.client.user.id && (this.type !== ChannelType.GuildPrivateThread || this.joined)) || + this.manageable + ); + } + + /** + * Whether the thread is joinable by the client user + * @type {boolean} + * @readonly + */ + get joinable() { + return ( + !this.archived && + !this.joined && + this.permissionsFor(this.client.user)?.has( + this.type === ChannelType.GuildPrivateThread + ? PermissionFlagsBits.ManageThreads + : PermissionFlagsBits.ViewChannel, + false, + ) + ); + } + + /** + * Whether the thread is manageable by the client user, for deleting or editing rateLimitPerUser or locked. + * @type {boolean} + * @readonly + */ + get manageable() { + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + // This flag allows managing even if timed out + if (permissions.has(PermissionFlagsBits.Administrator, false)) return true; + + return ( + this.guild.me.communicationDisabledUntilTimestamp < Date.now() && + permissions.has(PermissionFlagsBits.ManageThreads, false) + ); + } + + /** + * Whether the thread is viewable by the client user + * @type {boolean} + * @readonly + */ + get viewable() { + if (this.client.user.id === this.guild.ownerId) return true; + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + return permissions.has(PermissionFlagsBits.ViewChannel, false); + } + + /** + * Whether the client user can send messages in this thread + * @type {boolean} + * @readonly + */ + get sendable() { + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + // This flag allows sending even if timed out + if (permissions.has(PermissionFlagsBits.Administrator, false)) return true; + + return ( + !(this.archived && this.locked && !this.manageable) && + (this.type !== ChannelType.GuildPrivateThread || this.joined || this.manageable) && + permissions.has(PermissionFlagsBits.SendMessagesInThreads, false) && + this.guild.me.communicationDisabledUntilTimestamp < Date.now() + ); + } + + /** + * Whether the thread is unarchivable by the client user + * @type {boolean} + * @readonly + */ + get unarchivable() { + return this.archived && this.sendable && (!this.locked || this.manageable); + } + + /** + * Whether this thread is a private thread + * @returns {boolean} + */ + isPrivate() { + return this.type === ChannelType.GuildPrivateThread; + } + + /** + * Deletes this thread. + * @param {string} [reason] Reason for deleting this thread + * @returns {Promise} + * @example + * // Delete the thread + * thread.delete('cleaning out old threads') + * .then(deletedThread => console.log(deletedThread)) + * .catch(console.error); + */ + async delete(reason) { + await this.guild.channels.delete(this.id, reason); + return this; + } + + // These are here only for documentation purposes - they are implemented by TextBasedChannel + /* eslint-disable no-empty-function */ + get lastMessage() {} + get lastPinAt() {} + send() {} + sendTyping() {} + createMessageCollector() {} + awaitMessages() {} + createMessageComponentCollector() {} + awaitMessageComponent() {} + bulkDelete() {} +} + +TextBasedChannel.applyToClass(ThreadChannel, true); + +module.exports = ThreadChannel; diff --git a/src/structures/ThreadMember.js b/src/structures/ThreadMember.js new file mode 100644 index 00000000..9da8677 --- /dev/null +++ b/src/structures/ThreadMember.js @@ -0,0 +1,94 @@ +'use strict'; + +const Base = require('./Base'); +const ThreadMemberFlagsBitField = require('../util/ThreadMemberFlagsBitField'); + +/** + * Represents a Member for a Thread. + * @extends {Base} + */ +class ThreadMember extends Base { + constructor(thread, data) { + super(thread.client); + + /** + * The thread that this member is a part of + * @type {ThreadChannel} + */ + this.thread = thread; + + /** + * The timestamp the member last joined the thread at + * @type {?number} + */ + this.joinedTimestamp = null; + + /** + * The id of the thread member + * @type {Snowflake} + */ + this.id = data.user_id; + + this._patch(data); + } + + _patch(data) { + if ('join_timestamp' in data) this.joinedTimestamp = Date.parse(data.join_timestamp); + + if ('flags' in data) { + /** + * The flags for this thread member + * @type {ThreadMemberFlagsBitField} + */ + this.flags = new ThreadMemberFlagsBitField(data.flags).freeze(); + } + } + + /** + * The guild member associated with this thread member + * @type {?GuildMember} + * @readonly + */ + get guildMember() { + return this.thread.guild.members.resolve(this.id); + } + + /** + * The last time this member joined the thread + * @type {?Date} + * @readonly + */ + get joinedAt() { + return this.joinedTimestamp && new Date(this.joinedTimestamp); + } + + /** + * The user associated with this thread member + * @type {?User} + * @readonly + */ + get user() { + return this.client.users.resolve(this.id); + } + + /** + * Whether the client user can manage this thread member + * @type {boolean} + * @readonly + */ + get manageable() { + return !this.thread.archived && this.thread.editable; + } + + /** + * Removes this member from the thread. + * @param {string} [reason] Reason for removing the member + * @returns {ThreadMember} + */ + async remove(reason) { + await this.thread.members.remove(this.id, reason); + return this; + } +} + +module.exports = ThreadMember; diff --git a/src/structures/Typing.js b/src/structures/Typing.js new file mode 100644 index 00000000..341d7ca --- /dev/null +++ b/src/structures/Typing.js @@ -0,0 +1,74 @@ +'use strict'; + +const Base = require('./Base'); + +/** + * Represents a typing state for a user in a channel. + * @extends {Base} + */ +class Typing extends Base { + constructor(channel, user, data) { + super(channel.client); + + /** + * The channel the status is from + * @type {TextBasedChannels} + */ + this.channel = channel; + + /** + * The user who is typing + * @type {User} + */ + this.user = user; + + this._patch(data); + } + + _patch(data) { + if ('timestamp' in data) { + /** + * The UNIX timestamp in milliseconds the user started typing at + * @type {number} + */ + this.startedTimestamp = data.timestamp * 1_000; + } + } + + /** + * Indicates whether the status is received from a guild. + * @returns {boolean} + */ + inGuild() { + return this.guild !== null; + } + + /** + * The time the user started typing at + * @type {Date} + * @readonly + */ + get startedAt() { + return new Date(this.startedTimestamp); + } + + /** + * The guild the status is from + * @type {?Guild} + * @readonly + */ + get guild() { + return this.channel.guild ?? null; + } + + /** + * The member who is typing + * @type {?GuildMember} + * @readonly + */ + get member() { + return this.guild?.members.resolve(this.user) ?? null; + } +} + +module.exports = Typing; diff --git a/src/structures/User.js b/src/structures/User.js new file mode 100644 index 00000000..d5c4833 --- /dev/null +++ b/src/structures/User.js @@ -0,0 +1,420 @@ +'use strict'; + +const Base = require('./Base'); +const { Error } = require('../errors/DJSError'); +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const UserFlagsBitField = require('../util/UserFlagsBitField'); +const { default: Collection } = require('@discordjs/collection'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); + +/** + * Represents a user on Discord. + * @implements {TextBasedChannel} + * @extends {Base} + */ +class User extends Base { + constructor(client, data) { + super(client); + + /** + * The user's id + * @type {Snowflake} + */ + this.id = data.id; + + this.bot = null; + + this.system = null; + + this.flags = null; + + this.friend = client.friends.cache.has(this.id); + + this.blocked = client.blocked.cache.has(this.id); + + // Code written by https://github.com/aiko-chan-ai + this.connectedAccounds = []; + this.premiumSince = null; + this.premiumGuildSince = null; + this.mutualGuilds = new Collection(); + + this._patch(data); + } + + _patch(data) { + if ('username' in data) { + /** + * The username of the user + * @type {?string} + */ + this.username = data.username; + } else { + this.username ??= null; + } + + if ('bot' in data) { + /** + * Whether or not the user is a bot + * @type {?boolean} + */ + this.bot = Boolean(data.bot); + } else if (!this.partial && typeof this.bot !== 'boolean') { + this.bot = false; + } + + if ('discriminator' in data) { + /** + * A discriminator based on username for the user + * @type {?string} + */ + this.discriminator = data.discriminator; + } else { + this.discriminator ??= null; + } + + if ('avatar' in data) { + /** + * The user avatar's hash + * @type {?string} + */ + this.avatar = data.avatar; + } else { + this.avatar ??= null; + } + + if ('banner' in data) { + /** + * The user banner's hash + * The user must be force fetched for this property to be present or be updated + * @type {?string} + */ + this.banner = data.banner; + } else if (this.banner !== null) { + this.banner ??= undefined; + } + + if ('accent_color' in data) { + /** + * The base 10 accent color of the user's banner + * The user must be force fetched for this property to be present or be updated + * @type {?number} + */ + this.accentColor = data.accent_color; + } else if (this.accentColor !== null) { + this.accentColor ??= undefined; + } + + if ('system' in data) { + /** + * Whether the user is an Official Discord System user (part of the urgent message system) + * @type {?boolean} + */ + this.system = Boolean(data.system); + } else if (!this.partial && typeof this.system !== 'boolean') { + this.system = false; + } + + if ('public_flags' in data) { + /** + * The flags for this user + * @type {?UserFlagsBitField} + */ + this.flags = new UserFlagsBitField(data.public_flags); + } + } + + // Code written by https://github.com/aiko-chan-ai + _ProfilePatch(data) { + if(!data) return; + + if(data.connected_accounts.length > 0) this.connectedAccounds = data.connected_accounts; + + if('premium_since' in data) { + const date = new Date(data.premium_since); + this.premiumSince = date.getTime(); + } + + if('premium_guild_since' in data) { + const date = new Date(data.premium_guild_since); + this.premiumGuildSince = date.getTime(); + } + + this.mutualGuilds = new Collection(data.mutual_guilds.map((obj) => [obj.id, obj])); + } + + /** + * Get profile from Discord, if client is in a server with the target. + *
Code written by https://github.com/aiko-chan-ai + */ + async getProfile() { + if(this.client.bot) throw new Error('INVALID_BOT_METHOD'); + try { + const data = await this.client.api.users(this.id).profile.get(); + this._ProfilePatch(data); + return this + } catch (e) { + throw e + } + } + + /** + * Friends the user + * @returns {Promise} the user object + */ + async friend() { + return this.client.api + .user('@me') + .relationships[this.id].put({data:{type:1}}) + .then(_ => _) + } + + /** + * Blocks the user + * @returns {Promise} the user object + */ + async block() { + return this.client.api + .users('@me') + .relationships[this.id].put({data:{type: 2}}) + .then(_ => _) + } + + /** + * Removes the user from your blocks list + * @returns {Promise} the user object + */ + async unblock() { + return this.client.api + .users('@me') + .relationships[this.id].delete + .then(_ => _) + } + + /** + * Removes the user from your friends list + * @returns {Promise} the user object + */ + async unfriend() { + return this.client.api + .users('@me') + .relationships[this.id].delete + .then(_ => _) + } + + /** + * Whether this User is a partial + * @type {boolean} + * @readonly + */ + get partial() { + return typeof this.username !== 'string'; + } + + /** + * The timestamp the user was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the user was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * A link to the user's avatar. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + avatarURL(options = {}) { + return this.avatar && this.client.rest.cdn.avatar(this.id, this.avatar, options); + } + + /** + * If the user is a bot then it'll return the slash commands else return null + * @readonly + */ + get slashCommands() { + if(this.bot) { + return this.client.api.applications(this.id).commands.get(); + } else return null; + } + + /** + * A link to the user's default avatar + * @type {string} + * @readonly + */ + get defaultAvatarURL() { + return this.client.rest.cdn.defaultAvatar(this.discriminator % 5); + } + + /** + * A link to the user's avatar if they have one. + * Otherwise a link to their default avatar will be returned. + * @param {ImageURLOptions} [options={}] Options for the Image URL + * @returns {string} + */ + displayAvatarURL(options) { + return this.avatarURL(options) ?? this.defaultAvatarURL; + } + + /** + * The hexadecimal version of the user accent color, with a leading hash + * The user must be force fetched for this property to be present + * @type {?string} + * @readonly + */ + get hexAccentColor() { + if (typeof this.accentColor !== 'number') return this.accentColor; + return `#${this.accentColor.toString(16).padStart(6, '0')}`; + } + + /** + * A link to the user's banner. See {@link User#banner} for more info + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + bannerURL(options = {}) { + return this.banner && this.client.rest.cdn.banner(this.id, this.banner, options); + } + + /** + * The Discord "tag" (e.g. `hydrabolt#0001`) for this user + * @type {?string} + * @readonly + */ + get tag() { + return typeof this.username === 'string' ? `${this.username}#${this.discriminator}` : null; + } + + /** + * The DM between the client's user and this user + * @type {?DMChannel} + * @readonly + */ + get dmChannel() { + return this.client.users.dmChannel(this.id); + } + + /** + * Creates a DM channel between the client and the user. + * @param {boolean} [force=false] Whether to skip the cache check and request the API + * @returns {Promise} + */ + createDM(force = false) { + return this.client.users.createDM(this.id, force); + } + + /** + * Deletes a DM channel (if one exists) between the client and the user. Resolves with the channel if successful. + * @returns {Promise} + */ + deleteDM() { + return this.client.users.deleteDM(this.id); + } + + /** + * Checks if the user is equal to another. + * It compares id, username, discriminator, avatar, banner, accent color, and bot flags. + * It is recommended to compare equality by using `user.id === user2.id` unless you want to compare all properties. + * @param {User} user User to compare with + * @returns {boolean} + */ + equals(user) { + return ( + user && + this.id === user.id && + this.username === user.username && + this.discriminator === user.discriminator && + this.avatar === user.avatar && + this.flags?.bitfield === user.flags?.bitfield && + this.banner === user.banner && + this.accentColor === user.accentColor + ); + } + + /** + * Compares the user with an API user object + * @param {APIUser} user The API user object to compare + * @returns {boolean} + * @private + */ + _equals(user) { + return ( + user && + this.id === user.id && + this.username === user.username && + this.discriminator === user.discriminator && + this.avatar === user.avatar && + this.flags?.bitfield === user.public_flags && + ('banner' in user ? this.banner === user.banner : true) && + ('accent_color' in user ? this.accentColor === user.accent_color : true) + ); + } + + /** + * Fetches this user's flags. + * @param {boolean} [force=false] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetchFlags(force = false) { + return this.client.users.fetchFlags(this.id, { force }); + } + + /** + * Fetches this user. + * @param {boolean} [force=true] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetch(force = true) { + return this.client.users.fetch(this.id, { force }); + } + + /** + * When concatenated with a string, this automatically returns the user's mention instead of the User object. + * @returns {string} + * @example + * // Logs: Hello from <@123456789012345678>! + * console.log(`Hello from ${user}!`); + */ + toString() { + return `<@${this.id}>`; + } + + toJSON(...props) { + const json = super.toJSON( + { + createdTimestamp: true, + defaultAvatarURL: true, + hexAccentColor: true, + tag: true, + }, + ...props, + ); + json.avatarURL = this.avatarURL(); + json.displayAvatarURL = this.displayAvatarURL(); + json.bannerURL = this.banner ? this.bannerURL() : this.banner; + return json; + } + + // These are here only for documentation purposes - they are implemented by TextBasedChannel + /* eslint-disable no-empty-function */ + send() {} +} + +TextBasedChannel.applyToClass(User); + +module.exports = User; + +/** + * @external APIUser + * @see {@link https://discord.com/developers/docs/resources/user#user-object} + */ diff --git a/src/structures/UserContextMenuCommandInteraction.js b/src/structures/UserContextMenuCommandInteraction.js new file mode 100644 index 00000000..a074de1 --- /dev/null +++ b/src/structures/UserContextMenuCommandInteraction.js @@ -0,0 +1,29 @@ +'use strict'; + +const ContextMenuCommandInteraction = require('./ContextMenuCommandInteraction'); + +/** + * Represents a user context menu interaction. + * @extends {ContextMenuCommandInteraction} + */ +class UserContextMenuCommandInteraction extends ContextMenuCommandInteraction { + /** + * The user this interaction was sent from + * @type {User} + * @readonly + */ + get targetUser() { + return this.options.getUser('user'); + } + + /** + * The member this interaction was sent from + * @type {?(GuildMember|APIGuildMember)} + * @readonly + */ + get targetMember() { + return this.options.getMember('user'); + } +} + +module.exports = UserContextMenuCommandInteraction; diff --git a/src/structures/VoiceChannel.js b/src/structures/VoiceChannel.js new file mode 100644 index 00000000..1b5bc20 --- /dev/null +++ b/src/structures/VoiceChannel.js @@ -0,0 +1,83 @@ +'use strict'; + +const { PermissionFlagsBits } = require('discord-api-types/v9'); +const BaseGuildVoiceChannel = require('./BaseGuildVoiceChannel'); + +/** + * Represents a guild voice channel on Discord. + * @extends {BaseGuildVoiceChannel} + */ +class VoiceChannel extends BaseGuildVoiceChannel { + /** + * Whether the channel is joinable by the client user + * @type {boolean} + * @readonly + */ + get joinable() { + if (!super.joinable) return false; + if (this.full && !this.permissionsFor(this.client.user).has(PermissionFlagsBits.MoveMembers, false)) return false; + return true; + } + + /** + * Checks if the client has permission to send audio to the voice channel + * @type {boolean} + * @readonly + */ + get speakable() { + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + // This flag allows speaking even if timed out + if (permissions.has(PermissionFlagsBits.Administrator, false)) return true; + + return ( + this.guild.me.communicationDisabledUntilTimestamp < Date.now() && + permissions.has(PermissionFlagsBits.Speak, false) + ); + } + + /** + * Sets the bitrate of the channel. + * @param {number} bitrate The new bitrate + * @param {string} [reason] Reason for changing the channel's bitrate + * @returns {Promise} + * @example + * // Set the bitrate of a voice channel + * voiceChannel.setBitrate(48_000) + * .then(vc => console.log(`Set bitrate to ${vc.bitrate}bps for ${vc.name}`)) + * .catch(console.error); + */ + setBitrate(bitrate, reason) { + return this.edit({ bitrate }, reason); + } + + /** + * Sets the user limit of the channel. + * @param {number} userLimit The new user limit + * @param {string} [reason] Reason for changing the user limit + * @returns {Promise} + * @example + * // Set the user limit of a voice channel + * voiceChannel.setUserLimit(42) + * .then(vc => console.log(`Set user limit to ${vc.userLimit} for ${vc.name}`)) + * .catch(console.error); + */ + setUserLimit(userLimit, reason) { + return this.edit({ userLimit }, reason); + } + + /** + * Sets the RTC region of the channel. + * @name VoiceChannel#setRTCRegion + * @param {?string} region The new region of the channel. Set to `null` to remove a specific region for the channel + * @returns {Promise} + * @example + * // Set the RTC region to europe + * voiceChannel.setRTCRegion('europe'); + * @example + * // Remove a fixed region for this channel - let Discord decide automatically + * voiceChannel.setRTCRegion(null); + */ +} + +module.exports = VoiceChannel; diff --git a/src/structures/VoiceRegion.js b/src/structures/VoiceRegion.js new file mode 100644 index 00000000..be46b4d --- /dev/null +++ b/src/structures/VoiceRegion.js @@ -0,0 +1,46 @@ +'use strict'; + +const Util = require('../util/Util'); + +/** + * Represents a Discord voice region for guilds. + */ +class VoiceRegion { + constructor(data) { + /** + * The region's id + * @type {string} + */ + this.id = data.id; + + /** + * Name of the region + * @type {string} + */ + this.name = data.name; + + /** + * Whether the region is deprecated + * @type {boolean} + */ + this.deprecated = data.deprecated; + + /** + * Whether the region is optimal + * @type {boolean} + */ + this.optimal = data.optimal; + + /** + * Whether the region is custom + * @type {boolean} + */ + this.custom = data.custom; + } + + toJSON() { + return Util.flatten(this); + } +} + +module.exports = VoiceRegion; diff --git a/src/structures/VoiceState.js b/src/structures/VoiceState.js new file mode 100644 index 00000000..73762d0 --- /dev/null +++ b/src/structures/VoiceState.js @@ -0,0 +1,278 @@ +'use strict'; + +const { ChannelType, Routes } = require('discord-api-types/v9'); +const Base = require('./Base'); +const { Error, TypeError } = require('../errors'); + +/** + * Represents the voice state for a Guild Member. + */ +class VoiceState extends Base { + constructor(guild, data) { + super(guild.client); + /** + * The guild of this voice state + * @type {Guild} + */ + this.guild = guild; + /** + * The id of the member of this voice state + * @type {Snowflake} + */ + this.id = data.user_id; + this._patch(data); + } + + _patch(data) { + if ('deaf' in data) { + /** + * Whether this member is deafened server-wide + * @type {?boolean} + */ + this.serverDeaf = data.deaf; + } else { + this.serverDeaf ??= null; + } + + if ('mute' in data) { + /** + * Whether this member is muted server-wide + * @type {?boolean} + */ + this.serverMute = data.mute; + } else { + this.serverMute ??= null; + } + + if ('self_deaf' in data) { + /** + * Whether this member is self-deafened + * @type {?boolean} + */ + this.selfDeaf = data.self_deaf; + } else { + this.selfDeaf ??= null; + } + + if ('self_mute' in data) { + /** + * Whether this member is self-muted + * @type {?boolean} + */ + this.selfMute = data.self_mute; + } else { + this.selfMute ??= null; + } + + if ('self_video' in data) { + /** + * Whether this member's camera is enabled + * @type {?boolean} + */ + this.selfVideo = data.self_video; + } else { + this.selfVideo ??= null; + } + + if ('session_id' in data) { + /** + * The session id for this member's connection + * @type {?string} + */ + this.sessionId = data.session_id; + } else { + this.sessionId ??= null; + } + + // The self_stream is property is omitted if false, check for another property + // here to avoid incorrectly clearing this when partial data is specified + if ('self_video' in data) { + /** + * Whether this member is streaming using "Screen Share" + * @type {?boolean} + */ + this.streaming = data.self_stream ?? false; + } else { + this.streaming ??= null; + } + + if ('channel_id' in data) { + /** + * The {@link VoiceChannel} or {@link StageChannel} id the member is in + * @type {?Snowflake} + */ + this.channelId = data.channel_id; + } else { + this.channelId ??= null; + } + + if ('suppress' in data) { + /** + * Whether this member is suppressed from speaking. This property is specific to stage channels only. + * @type {?boolean} + */ + this.suppress = data.suppress; + } else { + this.suppress ??= null; + } + + if ('request_to_speak_timestamp' in data) { + /** + * The time at which the member requested to speak. This property is specific to stage channels only. + * @type {?number} + */ + this.requestToSpeakTimestamp = Date.parse(data.request_to_speak_timestamp); + } else { + this.requestToSpeakTimestamp ??= null; + } + + return this; + } + + /** + * The member that this voice state belongs to + * @type {?GuildMember} + * @readonly + */ + get member() { + return this.guild.members.cache.get(this.id) ?? null; + } + + /** + * The channel that the member is connected to + * @type {?(VoiceChannel|StageChannel)} + * @readonly + */ + get channel() { + return this.guild.channels.cache.get(this.channelId) ?? null; + } + + /** + * Whether this member is either self-deafened or server-deafened + * @type {?boolean} + * @readonly + */ + get deaf() { + return this.serverDeaf || this.selfDeaf; + } + + /** + * Whether this member is either self-muted or server-muted + * @type {?boolean} + * @readonly + */ + get mute() { + return this.serverMute || this.selfMute; + } + + /** + * Mutes/unmutes the member of this voice state. + * @param {boolean} [mute=true] Whether or not the member should be muted + * @param {string} [reason] Reason for muting or unmuting + * @returns {Promise} + */ + setMute(mute = true, reason) { + return this.guild.members.edit(this.id, { mute }, reason); + } + + /** + * Deafens/undeafens the member of this voice state. + * @param {boolean} [deaf=true] Whether or not the member should be deafened + * @param {string} [reason] Reason for deafening or undeafening + * @returns {Promise} + */ + setDeaf(deaf = true, reason) { + return this.guild.members.edit(this.id, { deaf }, reason); + } + + /** + * Disconnects the member from the channel. + * @param {string} [reason] Reason for disconnecting the member from the channel + * @returns {Promise} + */ + disconnect(reason) { + return this.setChannel(null, reason); + } + + /** + * Moves the member to a different channel, or disconnects them from the one they're in. + * @param {GuildVoiceChannelResolvable|null} channel Channel to move the member to, or `null` if you want to + * disconnect them from voice. + * @param {string} [reason] Reason for moving member to another channel or disconnecting + * @returns {Promise} + */ + setChannel(channel, reason) { + return this.guild.members.edit(this.id, { channel }, reason); + } + + /** + * Toggles the request to speak in the channel. + * Only applicable for stage channels and for the client's own voice state. + * @param {boolean} [request=true] Whether or not the client is requesting to become a speaker. + * @example + * // Making the client request to speak in a stage channel (raise its hand) + * guild.me.voice.setRequestToSpeak(true); + * @example + * // Making the client cancel a request to speak + * guild.me.voice.setRequestToSpeak(false); + * @returns {Promise} + */ + async setRequestToSpeak(request = true) { + if (this.channel?.type !== ChannelType.GuildStageVoice) throw new Error('VOICE_NOT_STAGE_CHANNEL'); + + if (this.client.user.id !== this.id) throw new Error('VOICE_STATE_NOT_OWN'); + + await this.client.api.guilds(this.guild.id, 'voice-states', '@me').patch({ + body: { + channel_id: this.channelId, + request_to_speak_timestamp: request ? new Date().toISOString() : null, + } + }) + } + + /** + * Suppress/unsuppress the user. Only applicable for stage channels. + * @param {boolean} [suppressed=true] Whether or not the user should be suppressed. + * @example + * // Making the client a speaker + * guild.me.voice.setSuppressed(false); + * @example + * // Making the client an audience member + * guild.me.voice.setSuppressed(true); + * @example + * // Inviting another user to speak + * voiceState.setSuppressed(false); + * @example + * // Moving another user to the audience, or cancelling their invite to speak + * voiceState.setSuppressed(true); + * @returns {Promise} + */ + async setSuppressed(suppressed = true) { + if (typeof suppressed !== 'boolean') throw new TypeError('VOICE_STATE_INVALID_TYPE', 'suppressed'); + + if (this.channel?.type !== ChannelType.GuildStageVoice) throw new Error('VOICE_NOT_STAGE_CHANNEL'); + + const target = this.client.user.id === this.id ? '@me' : this.id; + + await this.client.api.guilds(this.guild.id, 'voice-states', target).patch({ + body: { + channel_id: this.channelId, + suppress: suppressed, + } + }); + } + + toJSON() { + return super.toJSON({ + id: true, + serverDeaf: true, + serverMute: true, + selfDeaf: true, + selfMute: true, + sessionId: true, + channelId: 'channel', + }); + } +} + +module.exports = VoiceState; diff --git a/src/structures/Webhook.js b/src/structures/Webhook.js new file mode 100644 index 00000000..3ae03f0 --- /dev/null +++ b/src/structures/Webhook.js @@ -0,0 +1,449 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { Routes, WebhookType } = require('discord-api-types/v9'); +const MessagePayload = require('./MessagePayload'); +const { Error } = require('../errors'); +const DataResolver = require('../util/DataResolver'); + +/** + * Represents a webhook. + */ +class Webhook { + constructor(client, data) { + /** + * The client that instantiated the webhook + * @name Webhook#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + if (data) this._patch(data); + } + + _patch(data) { + if ('name' in data) { + /** + * The name of the webhook + * @type {string} + */ + this.name = data.name; + } + + /** + * The token for the webhook, unavailable for follower webhooks and webhooks owned by another application. + * @name Webhook#token + * @type {?string} + */ + Object.defineProperty(this, 'token', { value: data.token ?? null, writable: true, configurable: true }); + + if ('avatar' in data) { + /** + * The avatar for the webhook + * @type {?string} + */ + this.avatar = data.avatar; + } + + /** + * The webhook's id + * @type {Snowflake} + */ + this.id = data.id; + + if ('type' in data) { + /** + * The type of the webhook + * @type {WebhookType} + */ + this.type = data.type; + } + + if ('guild_id' in data) { + /** + * The guild the webhook belongs to + * @type {Snowflake} + */ + this.guildId = data.guild_id; + } + + if ('channel_id' in data) { + /** + * The channel the webhook belongs to + * @type {Snowflake} + */ + this.channelId = data.channel_id; + } + + if ('user' in data) { + /** + * The owner of the webhook + * @type {?(User|APIUser)} + */ + this.owner = this.client.users?._add(data.user) ?? data.user; + } else { + this.owner ??= null; + } + + if ('application_id' in data) { + /** + * The application that created this webhook + * @type {?Snowflake} + */ + this.applicationId = data.application_id; + } else { + this.applicationId ??= null; + } + + if ('source_guild' in data) { + /** + * The source guild of the webhook + * @type {?(Guild|APIGuild)} + */ + this.sourceGuild = this.client.guilds?.resolve(data.source_guild.id) ?? data.source_guild; + } else { + this.sourceGuild ??= null; + } + + if ('source_channel' in data) { + /** + * The source channel of the webhook + * @type {?(NewsChannel|APIChannel)} + */ + this.sourceChannel = this.client.channels?.resolve(data.source_channel?.id) ?? data.source_channel; + } else { + this.sourceChannel ??= null; + } + } + + /** + * Options that can be passed into send. + * @typedef {BaseMessageOptions} WebhookMessageOptions + * @property {string} [username=this.name] Username override for the message + * @property {string} [avatarURL] Avatar URL override for the message + * @property {Snowflake} [threadId] The id of the thread in the channel to send to. + * For interaction webhooks, this property is ignored + * @property {MessageFlags} [flags] Which flags to set for the message. Only `SUPPRESS_EMBEDS` can be set. + */ + + /** + * Options that can be passed into editMessage. + * @typedef {Object} WebhookEditMessageOptions + * @property {Embed[]|APIEmbed[]} [embeds] See {@link WebhookMessageOptions#embeds} + * @property {string} [content] See {@link BaseMessageOptions#content} + * @property {FileOptions[]|BufferResolvable[]|MessageAttachment[]} [files] See {@link BaseMessageOptions#files} + * @property {MessageMentionOptions} [allowedMentions] See {@link BaseMessageOptions#allowedMentions} + * @property {MessageAttachment[]} [attachments] Attachments to send with the message + * @property {ActionRow[]|ActionRowOptions[]} [components] + * Action rows containing interactive components for the message (buttons, select menus) + * @property {Snowflake} [threadId] The id of the thread this message belongs to + * For interaction webhooks, this property is ignored + */ + + /** + * Sends a message with this webhook. + * @param {string|MessagePayload|WebhookMessageOptions} options The options to provide + * @returns {Promise} + * @example + * // Send a basic message + * webhook.send('hello!') + * .then(message => console.log(`Sent message: ${message.content}`)) + * .catch(console.error); + * @example + * // Send a basic message in a thread + * webhook.send({ content: 'hello!', threadId: '836856309672348295' }) + * .then(message => console.log(`Sent message: ${message.content}`)) + * .catch(console.error); + * @example + * // Send a remote file + * webhook.send({ + * files: ['https://cdn.discordapp.com/icons/222078108977594368/6e1019b3179d71046e463a75915e7244.png?size=2048'] + * }) + * .then(console.log) + * .catch(console.error); + * @example + * // Send a local file + * webhook.send({ + * files: [{ + * attachment: 'entire/path/to/file.jpg', + * name: 'file.jpg' + * }] + * }) + * .then(console.log) + * .catch(console.error); + * @example + * // Send an embed with a local image inside + * webhook.send({ + * content: 'This is an embed', + * embeds: [{ + * thumbnail: { + * url: 'attachment://file.jpg' + * } + * }], + * files: [{ + * attachment: 'entire/path/to/file.jpg', + * name: 'file.jpg' + * }] + * }) + * .then(console.log) + * .catch(console.error); + */ + async send(options) { + if (!this.token) throw new Error('WEBHOOK_TOKEN_UNAVAILABLE'); + + let messagePayload; + + if (options instanceof MessagePayload) { + messagePayload = options.resolveBody(); + } else { + messagePayload = MessagePayload.create(this, options).resolveBody(); + } + + const query = new URLSearchParams({ wait: true }); + + if (messagePayload.options.threadId) { + query.set('thread_id', messagePayload.options.threadId); + } + + const { body, files } = await messagePayload.resolveFiles(); + const d = await this.client.api.webhooks(this.id, this.token).post({ body, files, query, auth: false }); + return this.client.channels?.cache.get(d.channel_id)?.messages._add(d, false) ?? d; + } + + /** + * Sends a raw slack message with this webhook. + * @param {Object} body The raw body to send + * @returns {Promise} + * @example + * // Send a slack message + * webhook.sendSlackMessage({ + * 'username': 'Wumpus', + * 'attachments': [{ + * 'pretext': 'this looks pretty cool', + * 'color': '#F0F', + * 'footer_icon': 'http://snek.s3.amazonaws.com/topSnek.png', + * 'footer': 'Powered by sneks', + * 'ts': Date.now() / 1_000 + * }] + * }).catch(console.error); + * @see {@link https://api.slack.com/messaging/webhooks} + */ + async sendSlackMessage(body) { + if (!this.token) throw new Error('WEBHOOK_TOKEN_UNAVAILABLE'); + + const data = await this.client.api.webhooks(this.id, this.token).slack.post({ + query: new URLSearchParams({ wait: true }), + auth: false, + body, + }); + return data.toString() === 'ok'; + } + + /** + * Options used to edit a {@link Webhook}. + * @typedef {Object} WebhookEditData + * @property {string} [name=this.name] The new name for the webhook + * @property {?(BufferResolvable)} [avatar] The new avatar for the webhook + * @property {GuildTextChannelResolvable} [channel] The new channel for the webhook + */ + + /** + * Edits this webhook. + * @param {WebhookEditData} options Options for editing the webhook + * @param {string} [reason] Reason for editing the webhook + * @returns {Promise} + */ + async edit({ name = this.name, avatar, channel }, reason) { + if (avatar && !(typeof avatar === 'string' && avatar.startsWith('data:'))) { + avatar = await DataResolver.resolveImage(avatar); + } + channel &&= channel.id ?? channel; + const data = await this.client.api.webhooks(this.id, channel ? undefined : this.token).patch({ + data: { name, avatar, channel_id: channel }, + reason, + auth: !this.token || Boolean(channel), + }); + + this.name = data.name; + this.avatar = data.avatar; + this.channelId = data.channel_id; + return this; + } + + /** + * Options that can be passed into fetchMessage. + * @typedef {options} WebhookFetchMessageOptions + * @property {boolean} [cache=true] Whether to cache the message. + * @property {Snowflake} [threadId] The id of the thread this message belongs to. + * For interaction webhooks, this property is ignored + */ + + /** + * Gets a message that was sent by this webhook. + * @param {Snowflake|'@original'} message The id of the message to fetch + * @param {WebhookFetchMessageOptions} [options={}] The options to provide to fetch the message. + * @returns {Promise} Returns the raw message data if the webhook was instantiated as a + * {@link WebhookClient} or if the channel is uncached, otherwise a {@link Message} will be returned + */ + async fetchMessage(message, { cache = true, threadId } = {}) { + if (!this.token) throw new Error('WEBHOOK_TOKEN_UNAVAILABLE'); + + const data = await this.client.api.webhooks(this.id, this.token).messages(message).get({ + query: threadId + ? new URLSearchParams({ + thread_id: threadId, + }) + : undefined, + auth: false + }); + return this.client.channels?.cache.get(data.channel_id)?.messages._add(data, cache) ?? data; + } + + /** + * Edits a message that was sent by this webhook. + * @param {MessageResolvable|'@original'} message The message to edit + * @param {string|MessagePayload|WebhookEditMessageOptions} options The options to provide + * @returns {Promise} Returns the raw message data if the webhook was instantiated as a + * {@link WebhookClient} or if the channel is uncached, otherwise a {@link Message} will be returned + */ + async editMessage(message, options) { + if (!this.token) throw new Error('WEBHOOK_TOKEN_UNAVAILABLE'); + + let messagePayload; + + if (options instanceof MessagePayload) messagePayload = options; + else messagePayload = MessagePayload.create(this, options); + + const { body, files } = await messagePayload.resolveBody().resolveFiles(); + + const d = await this.client.api.webhooks(this.id, this.token).messages(typeof message === 'string' ? message : message.id).patch({ + body, files, query: messagePayload.options.threadId ? new URLSearchParams({ thread_id: messagePayload.options.threadId }) : undefined, auth: false + }); + + const messageManager = this.client.channels?.cache.get(d.channel_id)?.messages; + if (!messageManager) return d; + + const existing = messageManager.cache.get(d.id); + if (!existing) return messageManager._add(d); + + const clone = existing._clone(); + clone._patch(d); + return clone; + } + + /** + * Deletes the webhook. + * @param {string} [reason] Reason for deleting this webhook + * @returns {Promise} + */ + async delete(reason) { + await this.client.api.webhooks(this.id, this.token).delete({ reason, auth: !this.token }); + } + + /** + * Delete a message that was sent by this webhook. + * @param {MessageResolvable|'@original'} message The message to delete + * @param {Snowflake} [threadId] The id of the thread this message belongs to + * @returns {Promise} + */ + async deleteMessage(message, threadId) { + if (!this.token) throw new Error('WEBHOOK_TOKEN_UNAVAILABLE'); + + await this.client.api.webhooks(this.id, this.token).messages(typeof message === 'string' ? message : message.id ).delete({ + query: threadId + ? new URLSearchParams({ + thread_id: threadId, + }) + : undefined, + auth: false, + }) + } + + /** + * The timestamp the webhook was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the webhook was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The URL of this webhook + * @type {string} + * @readonly + */ + get url() { + return this.client.options.rest.api + Routes.webhook(this.id, this.token); + } + + /** + * A link to the webhook's avatar. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + avatarURL(options = {}) { + return this.avatar && this.client.rest.cdn.avatar(this.id, this.avatar, options); + } + + /** + * Whether this webhook is created by a user. + * @returns {boolean} + */ + isUserCreated() { + return Boolean(this.type === WebhookType.Incoming && this.owner && !this.owner.bot); + } + + /** + * Whether this webhook is created by an application. + * @returns {boolean} + */ + isApplicationCreated() { + return this.type === WebhookType.Application; + } + + /** + * Whether or not this webhook is a channel follower webhook. + * @returns {boolean} + */ + isChannelFollower() { + return this.type === WebhookType.ChannelFollower; + } + + /** + * Whether or not this webhook is an incoming webhook. + * @returns {boolean} + */ + isIncoming() { + return this.type === WebhookType.Incoming; + } + + static applyToClass(structure, ignore = []) { + for (const prop of [ + 'send', + 'sendSlackMessage', + 'fetchMessage', + 'edit', + 'editMessage', + 'delete', + 'deleteMessage', + 'createdTimestamp', + 'createdAt', + 'url', + ]) { + if (ignore.includes(prop)) continue; + Object.defineProperty(structure.prototype, prop, Object.getOwnPropertyDescriptor(Webhook.prototype, prop)); + } + } +} + +module.exports = Webhook; diff --git a/src/structures/WelcomeChannel.js b/src/structures/WelcomeChannel.js new file mode 100644 index 00000000..0741ab7 --- /dev/null +++ b/src/structures/WelcomeChannel.js @@ -0,0 +1,60 @@ +'use strict'; + +const Base = require('./Base'); +const { Emoji } = require('./Emoji'); + +/** + * Represents a channel link in a guild's welcome screen. + * @extends {Base} + */ +class WelcomeChannel extends Base { + constructor(guild, data) { + super(guild.client); + + /** + * The guild for this welcome channel + * @type {Guild|InviteGuild} + */ + this.guild = guild; + + /** + * The description of this welcome channel + * @type {string} + */ + this.description = data.description; + + /** + * The raw emoji data + * @type {Object} + * @private + */ + this._emoji = { + name: data.emoji_name, + id: data.emoji_id, + }; + + /** + * The id of this welcome channel + * @type {Snowflake} + */ + this.channelId = data.channel_id; + } + + /** + * The channel of this welcome channel + * @type {?(TextChannel|NewsChannel|StoreChannel)} + */ + get channel() { + return this.client.channels.resolve(this.channelId); + } + + /** + * The emoji of this welcome channel + * @type {GuildEmoji|Emoji} + */ + get emoji() { + return this.client.emojis.resolve(this._emoji.id) ?? new Emoji(this.client, this._emoji); + } +} + +module.exports = WelcomeChannel; diff --git a/src/structures/WelcomeScreen.js b/src/structures/WelcomeScreen.js new file mode 100644 index 00000000..b4b7449 --- /dev/null +++ b/src/structures/WelcomeScreen.js @@ -0,0 +1,48 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Base = require('./Base'); +const WelcomeChannel = require('./WelcomeChannel'); + +/** + * Represents a welcome screen. + * @extends {Base} + */ +class WelcomeScreen extends Base { + constructor(guild, data) { + super(guild.client); + + /** + * The guild for this welcome screen + * @type {Guild} + */ + this.guild = guild; + + /** + * The description of this welcome screen + * @type {?string} + */ + this.description = data.description ?? null; + + /** + * Collection of welcome channels belonging to this welcome screen + * @type {Collection} + */ + this.welcomeChannels = new Collection(); + + for (const channel of data.welcome_channels) { + const welcomeChannel = new WelcomeChannel(this.guild, channel); + this.welcomeChannels.set(welcomeChannel.channelId, welcomeChannel); + } + } + + /** + * Whether the welcome screen is enabled on the guild or not + * @type {boolean} + */ + get enabled() { + return this.guild.features.includes('WELCOME_SCREEN_ENABLED'); + } +} + +module.exports = WelcomeScreen; diff --git a/src/structures/Widget.js b/src/structures/Widget.js new file mode 100644 index 00000000..bf6b725 --- /dev/null +++ b/src/structures/Widget.js @@ -0,0 +1,87 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v9'); +const Base = require('./Base'); +const WidgetMember = require('./WidgetMember'); + +/** + * Represents a Widget. + */ +class Widget extends Base { + constructor(client, data) { + super(client); + this._patch(data); + } + + /** + * Represents a channel in a Widget + * @typedef {Object} WidgetChannel + * @property {Snowflake} id Id of the channel + * @property {string} name Name of the channel + * @property {number} position Position of the channel + */ + + _patch(data) { + /** + * The id of the guild. + * @type {Snowflake} + */ + this.id = data.id; + + if ('name' in data) { + /** + * The name of the guild. + * @type {string} + */ + this.name = data.name; + } + + if ('instant_invite' in data) { + /** + * The invite of the guild. + * @type {?string} + */ + this.instantInvite = data.instant_invite; + } + + /** + * The list of channels in the guild. + * @type {Collection} + */ + this.channels = new Collection(); + for (const channel of data.channels) { + this.channels.set(channel.id, channel); + } + + /** + * The list of members in the guild. + * These strings are just arbitrary numbers, they aren't Snowflakes. + * @type {Collection} + */ + this.members = new Collection(); + for (const member of data.members) { + this.members.set(member.id, new WidgetMember(this.client, member)); + } + + if ('presence_count' in data) { + /** + * The number of members online. + * @type {number} + */ + this.presenceCount = data.presence_count; + } + } + + /** + * Update the Widget. + * @returns {Promise} + */ + async fetch() { + const data = await this.client.api.guilds(this.id, 'widget.json').get(); + this._patch(data); + return this; + } +} + +module.exports = Widget; diff --git a/src/structures/WidgetMember.js b/src/structures/WidgetMember.js new file mode 100644 index 00000000..472d9e2 --- /dev/null +++ b/src/structures/WidgetMember.js @@ -0,0 +1,98 @@ +'use strict'; + +const Base = require('./Base'); + +/** + * Represents a WidgetMember. + */ +class WidgetMember extends Base { + /** + * Activity sent in a {@link WidgetMember}. + * @typedef {Object} WidgetActivity + * @property {string} name The name of the activity + */ + + constructor(client, data) { + super(client); + + /** + * The id of the user. It's an arbitrary number. + * @type {string} + */ + this.id = data.id; + + /** + * The username of the member. + * @type {string} + */ + this.username = data.username; + + /** + * The discriminator of the member. + * @type {string} + */ + this.discriminator = data.discriminator; + + /** + * The avatar of the member. + * @type {?string} + */ + this.avatar = data.avatar; + + /** + * The status of the member. + * @type {PresenceStatus} + */ + this.status = data.status; + + /** + * If the member is server deafened + * @type {?boolean} + */ + this.deaf = data.deaf ?? null; + + /** + * If the member is server muted + * @type {?boolean} + */ + this.mute = data.mute ?? null; + + /** + * If the member is self deafened + * @type {?boolean} + */ + this.selfDeaf = data.self_deaf ?? null; + + /** + * If the member is self muted + * @type {?boolean} + */ + this.selfMute = data.self_mute ?? null; + + /** + * If the member is suppressed + * @type {?boolean} + */ + this.suppress = data.suppress ?? null; + + /** + * The id of the voice channel the member is in, if any + * @type {?Snowflake} + */ + this.channelId = data.channel_id ?? null; + + /** + * The avatar URL of the member. + * @type {string} + */ + this.avatarURL = data.avatar_url; + + /** + * The activity of the member. + * @type {?WidgetActivity} + */ + this.activity = data.activity ?? null; + } +} + +module.exports = WidgetMember; diff --git a/src/structures/interfaces/Application.js b/src/structures/interfaces/Application.js new file mode 100644 index 00000000..766da07 --- /dev/null +++ b/src/structures/interfaces/Application.js @@ -0,0 +1,109 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const Base = require('../Base'); + +/** + * Represents an OAuth2 Application. + * @abstract + */ +class Application extends Base { + constructor(client, data) { + super(client); + this._patch(data); + } + + _patch(data) { + if(!data) return; + + /** + * The application's id + * @type {Snowflake} + */ + this.id = data.id; + + if ('name' in data) { + /** + * The name of the application + * @type {?string} + */ + this.name = data.name; + } else { + this.name ??= null; + } + + if ('description' in data) { + /** + * The application's description + * @type {?string} + */ + this.description = data.description; + } else { + this.description ??= null; + } + + if ('icon' in data) { + /** + * The application's icon hash + * @type {?string} + */ + this.icon = data.icon; + } else { + this.icon ??= null; + } + } + + /** + * The timestamp the application was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the application was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * A link to the application's icon. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + iconURL(options = {}) { + return this.icon && this.client.rest.cdn.appIcon(this.id, this.icon, options); + } + + /** + * A link to this application's cover image. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + coverURL(options = {}) { + return this.cover && this.client.rest.cdn.appIcon(this.id, this.cover, options); + } + + /** + * When concatenated with a string, this automatically returns the application's name instead of the + * Application object. + * @returns {?string} + * @example + * // Logs: Application name: My App + * console.log(`Application name: ${application}`); + */ + toString() { + return this.name; + } + + toJSON() { + return super.toJSON({ createdTimestamp: true }); + } +} + +module.exports = Application; diff --git a/src/structures/interfaces/Collector.js b/src/structures/interfaces/Collector.js new file mode 100644 index 00000000..20cb71e --- /dev/null +++ b/src/structures/interfaces/Collector.js @@ -0,0 +1,299 @@ +'use strict'; + +const EventEmitter = require('node:events'); +const { setTimeout, clearTimeout } = require('node:timers'); +const { Collection } = require('@discordjs/collection'); +const { TypeError } = require('../../errors'); +const Util = require('../../util/Util'); + +/** + * Filter to be applied to the collector. + * @typedef {Function} CollectorFilter + * @param {...*} args Any arguments received by the listener + * @param {Collection} collection The items collected by this collector + * @returns {boolean|Promise} + */ + +/** + * Options to be applied to the collector. + * @typedef {Object} CollectorOptions + * @property {CollectorFilter} [filter] The filter applied to this collector + * @property {number} [time] How long to run the collector for in milliseconds + * @property {number} [idle] How long to stop the collector after inactivity in milliseconds + * @property {boolean} [dispose=false] Whether to dispose data when it's deleted + */ + +/** + * Abstract class for defining a new Collector. + * @abstract + */ +class Collector extends EventEmitter { + constructor(client, options = {}) { + super(); + + /** + * The client that instantiated this Collector + * @name Collector#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * The filter applied to this collector + * @type {CollectorFilter} + * @returns {boolean|Promise} + */ + this.filter = options.filter ?? (() => true); + + /** + * The options of this collector + * @type {CollectorOptions} + */ + this.options = options; + + /** + * The items collected by this collector + * @type {Collection} + */ + this.collected = new Collection(); + + /** + * Whether this collector has finished collecting + * @type {boolean} + */ + this.ended = false; + + /** + * Timeout for cleanup + * @type {?Timeout} + * @private + */ + this._timeout = null; + + /** + * Timeout for cleanup due to inactivity + * @type {?Timeout} + * @private + */ + this._idletimeout = null; + + if (typeof this.filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'options.filter', 'function'); + } + + this.handleCollect = this.handleCollect.bind(this); + this.handleDispose = this.handleDispose.bind(this); + + if (options.time) this._timeout = setTimeout(() => this.stop('time'), options.time).unref(); + if (options.idle) this._idletimeout = setTimeout(() => this.stop('idle'), options.idle).unref(); + } + + /** + * Call this to handle an event as a collectable element. Accepts any event data as parameters. + * @param {...*} args The arguments emitted by the listener + * @returns {Promise} + * @emits Collector#collect + */ + async handleCollect(...args) { + const collect = await this.collect(...args); + + if (collect && (await this.filter(...args, this.collected))) { + this.collected.set(collect, args[0]); + + /** + * Emitted whenever an element is collected. + * @event Collector#collect + * @param {...*} args The arguments emitted by the listener + */ + this.emit('collect', ...args); + + if (this._idletimeout) { + clearTimeout(this._idletimeout); + this._idletimeout = setTimeout(() => this.stop('idle'), this.options.idle).unref(); + } + } + this.checkEnd(); + } + + /** + * Call this to remove an element from the collection. Accepts any event data as parameters. + * @param {...*} args The arguments emitted by the listener + * @returns {Promise} + * @emits Collector#dispose + */ + async handleDispose(...args) { + if (!this.options.dispose) return; + + const dispose = this.dispose(...args); + if (!dispose || !(await this.filter(...args)) || !this.collected.has(dispose)) return; + this.collected.delete(dispose); + + /** + * Emitted whenever an element is disposed of. + * @event Collector#dispose + * @param {...*} args The arguments emitted by the listener + */ + this.emit('dispose', ...args); + this.checkEnd(); + } + + /** + * Returns a promise that resolves with the next collected element; + * rejects with collected elements if the collector finishes without receiving a next element + * @type {Promise} + * @readonly + */ + get next() { + return new Promise((resolve, reject) => { + if (this.ended) { + reject(this.collected); + return; + } + + const cleanup = () => { + this.removeListener('collect', onCollect); + this.removeListener('end', onEnd); + }; + + const onCollect = item => { + cleanup(); + resolve(item); + }; + + const onEnd = () => { + cleanup(); + reject(this.collected); // eslint-disable-line prefer-promise-reject-errors + }; + + this.on('collect', onCollect); + this.on('end', onEnd); + }); + } + + /** + * Stops this collector and emits the `end` event. + * @param {string} [reason='user'] The reason this collector is ending + * @emits Collector#end + */ + stop(reason = 'user') { + if (this.ended) return; + + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + if (this._idletimeout) { + clearTimeout(this._idletimeout); + this._idletimeout = null; + } + this.ended = true; + + /** + * Emitted when the collector is finished collecting. + * @event Collector#end + * @param {Collection} collected The elements collected by the collector + * @param {string} reason The reason the collector ended + */ + this.emit('end', this.collected, reason); + } + + /** + * Options used to reset the timeout and idle timer of a {@link Collector}. + * @typedef {Object} CollectorResetTimerOptions + * @property {number} [time] How long to run the collector for (in milliseconds) + * @property {number} [idle] How long to wait to stop the collector after inactivity (in milliseconds) + */ + + /** + * Resets the collector's timeout and idle timer. + * @param {CollectorResetTimerOptions} [options] Options for resetting + */ + resetTimer({ time, idle } = {}) { + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = setTimeout(() => this.stop('time'), time ?? this.options.time).unref(); + } + if (this._idletimeout) { + clearTimeout(this._idletimeout); + this._idletimeout = setTimeout(() => this.stop('idle'), idle ?? this.options.idle).unref(); + } + } + + /** + * Checks whether the collector should end, and if so, ends it. + * @returns {boolean} Whether the collector ended or not + */ + checkEnd() { + const reason = this.endReason; + if (reason) this.stop(reason); + return Boolean(reason); + } + + /** + * Allows collectors to be consumed with for-await-of loops + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of} + */ + async *[Symbol.asyncIterator]() { + const queue = []; + const onCollect = (...item) => queue.push(item); + this.on('collect', onCollect); + + try { + while (queue.length || !this.ended) { + if (queue.length) { + yield queue.shift(); + } else { + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => { + const tick = () => { + this.removeListener('collect', tick); + this.removeListener('end', tick); + return resolve(); + }; + this.on('collect', tick); + this.on('end', tick); + }); + } + } + } finally { + this.removeListener('collect', onCollect); + } + } + + toJSON() { + return Util.flatten(this); + } + + /* eslint-disable no-empty-function */ + /** + * The reason this collector has ended with, or null if it hasn't ended yet + * @type {?string} + * @readonly + * @abstract + */ + get endReason() {} + + /** + * Handles incoming events from the `handleCollect` function. Returns null if the event should not + * be collected, or returns an object describing the data that should be stored. + * @see Collector#handleCollect + * @param {...*} args Any args the event listener emits + * @returns {?(*|Promise)} Data to insert into collection, if any + * @abstract + */ + collect() {} + + /** + * Handles incoming events from the `handleDispose`. Returns null if the event should not + * be disposed, or returns the key that should be removed. + * @see Collector#handleDispose + * @param {...*} args Any args the event listener emits + * @returns {?*} Key to remove from the collection, if any + * @abstract + */ + dispose() {} + /* eslint-enable no-empty-function */ +} + +module.exports = Collector; diff --git a/src/structures/interfaces/InteractionResponses.js b/src/structures/interfaces/InteractionResponses.js new file mode 100644 index 00000000..15c8830 --- /dev/null +++ b/src/structures/interfaces/InteractionResponses.js @@ -0,0 +1,251 @@ +'use strict'; + +const { InteractionResponseType, MessageFlags, Routes } = require('discord-api-types/v9'); +const { Error } = require('../../errors'); +const MessagePayload = require('../MessagePayload'); + +/** + * Interface for classes that support shared interaction response types. + * @interface + */ +class InteractionResponses { + /** + * Options for deferring the reply to an {@link Interaction}. + * @typedef {Object} InteractionDeferReplyOptions + * @property {boolean} [ephemeral] Whether the reply should be ephemeral + * @property {boolean} [fetchReply] Whether to fetch the reply + */ + + /** + * Options for deferring and updating the reply to a {@link MessageComponentInteraction}. + * @typedef {Object} InteractionDeferUpdateOptions + * @property {boolean} [fetchReply] Whether to fetch the reply + */ + + /** + * Options for a reply to an {@link Interaction}. + * @typedef {BaseMessageOptions} InteractionReplyOptions + * @property {boolean} [ephemeral] Whether the reply should be ephemeral + * @property {boolean} [fetchReply] Whether to fetch the reply + * @property {MessageFlags} [flags] Which flags to set for the message. + * Only `MessageFlags.SuppressEmbeds` and `MessageFlags.Ephemeral` can be set. + */ + + /** + * Options for updating the message received from a {@link MessageComponentInteraction}. + * @typedef {MessageEditOptions} InteractionUpdateOptions + * @property {boolean} [fetchReply] Whether to fetch the reply + */ + + /** + * Defers the reply to this interaction. + * @param {InteractionDeferReplyOptions} [options] Options for deferring the reply to this interaction + * @returns {Promise} + * @example + * // Defer the reply to this interaction + * interaction.deferReply() + * .then(console.log) + * .catch(console.error) + * @example + * // Defer to send an ephemeral reply later + * interaction.deferReply({ ephemeral: true }) + * .then(console.log) + * .catch(console.error); + */ + async deferReply(options = {}) { + if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); + this.ephemeral = options.ephemeral ?? false; + await this.client.api.interactions(this.id, this.token).callback.post({ + body: { + type: InteractionResponseType.DeferredChannelMessageWithSource, + data: { + flags: options.ephemeral ? MessageFlags.Ephemeral : undefined, + }, + }, + auth: false, + }) + this.deferred = true; + + return options.fetchReply ? this.fetchReply() : undefined; + } + + /** + * Creates a reply to this interaction. + * Use the `fetchReply` option to get the bot's reply message. + * @param {string|MessagePayload|InteractionReplyOptions} options The options for the reply + * @returns {Promise} + * @example + * // Reply to the interaction and fetch the response + * interaction.reply({ content: 'Pong!', fetchReply: true }) + * .then((message) => console.log(`Reply sent with content ${message.content}`)) + * .catch(console.error); + * @example + * // Create an ephemeral reply with an embed + * const embed = new Embed().setDescription('Pong!'); + * + * interaction.reply({ embeds: [embed], ephemeral: true }) + * .then(() => console.log('Reply sent.')) + * .catch(console.error); + */ + async reply(options) { + if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); + this.ephemeral = options.ephemeral ?? false; + + let messagePayload; + if (options instanceof MessagePayload) messagePayload = options; + else messagePayload = MessagePayload.create(this, options); + + const { body: data, files } = await messagePayload.resolveBody().resolveFiles(); + + await this.client.api.interactions(this.id, this.token).callback.post({ + body: { + type: InteractionResponseType.ChannelMessageWithSource, + data, + }, + files, + auth: false, + }); + this.replied = true; + + return options.fetchReply ? this.fetchReply() : undefined; + } + + /** + * Fetches the initial reply to this interaction. + * @see Webhook#fetchMessage + * @returns {Promise} + * @example + * // Fetch the reply to this interaction + * interaction.fetchReply() + * .then(reply => console.log(`Replied with ${reply.content}`)) + * .catch(console.error); + */ + fetchReply() { + return this.webhook.fetchMessage('@original'); + } + + /** + * Edits the initial reply to this interaction. + * @see Webhook#editMessage + * @param {string|MessagePayload|WebhookEditMessageOptions} options The new options for the message + * @returns {Promise} + * @example + * // Edit the reply to this interaction + * interaction.editReply('New content') + * .then(console.log) + * .catch(console.error); + */ + async editReply(options) { + if (!this.deferred && !this.replied) throw new Error('INTERACTION_NOT_REPLIED'); + const message = await this.webhook.editMessage('@original', options); + this.replied = true; + return message; + } + + /** + * Deletes the initial reply to this interaction. + * @see Webhook#deleteMessage + * @returns {Promise} + * @example + * // Delete the reply to this interaction + * interaction.deleteReply() + * .then(console.log) + * .catch(console.error); + */ + async deleteReply() { + if (this.ephemeral) throw new Error('INTERACTION_EPHEMERAL_REPLIED'); + await this.webhook.deleteMessage('@original'); + } + + /** + * Send a follow-up message to this interaction. + * @param {string|MessagePayload|InteractionReplyOptions} options The options for the reply + * @returns {Promise} + */ + followUp(options) { + if (!this.deferred && !this.replied) return Promise.reject(new Error('INTERACTION_NOT_REPLIED')); + return this.webhook.send(options); + } + + /** + * Defers an update to the message to which the component was attached. + * @param {InteractionDeferUpdateOptions} [options] Options for deferring the update to this interaction + * @returns {Promise} + * @example + * // Defer updating and reset the component's loading state + * interaction.deferUpdate() + * .then(console.log) + * .catch(console.error); + */ + async deferUpdate(options = {}) { + if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); + await this.client.api.interactions(this.id, this.token).callback.post({ + body: { + type: InteractionResponseType.DeferredMessageUpdate, + }, + auth: false, + }); + this.deferred = true; + + return options.fetchReply ? this.fetchReply() : undefined; + } + + /** + * Updates the original message of the component on which the interaction was received on. + * @param {string|MessagePayload|InteractionUpdateOptions} options The options for the updated message + * @returns {Promise} + * @example + * // Remove the components from the message + * interaction.update({ + * content: "A component interaction was received", + * components: [] + * }) + * .then(console.log) + * .catch(console.error); + */ + async update(options) { + if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); + + let messagePayload; + if (options instanceof MessagePayload) messagePayload = options; + else messagePayload = MessagePayload.create(this, options); + + const { body: data, files } = await messagePayload.resolveBody().resolveFiles(); + + await this.client.api.interactions(this.id, this.token).callback.post({ + body: { + type: InteractionResponseType.UpdateMessage, + data, + }, + files, + auth: false, + }); + this.replied = true; + + return options.fetchReply ? this.fetchReply() : undefined; + } + + static applyToClass(structure, ignore = []) { + const props = [ + 'deferReply', + 'reply', + 'fetchReply', + 'editReply', + 'deleteReply', + 'followUp', + 'deferUpdate', + 'update', + ]; + + for (const prop of props) { + if (ignore.includes(prop)) continue; + Object.defineProperty( + structure.prototype, + prop, + Object.getOwnPropertyDescriptor(InteractionResponses.prototype, prop), + ); + } + } +} + +module.exports = InteractionResponses; diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js new file mode 100644 index 00000000..ebca69d --- /dev/null +++ b/src/structures/interfaces/TextBasedChannel.js @@ -0,0 +1,363 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { InteractionType, Routes } = require('discord-api-types/v9'); +const { TypeError, Error } = require('../../errors'); +const InteractionCollector = require('../InteractionCollector'); +const MessageCollector = require('../MessageCollector'); +const MessagePayload = require('../MessagePayload'); + +/** + * Interface for classes that have text-channel-like features. + * @interface + */ +class TextBasedChannel { + constructor() { + /** + * A manager of the messages sent to this channel + * @type {MessageManager} + */ + this.messages = new MessageManager(this); + + /** + * The channel's last message id, if one was sent + * @type {?Snowflake} + */ + this.lastMessageId = null; + + /** + * The timestamp when the last pinned message was pinned, if there was one + * @type {?number} + */ + this.lastPinTimestamp = null; + } + + /** + * The Message object of the last message in the channel, if one was sent + * @type {?Message} + * @readonly + */ + get lastMessage() { + return this.messages.resolve(this.lastMessageId); + } + + /** + * The date when the last pinned message was pinned, if there was one + * @type {?Date} + * @readonly + */ + get lastPinAt() { + return this.lastPinTimestamp && new Date(this.lastPinTimestamp); + } + + /** + * Base options provided when sending. + * @typedef {Object} BaseMessageOptions + * @property {boolean} [tts=false] Whether or not the message should be spoken aloud + * @property {string} [nonce=''] The nonce for the message + * @property {string} [content=''] The content for the message + * @property {Embed[]|APIEmbed[]} [embeds] The embeds for the message + * (see [here](https://discord.com/developers/docs/resources/channel#embed-object) for more details) + * @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content + * (see [here](https://discord.com/developers/docs/resources/channel#allowed-mentions-object) for more details) + * @property {FileOptions[]|BufferResolvable[]|MessageAttachment[]} [files] Files to send with the message + * @property {ActionRow[]|ActionRowOptions[]} [components] + * Action rows containing interactive components for the message (buttons, select menus) + * @property {MessageAttachment[]} [attachments] Attachments to send in the message + */ + + /** + * Options provided when sending or editing a message. + * @typedef {BaseMessageOptions} MessageOptions + * @property {ReplyOptions} [reply] The options for replying to a message + * @property {StickerResolvable[]} [stickers=[]] Stickers to send in the message + * @property {MessageFlags} [flags] Which flags to set for the message. Only `MessageFlags.SuppressEmbeds` can be set. + */ + + /** + * Options provided to control parsing of mentions by Discord + * @typedef {Object} MessageMentionOptions + * @property {MessageMentionTypes[]} [parse] Types of mentions to be parsed + * @property {Snowflake[]} [users] Snowflakes of Users to be parsed as mentions + * @property {Snowflake[]} [roles] Snowflakes of Roles to be parsed as mentions + * @property {boolean} [repliedUser=true] Whether the author of the Message being replied to should be pinged + */ + + /** + * Types of mentions to enable in MessageMentionOptions. + * - `roles` + * - `users` + * - `everyone` + * @typedef {string} MessageMentionTypes + */ + + /** + * @typedef {Object} FileOptions + * @property {BufferResolvable} attachment File to attach + * @property {string} [name='file.jpg'] Filename of the attachment + * @property {string} description The description of the file + */ + + /** + * Options for sending a message with a reply. + * @typedef {Object} ReplyOptions + * @property {MessageResolvable} messageReference The message to reply to (must be in the same channel and not system) + * @property {boolean} [failIfNotExists=this.client.options.failIfNotExists] Whether to error if the referenced + * message does not exist (creates a standard message in this case when false) + */ + + /** + * Sends a message to this channel. + * @param {string|MessagePayload|MessageOptions} options The options to provide + * @returns {Promise} + * @example + * // Send a basic message + * channel.send('hello!') + * .then(message => console.log(`Sent message: ${message.content}`)) + * .catch(console.error); + * @example + * // Send a remote file + * channel.send({ + * files: ['https://cdn.discordapp.com/icons/222078108977594368/6e1019b3179d71046e463a75915e7244.png?size=2048'] + * }) + * .then(console.log) + * .catch(console.error); + * @example + * // Send a local file + * channel.send({ + * files: [{ + * attachment: 'entire/path/to/file.jpg', + * name: 'file.jpg', + * description: 'A description of the file' + * }] + * }) + * .then(console.log) + * .catch(console.error); + * @example + * // Send an embed with a local image inside + * channel.send({ + * content: 'This is an embed', + * embeds: [ + * { + * thumbnail: { + * url: 'attachment://file.jpg' + * } + * } + * ], + * files: [{ + * attachment: 'entire/path/to/file.jpg', + * name: 'file.jpg', + * description: 'A description of the file' + * }] + * }) + * .then(console.log) + * .catch(console.error); + */ + async send(options) { + await this.client.api.channels(this.id).typing.post(); + const User = require('../User'); + const { GuildMember } = require('../GuildMember'); + + if (this instanceof User || this instanceof GuildMember) { + const dm = await this.createDM(); + return dm.send(options); + } + + let messagePayload; + + if (options instanceof MessagePayload) { + messagePayload = options.resolveBody(); + } else { + messagePayload = MessagePayload.create(this, options).resolveBody(); + } + + const { body, files } = await messagePayload.resolveFiles(); + const d = await this.client.api.channels[this.id].messages.post({ body, files }); + + await this.client.api.channels(this.id).typing.delete(); + return this.messages.cache.get(d.id) ?? this.messages._add(d); + } + + /** + * Sends a typing indicator in the channel. + * @returns {Promise} Resolves upon the typing status being sent + * @example + * // Start typing in a channel + * channel.sendTyping(); + */ + async sendTyping() { + await this.client.api.channels(this.id).typing.post(); + } + + /** + * Creates a Message Collector. + * @param {MessageCollectorOptions} [options={}] The options to pass to the collector + * @returns {MessageCollector} + * @example + * // Create a message collector + * const filter = m => m.content.includes('discord'); + * const collector = channel.createMessageCollector({ filter, time: 15_000 }); + * collector.on('collect', m => console.log(`Collected ${m.content}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createMessageCollector(options = {}) { + return new MessageCollector(this, options); + } + + /** + * An object containing the same properties as CollectorOptions, but a few more: + * @typedef {MessageCollectorOptions} AwaitMessagesOptions + * @property {string[]} [errors] Stop/end reasons that cause the promise to reject + */ + + /** + * Similar to createMessageCollector but in promise form. + * Resolves with a collection of messages that pass the specified filter. + * @param {AwaitMessagesOptions} [options={}] Optional options to pass to the internal collector + * @returns {Promise>} + * @example + * // Await !vote messages + * const filter = m => m.content.startsWith('!vote'); + * // Errors: ['time'] treats ending because of the time limit as an error + * channel.awaitMessages({ filter, max: 4, time: 60_000, errors: ['time'] }) + * .then(collected => console.log(collected.size)) + * .catch(collected => console.log(`After a minute, only ${collected.size} out of 4 voted.`)); + */ + awaitMessages(options = {}) { + return new Promise((resolve, reject) => { + const collector = this.createMessageCollector(options); + collector.once('end', (collection, reason) => { + if (options.errors?.includes(reason)) { + reject(collection); + } else { + resolve(collection); + } + }); + }); + } + + /** + * Creates a component interaction collector. + * @param {MessageComponentCollectorOptions} [options={}] Options to send to the collector + * @returns {InteractionCollector} + * @example + * // Create a button interaction collector + * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; + * const collector = channel.createMessageComponentCollector({ filter, time: 15_000 }); + * collector.on('collect', i => console.log(`Collected ${i.customId}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createMessageComponentCollector(options = {}) { + return new InteractionCollector(this.client, { + ...options, + interactionType: InteractionType.MessageComponent, + channel: this, + }); + } + + /** + * Collects a single component interaction that passes the filter. + * The Promise will reject if the time expires. + * @param {AwaitMessageComponentOptions} [options={}] Options to pass to the internal collector + * @returns {Promise} + * @example + * // Collect a message component interaction + * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; + * channel.awaitMessageComponent({ filter, time: 15_000 }) + * .then(interaction => console.log(`${interaction.customId} was clicked!`)) + * .catch(console.error); + */ + awaitMessageComponent(options = {}) { + const _options = { ...options, max: 1 }; + return new Promise((resolve, reject) => { + const collector = this.createMessageComponentCollector(_options); + collector.once('end', (interactions, reason) => { + const interaction = interactions.first(); + if (interaction) resolve(interaction); + else reject(new Error('INTERACTION_COLLECTOR_ERROR', reason)); + }); + }); + } + + /** + * Bulk deletes given messages that are newer than two weeks. + * @param {Collection|MessageResolvable[]|number} messages + * Messages or number of messages to delete + * @param {boolean} [filterOld=false] Filter messages to remove those which are older than two weeks automatically + * @returns {Promise>} Returns the deleted messages + * @example + * // Bulk delete messages + * channel.bulkDelete(5) + * .then(messages => console.log(`Bulk deleted ${messages.size} messages`)) + * .catch(console.error); + */ + async bulkDelete(messages, filterOld = false) { + if (Array.isArray(messages) || messages instanceof Collection) { + let messageIds = messages instanceof Collection ? [...messages.keys()] : messages.map(m => m.id ?? m); + if (filterOld) { + messageIds = messageIds.filter(id => Date.now() - DiscordSnowflake.timestampFrom(id) < 1_209_600_000); + } + if (messageIds.length === 0) return new Collection(); + if (messageIds.length === 1) { + await this.client.api.channels(this.id).messages(messageIds[0]).delete(); + const message = this.client.actions.MessageDelete.getMessage( + { + message_id: messageIds[0], + }, + this, + ); + return message ? new Collection([[message.id, message]]) : new Collection(); + } + await this.client.api.channels(this.id).messages['bulk-delete'].post({ body: { messages: messageIds } }); + return messageIds.reduce( + (col, id) => + col.set( + id, + this.client.actions.MessageDeleteBulk.getMessage( + { + message_id: id, + }, + this, + ), + ), + new Collection(), + ); + } + if (!isNaN(messages)) { + const msgs = await this.messages.fetch({ limit: messages }); + return this.bulkDelete(msgs, filterOld); + } + throw new TypeError('MESSAGE_BULK_DELETE_TYPE'); + } + + static applyToClass(structure, full = false, ignore = []) { + const props = ['send']; + if (full) { + props.push( + 'lastMessage', + 'lastPinAt', + 'bulkDelete', + 'sendTyping', + 'createMessageCollector', + 'awaitMessages', + 'createMessageComponentCollector', + 'awaitMessageComponent', + ); + } + for (const prop of props) { + if (ignore.includes(prop)) continue; + Object.defineProperty( + structure.prototype, + prop, + Object.getOwnPropertyDescriptor(TextBasedChannel.prototype, prop), + ); + } + } +} + +module.exports = TextBasedChannel; + +// Fixes Circular +// eslint-disable-next-line import/order +const MessageManager = require('../../managers/MessageManager'); diff --git a/src/util/ActivityFlagsBitField.js b/src/util/ActivityFlagsBitField.js new file mode 100644 index 00000000..2de552f --- /dev/null +++ b/src/util/ActivityFlagsBitField.js @@ -0,0 +1,25 @@ +'use strict'; + +const { ActivityFlags } = require('discord-api-types/v9'); +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to interact with an {@link Activity#flags} bitfield. + * @extends {BitField} + */ +class ActivityFlagsBitField extends BitField {} + +/** + * @name ActivityFlagsBitField + * @kind constructor + * @memberof ActivityFlagsBitField + * @param {BitFieldResolvable} [bits=0] Bit(s) to read from + */ + +/** + * Numeric activity flags. + * @type {ActivityFlags} + */ +ActivityFlagsBitField.Flags = ActivityFlags; + +module.exports = ActivityFlagsBitField; diff --git a/src/util/ApplicationFlagsBitField.js b/src/util/ApplicationFlagsBitField.js new file mode 100644 index 00000000..885260e --- /dev/null +++ b/src/util/ApplicationFlagsBitField.js @@ -0,0 +1,31 @@ +'use strict'; + +const { ApplicationFlags } = require('discord-api-types/v9'); +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to interact with a {@link ClientApplication#flags} bitfield. + * @extends {BitField} + */ +class ApplicationFlagsBitField extends BitField {} + +/** + * @name ApplicationFlagsBitField + * @kind constructor + * @memberof ApplicationFlagsBitField + * @param {BitFieldResolvable} [bits=0] Bit(s) to read from + */ + +/** + * Bitfield of the packed bits + * @type {number} + * @name ApplicationFlagsBitField#bitfield + */ + +/** + * Numeric application flags. All available properties: + * @type {ApplicationFlags} + */ +ApplicationFlagsBitField.Flags = ApplicationFlags; + +module.exports = ApplicationFlagsBitField; diff --git a/src/util/BitField.js b/src/util/BitField.js new file mode 100644 index 00000000..c444d2d --- /dev/null +++ b/src/util/BitField.js @@ -0,0 +1,170 @@ +'use strict'; + +const { RangeError } = require('../errors'); + +/** + * Data structure that makes it easy to interact with a bitfield. + */ +class BitField { + /** + * @param {BitFieldResolvable} [bits=this.constructor.defaultBit] Bit(s) to read from + */ + constructor(bits = this.constructor.defaultBit) { + /** + * Bitfield of the packed bits + * @type {number|bigint} + */ + this.bitfield = this.constructor.resolve(bits); + } + + /** + * Checks whether the bitfield has a bit, or any of multiple bits. + * @param {BitFieldResolvable} bit Bit(s) to check for + * @returns {boolean} + */ + any(bit) { + return (this.bitfield & this.constructor.resolve(bit)) !== this.constructor.defaultBit; + } + + /** + * Checks if this bitfield equals another + * @param {BitFieldResolvable} bit Bit(s) to check for + * @returns {boolean} + */ + equals(bit) { + return this.bitfield === this.constructor.resolve(bit); + } + + /** + * Checks whether the bitfield has a bit, or multiple bits. + * @param {BitFieldResolvable} bit Bit(s) to check for + * @returns {boolean} + */ + has(bit) { + bit = this.constructor.resolve(bit); + return (this.bitfield & bit) === bit; + } + + /** + * Gets all given bits that are missing from the bitfield. + * @param {BitFieldResolvable} bits Bit(s) to check for + * @param {...*} hasParams Additional parameters for the has method, if any + * @returns {string[]} + */ + missing(bits, ...hasParams) { + return new this.constructor(bits).remove(this).toArray(...hasParams); + } + + /** + * Freezes these bits, making them immutable. + * @returns {Readonly} + */ + freeze() { + return Object.freeze(this); + } + + /** + * Adds bits to these ones. + * @param {...BitFieldResolvable} [bits] Bits to add + * @returns {BitField} These bits or new BitField if the instance is frozen. + */ + add(...bits) { + let total = this.constructor.defaultBit; + for (const bit of bits) { + total |= this.constructor.resolve(bit); + } + if (Object.isFrozen(this)) return new this.constructor(this.bitfield | total); + this.bitfield |= total; + return this; + } + + /** + * Removes bits from these. + * @param {...BitFieldResolvable} [bits] Bits to remove + * @returns {BitField} These bits or new BitField if the instance is frozen. + */ + remove(...bits) { + let total = this.constructor.defaultBit; + for (const bit of bits) { + total |= this.constructor.resolve(bit); + } + if (Object.isFrozen(this)) return new this.constructor(this.bitfield & ~total); + this.bitfield &= ~total; + return this; + } + + /** + * Gets an object mapping field names to a {@link boolean} indicating whether the + * bit is available. + * @param {...*} hasParams Additional parameters for the has method, if any + * @returns {Object} + */ + serialize(...hasParams) { + const serialized = {}; + for (const [flag, bit] of Object.entries(this.constructor.Flags)) serialized[flag] = this.has(bit, ...hasParams); + return serialized; + } + + /** + * Gets an {@link Array} of bitfield names based on the bits available. + * @param {...*} hasParams Additional parameters for the has method, if any + * @returns {string[]} + */ + toArray(...hasParams) { + return Object.keys(this.constructor.Flags).filter(bit => this.has(bit, ...hasParams)); + } + + toJSON() { + return typeof this.bitfield === 'number' ? this.bitfield : this.bitfield.toString(); + } + + valueOf() { + return this.bitfield; + } + + *[Symbol.iterator]() { + yield* this.toArray(); + } + + /** + * Data that can be resolved to give a bitfield. This can be: + * * A bit number (this can be a number literal or a value taken from {@link BitField.Flags}) + * * A string bit number + * * An instance of BitField + * * An Array of BitFieldResolvable + * @typedef {number|string|bigint|BitField|BitFieldResolvable[]} BitFieldResolvable + */ + + /** + * Resolves bitfields to their numeric form. + * @param {BitFieldResolvable} [bit] bit(s) to resolve + * @returns {number|bigint} + */ + static resolve(bit) { + const { defaultBit } = this; + if (typeof defaultBit === typeof bit && bit >= defaultBit) return bit; + if (bit instanceof BitField) return bit.bitfield; + if (Array.isArray(bit)) return bit.map(p => this.resolve(p)).reduce((prev, p) => prev | p, defaultBit); + if (typeof bit === 'string') { + if (typeof this.Flags[bit] !== 'undefined') return this.Flags[bit]; + if (!isNaN(bit)) return typeof defaultBit === 'bigint' ? BigInt(bit) : Number(bit); + } + throw new RangeError('BITFIELD_INVALID', bit); + } +} + +/** + * Numeric bitfield flags. + * Defined in extension classes + * @type {Object} + * @abstract + */ +BitField.Flags = {}; + +/** + * @type {number|bigint} + * @private + */ +BitField.defaultBit = 0; + +module.exports = BitField; diff --git a/src/util/Colors.js b/src/util/Colors.js new file mode 100644 index 00000000..5b4a383 --- /dev/null +++ b/src/util/Colors.js @@ -0,0 +1,34 @@ +'use strict'; + +module.exports = { + Default: 0x000000, + White: 0xffffff, + Aqua: 0x1abc9c, + Green: 0x57f287, + Blue: 0x3498db, + Yellow: 0xfee75c, + Purple: 0x9b59b6, + LuminousVividPink: 0xe91e63, + Fuchsia: 0xeb459e, + Gold: 0xf1c40f, + Orange: 0xe67e22, + Red: 0xed4245, + Grey: 0x95a5a6, + Navy: 0x34495e, + DarkAqua: 0x11806a, + DarkGreen: 0x1f8b4c, + DarkBlue: 0x206694, + DarkPurple: 0x71368a, + DarkVividPink: 0xad1457, + DarkGold: 0xc27c0e, + DarkOrange: 0xa84300, + DarkRed: 0x992d22, + DarkGrey: 0x979c9f, + DarkerGrey: 0x7f8c8d, + LightGrey: 0xbcc0c0, + DarkNavy: 0x2c3e50, + Blurple: 0x5865f2, + Greyple: 0x99aab5, + DarkButNotBlack: 0x2c2f33, + NotQuiteBlack: 0x23272a, +}; diff --git a/src/util/Components.js b/src/util/Components.js new file mode 100644 index 00000000..59a816b --- /dev/null +++ b/src/util/Components.js @@ -0,0 +1,44 @@ +'use strict'; + +/** + * @typedef {Object} BaseComponentData + * @property {ComponentType} type + */ + +/** + * @typedef {BaseComponentData} ActionRowData + * @property {ComponentData[]} components + */ + +/** + * @typedef {BaseComponentData} ButtonComponentData + * @property {ButtonStyle} style + * @property {?boolean} disabled + * @property {string} label + * @property {?APIComponentEmoji} emoji + * @property {?string} customId + * @property {?string} url + */ + +/** + * @typedef {object} SelectMenuComponentOptionData + * @property {string} label + * @property {string} value + * @property {?string} description + * @property {?APIComponentEmoji} emoji + * @property {?boolean} default + */ + +/** + * @typedef {BaseComponentData} SelectMenuComponentData + * @property {string} customId + * @property {?boolean} disabled + * @property {?number} maxValues + * @property {?number} minValues + * @property {?SelectMenuComponentOptionData[]} options + * @property {?string} placeholder + */ + +/** + * @typedef {ActionRowData|ButtonComponentData|SelectMenuComponentData} ComponentData + */ diff --git a/src/util/Constants.js b/src/util/Constants.js new file mode 100644 index 00000000..63a780a --- /dev/null +++ b/src/util/Constants.js @@ -0,0 +1,266 @@ +'use strict'; + +const process = require('node:process'); +const { ChannelType, MessageType } = require('discord-api-types/v9'); +const Package = (exports.Package = require('../../package.json')); + +exports.UserAgent = `Mozilla/5.0 (iPhone; CPU iPhone OS 15_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/90.0.4430.78 Mobile/15E148 Safari/604.1`; + +/** + * The name of an item to be swept in Sweepers + * * `applicationCommands` - both global and guild commands + * * `bans` + * * `emojis` + * * `invites` - accepts the `lifetime` property, using it will sweep based on expires timestamp + * * `guildMembers` + * * `messages` - accepts the `lifetime` property, using it will sweep based on edited or created timestamp + * * `presences` + * * `reactions` + * * `stageInstances` + * * `stickers` + * * `threadMembers` + * * `threads` - accepts the `lifetime` property, using it will sweep archived threads based on archived timestamp + * * `users` + * * `voiceStates` + * @typedef {string} SweeperKey + */ +exports.SweeperKeys = [ + 'applicationCommands', + 'bans', + 'emojis', + 'invites', + 'guildMembers', + 'messages', + 'presences', + 'reactions', + 'stageInstances', + 'stickers', + 'threadMembers', + 'threads', + 'users', + 'voiceStates', +]; + +/** + * The types of messages that are not `System`. The available types are: + * * {@link MessageType.Default} + * * {@link MessageType.Reply} + * * {@link MessageType.ChatInputCommand} + * * {@link MessageType.ContextMenuCommand} + * @typedef {MessageType[]} NonSystemMessageTypes + */ +exports.NonSystemMessageTypes = [ + MessageType.Default, + MessageType.Reply, + MessageType.ChatInputCommand, + MessageType.ContextMenuCommand, +]; + +/** + * The channels that are text-based. + * * DMChannel + * * TextChannel + * * NewsChannel + * * ThreadChannel + * @typedef {DMChannel|TextChannel|NewsChannel|ThreadChannel} TextBasedChannels + */ + +/** + * The types of channels that are text-based. The available types are: + * * {@link ChannelType.DM} + * * {@link ChannelType.GuildText} + * * {@link ChannelType.GuildNews} + * * {@link ChannelType.GuildNewsThread} + * * {@link ChannelType.GuildPublicThread} + * * {@link ChannelType.GuildPrivateThread} + * @typedef {ChannelType} TextBasedChannelTypes + */ +exports.TextBasedChannelTypes = [ + ChannelType.DM, + ChannelType.GuildText, + ChannelType.GuildNews, + ChannelType.GuildNewsThread, + ChannelType.GuildPublicThread, + ChannelType.GuildPrivateThread, +]; + +/** + * The types of channels that are threads. The available types are: + * * {@link ChannelType.GuildNewsThread} + * * {@link ChannelType.GuildPublicThread} + * * {@link ChannelType.GuildPrivateThread} + * @typedef {ChannelType[]} ThreadChannelTypes + */ +exports.ThreadChannelTypes = [ + ChannelType.GuildNewsThread, + ChannelType.GuildPublicThread, + ChannelType.GuildPrivateThread, +]; + +/** + * The types of channels that are voice-based. The available types are: + * * {@link ChannelType.GuildVoice} + * * {@link ChannelType.GuildStageVoice} + * @typedef {ChannelType[]} VoiceBasedChannelTypes + */ +exports.VoiceBasedChannelTypes = [ChannelType.GuildVoice, ChannelType.GuildStageVoice]; + +/* eslint-enable max-len */ + +/** + * @typedef {Object} Constants Constants that can be used in an enum or object-like way. + * @property {Status} Status The available statuses of the client. + */ + + +exports.Events = { + RATE_LIMIT: 'rateLimit', + INVALID_REQUEST_WARNING: 'invalidRequestWarning', + API_RESPONSE: 'apiResponse', + API_REQUEST: 'apiRequest', + CLIENT_READY: 'ready', + /** + * @deprecated See {@link https://github.com/discord/discord-api-docs/issues/3690 this issue} for more information. + */ + APPLICATION_COMMAND_CREATE: 'applicationCommandCreate', + /** + * @deprecated See {@link https://github.com/discord/discord-api-docs/issues/3690 this issue} for more information. + */ + APPLICATION_COMMAND_DELETE: 'applicationCommandDelete', + /** + * @deprecated See {@link https://github.com/discord/discord-api-docs/issues/3690 this issue} for more information. + */ + APPLICATION_COMMAND_UPDATE: 'applicationCommandUpdate', + GUILD_CREATE: 'guildCreate', + GUILD_DELETE: 'guildDelete', + GUILD_UPDATE: 'guildUpdate', + GUILD_UNAVAILABLE: 'guildUnavailable', + GUILD_MEMBER_ADD: 'guildMemberAdd', + GUILD_MEMBER_REMOVE: 'guildMemberRemove', + GUILD_MEMBER_UPDATE: 'guildMemberUpdate', + GUILD_MEMBER_AVAILABLE: 'guildMemberAvailable', + GUILD_MEMBERS_CHUNK: 'guildMembersChunk', + GUILD_INTEGRATIONS_UPDATE: 'guildIntegrationsUpdate', + GUILD_ROLE_CREATE: 'roleCreate', + GUILD_ROLE_DELETE: 'roleDelete', + INVITE_CREATE: 'inviteCreate', + INVITE_DELETE: 'inviteDelete', + GUILD_ROLE_UPDATE: 'roleUpdate', + GUILD_EMOJI_CREATE: 'emojiCreate', + GUILD_EMOJI_DELETE: 'emojiDelete', + GUILD_EMOJI_UPDATE: 'emojiUpdate', + GUILD_BAN_ADD: 'guildBanAdd', + GUILD_BAN_REMOVE: 'guildBanRemove', + CHANNEL_CREATE: 'channelCreate', + CHANNEL_DELETE: 'channelDelete', + CHANNEL_UPDATE: 'channelUpdate', + CHANNEL_PINS_UPDATE: 'channelPinsUpdate', + MESSAGE_CREATE: 'messageCreate', + MESSAGE_DELETE: 'messageDelete', + MESSAGE_UPDATE: 'messageUpdate', + MESSAGE_BULK_DELETE: 'messageDeleteBulk', + MESSAGE_REACTION_ADD: 'messageReactionAdd', + MESSAGE_REACTION_REMOVE: 'messageReactionRemove', + MESSAGE_REACTION_REMOVE_ALL: 'messageReactionRemoveAll', + MESSAGE_REACTION_REMOVE_EMOJI: 'messageReactionRemoveEmoji', + THREAD_CREATE: 'threadCreate', + THREAD_DELETE: 'threadDelete', + THREAD_UPDATE: 'threadUpdate', + THREAD_LIST_SYNC: 'threadListSync', + THREAD_MEMBER_UPDATE: 'threadMemberUpdate', + THREAD_MEMBERS_UPDATE: 'threadMembersUpdate', + USER_UPDATE: 'userUpdate', + PRESENCE_UPDATE: 'presenceUpdate', + VOICE_SERVER_UPDATE: 'voiceServerUpdate', + VOICE_STATE_UPDATE: 'voiceStateUpdate', + TYPING_START: 'typingStart', + WEBHOOKS_UPDATE: 'webhookUpdate', + INTERACTION_CREATE: 'interactionCreate', + ERROR: 'error', + WARN: 'warn', + DEBUG: 'debug', + CACHE_SWEEP: 'cacheSweep', + SHARD_DISCONNECT: 'shardDisconnect', + SHARD_ERROR: 'shardError', + SHARD_RECONNECTING: 'shardReconnecting', + SHARD_READY: 'shardReady', + SHARD_RESUME: 'shardResume', + INVALIDATED: 'invalidated', + RAW: 'raw', + STAGE_INSTANCE_CREATE: 'stageInstanceCreate', + STAGE_INSTANCE_UPDATE: 'stageInstanceUpdate', + STAGE_INSTANCE_DELETE: 'stageInstanceDelete', + GUILD_STICKER_CREATE: 'stickerCreate', + GUILD_STICKER_DELETE: 'stickerDelete', + GUILD_STICKER_UPDATE: 'stickerUpdate', + GUILD_SCHEDULED_EVENT_CREATE: 'guildScheduledEventCreate', + GUILD_SCHEDULED_EVENT_UPDATE: 'guildScheduledEventUpdate', + GUILD_SCHEDULED_EVENT_DELETE: 'guildScheduledEventDelete', + GUILD_SCHEDULED_EVENT_USER_ADD: 'guildScheduledEventUserAdd', + GUILD_SCHEDULED_EVENT_USER_REMOVE: 'guildScheduledEventUserRemove', +}; + +function makeImageUrl(root, { format = 'webp', size } = {}) { + if (!['undefined', 'number'].includes(typeof size)) throw new TypeError('INVALID_TYPE', 'size', 'number'); + if (format && !AllowedImageFormats.includes(format)) throw new Error('IMAGE_FORMAT', format); + if (size && !AllowedImageSizes.includes(size)) throw new RangeError('IMAGE_SIZE', size); + return `${root}.${format}${size ? `?size=${size}` : ''}`; +} + +/** + * Options for Image URLs. + * @typedef {StaticImageURLOptions} ImageURLOptions + * @property {boolean} [dynamic=false] If true, the format will dynamically change to `gif` for animated avatars. + */ + +/** + * Options for static Image URLs. + * @typedef {Object} StaticImageURLOptions + * @property {string} [format='webp'] One of `webp`, `png`, `jpg`, `jpeg`. + * @property {number} [size] One of `16`, `32`, `56`, `64`, `96`, `128`, `256`, `300`, `512`, `600`, `1024`, `2048`, + * `4096` + */ + +// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints +exports.Endpoints = { + CDN(root) { + return { + Emoji: (emojiId, format = 'webp') => `${root}/emojis/${emojiId}.${format}`, + Asset: name => `${root}/assets/${name}`, + DefaultAvatar: discriminator => `${root}/embed/avatars/${discriminator}.png`, + Avatar: (userId, hash, format, size, dynamic = false) => { + if (dynamic && hash.startsWith('a_')) format = 'gif'; + return makeImageUrl(`${root}/avatars/${userId}/${hash}`, { format, size }); + }, + GuildMemberAvatar: (guildId, memberId, hash, format = 'webp', size, dynamic = false) => { + if (dynamic && hash.startsWith('a_')) format = 'gif'; + return makeImageUrl(`${root}/guilds/${guildId}/users/${memberId}/avatars/${hash}`, { format, size }); + }, + Banner: (id, hash, format, size, dynamic = false) => { + if (dynamic && hash.startsWith('a_')) format = 'gif'; + return makeImageUrl(`${root}/banners/${id}/${hash}`, { format, size }); + }, + Icon: (guildId, hash, format, size, dynamic = false) => { + if (dynamic && hash.startsWith('a_')) format = 'gif'; + return makeImageUrl(`${root}/icons/${guildId}/${hash}`, { format, size }); + }, + AppIcon: (appId, hash, options) => makeImageUrl(`${root}/app-icons/${appId}/${hash}`, options), + AppAsset: (appId, hash, options) => makeImageUrl(`${root}/app-assets/${appId}/${hash}`, options), + StickerPackBanner: (bannerId, format, size) => + makeImageUrl(`${root}/app-assets/710982414301790216/store/${bannerId}`, { size, format }), + GDMIcon: (channelId, hash, format, size) => + makeImageUrl(`${root}/channel-icons/${channelId}/${hash}`, { size, format }), + Splash: (guildId, hash, format, size) => makeImageUrl(`${root}/splashes/${guildId}/${hash}`, { size, format }), + DiscoverySplash: (guildId, hash, format, size) => + makeImageUrl(`${root}/discovery-splashes/${guildId}/${hash}`, { size, format }), + TeamIcon: (teamId, hash, options) => makeImageUrl(`${root}/team-icons/${teamId}/${hash}`, options), + Sticker: (stickerId, stickerFormat) => + `${root}/stickers/${stickerId}.${stickerFormat === 'LOTTIE' ? 'json' : 'png'}`, + RoleIcon: (roleId, hash, format = 'webp', size) => + makeImageUrl(`${root}/role-icons/${roleId}/${hash}`, { size, format }), + }; + }, + invite: (root, code, eventId) => (eventId ? `${root}/${code}?event=${eventId}` : `${root}/${code}`), + scheduledEvent: (root, guildId, eventId) => `${root}/${guildId}/${eventId}`, + botGateway: '/gateway/bot', +}; \ No newline at end of file diff --git a/src/util/DataResolver.js b/src/util/DataResolver.js new file mode 100644 index 00000000..e3ab586 --- /dev/null +++ b/src/util/DataResolver.js @@ -0,0 +1,135 @@ +'use strict'; + +const { Buffer } = require('node:buffer'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const stream = require('node:stream'); +const { fetch } = require('undici'); +const { Error: DiscordError, TypeError } = require('../errors'); +const Invite = require('../structures/Invite'); + +/** + * The DataResolver identifies different objects and tries to resolve a specific piece of information from them. + * @private + */ +class DataResolver extends null { + /** + * Data that can be resolved to give an invite code. This can be: + * * An invite code + * * An invite URL + * @typedef {string} InviteResolvable + */ + + /** + * Data that can be resolved to give a template code. This can be: + * * A template code + * * A template URL + * @typedef {string} GuildTemplateResolvable + */ + + /** + * Resolves the string to a code based on the passed regex. + * @param {string} data The string to resolve + * @param {RegExp} regex The RegExp used to extract the code + * @returns {string} + */ + static resolveCode(data, regex) { + return data.matchAll(regex).next().value?.[1] ?? data; + } + + /** + * Resolves InviteResolvable to an invite code. + * @param {InviteResolvable} data The invite resolvable to resolve + * @returns {string} + */ + static resolveInviteCode(data) { + return this.resolveCode(data, Invite.INVITES_PATTERN); + } + + /** + * Resolves GuildTemplateResolvable to a template code. + * @param {GuildTemplateResolvable} data The template resolvable to resolve + * @returns {string} + */ + static resolveGuildTemplateCode(data) { + const GuildTemplate = require('../structures/GuildTemplate'); + return this.resolveCode(data, GuildTemplate.GUILD_TEMPLATES_PATTERN); + } + + /** + * Resolves a Base64Resolvable, a string, or a BufferResolvable to a Base 64 image. + * @param {BufferResolvable|Base64Resolvable} image The image to be resolved + * @returns {Promise} + */ + static async resolveImage(image) { + if (!image) return null; + if (typeof image === 'string' && image.startsWith('data:')) { + return image; + } + const file = await this.resolveFile(image); + return this.resolveBase64(file); + } + + /** + * Data that resolves to give a Base64 string, typically for image uploading. This can be: + * * A Buffer + * * A base64 string + * @typedef {Buffer|string} Base64Resolvable + */ + + /** + * Resolves a Base64Resolvable to a Base 64 image. + * @param {Base64Resolvable} data The base 64 resolvable you want to resolve + * @returns {?string} + */ + static resolveBase64(data) { + if (Buffer.isBuffer(data)) return `data:image/jpg;base64,${data.toString('base64')}`; + return data; + } + + /** + * Data that can be resolved to give a Buffer. This can be: + * * A Buffer + * * The path to a local file + * * A URL When provided a URL, discord.js will fetch the URL internally in order to create a Buffer. + * This can pose a security risk when the URL has not been sanitized + * @typedef {string|Buffer} BufferResolvable + */ + + /** + * @external Stream + * @see {@link https://nodejs.org/api/stream.html} + */ + + /** + * Resolves a BufferResolvable to a Buffer. + * @param {BufferResolvable|Stream} resource The buffer or stream resolvable to resolve + * @returns {Promise} + */ + static async resolveFile(resource) { + if (Buffer.isBuffer(resource)) return resource; + + if (resource instanceof stream.Readable) { + const buffers = []; + for await (const data of resource) buffers.push(data); + return Buffer.concat(buffers); + } + + if (typeof resource === 'string') { + if (/^https?:\/\//.test(resource)) { + const res = await fetch(resource); + return Buffer.from(await res.arrayBuffer()); + } + + const file = path.resolve(resource); + + const stats = await fs.stat(file); + if (!stats.isFile()) throw new DiscordError('FILE_NOT_FOUND', file); + return fs.readFile(file); + } + + throw new TypeError('REQ_RESOURCE_TYPE'); + } +} + +module.exports = DataResolver; diff --git a/src/util/Embeds.js b/src/util/Embeds.js new file mode 100644 index 00000000..d6294b2 --- /dev/null +++ b/src/util/Embeds.js @@ -0,0 +1,48 @@ +'use strict'; + +/** + * @typedef {Object} EmbedData + * @property {?string} title + * @property {?EmbedType} type + * @property {?string} description + * @property {?string} url + * @property {?string} timestamp + * @property {?number} color + * @property {?EmbedFooterData} footer + * @property {?EmbedImageData} image + * @property {?EmbedImageData} thumbnail + * @property {?EmbedProviderData} provider + * @property {?EmbedAuthorData} author + * @property {?EmbedFieldData[]} fields + */ + +/** + * @typedef {Object} EmbedFooterData + * @property {string} text + * @property {?string} iconURL + */ + +/** + * @typedef {Object} EmbedImageData + * @property {?string} url + */ + +/** + * @typedef {Object} EmbedProviderData + * @property {?string} name + * @property {?string} url + */ + +/** + * @typedef {Object} EmbedAuthorData + * @property {string} name + * @property {?string} url + * @property {?string} iconURL + */ + +/** + * @typedef {Object} EmbedFieldData + * @property {string} name + * @property {string} value + * @property {?boolean} inline + */ diff --git a/src/util/EnumResolvers.js b/src/util/EnumResolvers.js new file mode 100644 index 00000000..92684af --- /dev/null +++ b/src/util/EnumResolvers.js @@ -0,0 +1,819 @@ +'use strict'; + +const { + ApplicationCommandType, + InteractionType, + ComponentType, + ButtonStyle, + ApplicationCommandOptionType, + ChannelType, + ApplicationCommandPermissionType, + MessageType, + GuildNSFWLevel, + GuildVerificationLevel, + GuildDefaultMessageNotifications, + GuildExplicitContentFilter, + GuildPremiumTier, + GuildScheduledEventStatus, + StageInstancePrivacyLevel, + GuildMFALevel, + TeamMemberMembershipState, + GuildScheduledEventEntityType, + IntegrationExpireBehavior, + AuditLogEvent, +} = require('discord-api-types/v9'); + +function unknownKeyStrategy(val) { + throw new Error(`Could not resolve enum value for ${val}`); +} + +/** + * Holds a bunch of methods to resolve enum values to readable strings. + */ +class EnumResolvers extends null { + /** + * A string that can be resolved to a {@link ChannelType} enum value. Here are the available types: + * * GUILD_TEXT + * * DM + * * GUILD_VOICE + * * GROUP_DM + * * GUILD_CATEGORY + * * GUILD_NEWS + * * GUILD_NEWS_THREAD + * * GUILD_PUBLIC_THREAD + * * GUILD_PRIVATE_THREAD + * * GUILD_STAGE_VOICE + * @typedef {string} ChannelTypeEnumResolvable + */ + + /** + * Resolves enum key to {@link ChannelType} enum value + * @param {ChannelTypeEnumResolvable|ChannelType} key The key to resolve + * @returns {ChannelType} + */ + static resolveChannelType(key) { + switch (key) { + case 'GUILD_TEXT': + return ChannelType.GuildText; + case 'DM': + return ChannelType.DM; + case 'GUILD_VOICE': + return ChannelType.GuildVoice; + case 'GROUP_DM': + return ChannelType.GroupDM; + case 'GUILD_CATEGORY': + return ChannelType.GuildCategory; + case 'GUILD_NEWS': + return ChannelType.GuildNews; + case 'GUILD_NEWS_THREAD': + return ChannelType.GuildNewsThread; + case 'GUILD_PUBLIC_THREAD': + return ChannelType.GuildPublicThread; + case 'GUILD_PRIVATE_THREAD': + return ChannelType.GuildPrivateThread; + case 'GUILD_STAGE_VOICE': + return ChannelType.GuildStageVoice; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to an {@link InteractionType} enum value. Here are the available types: + * * PING + * * APPLICATION_COMMAND + * * MESSAGE_COMPONENT + * * APPLICATION_COMMAND_AUTOCOMPLETE + * @typedef {string} InteractionTypeEnumResolvable + */ + + /** + * Resolves enum key to {@link InteractionType} enum value + * @param {InteractionTypeEnumResolvable|InteractionType} key The key to resolve + * @returns {InteractionType} + */ + static resolveInteractionType(key) { + switch (key) { + case 'PING': + return InteractionType.Ping; + case 'APPLICATION_COMMAND': + return InteractionType.ApplicationCommand; + case 'MESSAGE_COMPONENT': + return InteractionType.MessageComponent; + case 'APPLICATION_COMMAND_AUTOCOMPLETE': + return InteractionType.ApplicationCommandAutocomplete; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to an {@link ApplicationCommandType} enum value. Here are the available types: + * * CHAT_INPUT + * * USER + * * MESSAGE + * @typedef {string} ApplicationCommandTypeEnumResolvable + */ + + /** + * Resolves enum key to {@link ApplicationCommandType} enum value + * @param {ApplicationCommandTypeEnumResolvable|ApplicationCommandType} key The key to resolve + * @returns {ApplicationCommandType} + */ + static resolveApplicationCommandType(key) { + switch (key) { + case 'CHAT_INPUT': + return ApplicationCommandType.ChatInput; + case 'USER': + return ApplicationCommandType.User; + case 'MESSAGE': + return ApplicationCommandType.Message; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to an {@link ApplicationCommandOptionType} enum value. Here are the available types: + * * SUB_COMMAND + * * SUB_COMMAND_GROUP + * * STRING + * * INTEGER + * * BOOLEAN + * * USER + * * CHANNEL + * * ROLE + * * NUMBER + * * MENTIONABLE + * @typedef {string} ApplicationCommandOptionTypeEnumResolvable + */ + + /** + * Resolves enum key to {@link ApplicationCommandOptionType} enum value + * @param {ApplicationCommandOptionTypeEnumResolvable|ApplicationCommandOptionType} key The key to resolve + * @returns {ApplicationCommandOptionType} + */ + static resolveApplicationCommandOptionType(key) { + switch (key) { + case 'SUB_COMMAND': + return ApplicationCommandOptionType.Subcommand; + case 'SUB_COMMAND_GROUP': + return ApplicationCommandOptionType.SubcommandGroup; + case 'STRING': + return ApplicationCommandOptionType.String; + case 'INTEGER': + return ApplicationCommandOptionType.Integer; + case 'BOOLEAN': + return ApplicationCommandOptionType.Boolean; + case 'USER': + return ApplicationCommandOptionType.User; + case 'CHANNEL': + return ApplicationCommandOptionType.Channel; + case 'ROLE': + return ApplicationCommandOptionType.Role; + case 'NUMBER': + return ApplicationCommandOptionType.Number; + case 'MENTIONABLE': + return ApplicationCommandOptionType.Mentionable; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to an {@link ApplicationCommandPermissionType} enum value. + * Here are the available types: + * * ROLE + * * USER + * @typedef {string} ApplicationCommandPermissionTypeEnumResolvable + */ + + /** + * Resolves enum key to {@link ApplicationCommandPermissionType} enum value + * @param {ApplicationCommandPermissionTypeEnumResolvable|ApplicationCommandPermissionType} key The key to resolve + * @returns {ApplicationCommandPermissionType} + */ + static resolveApplicationCommandPermissionType(key) { + switch (key) { + case 'ROLE': + return ApplicationCommandPermissionType.Role; + case 'USER': + return ApplicationCommandPermissionType.User; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link ComponentType} enum value. Here are the available types: + * * ACTION_ROW + * * BUTTON + * * SELECT_MENU + * @typedef {string} ComponentTypeEnumResolvable + */ + + /** + * Resolves enum key to {@link ComponentType} enum value + * @param {ComponentTypeEnumResolvable|ComponentType} key The key to resolve + * @returns {ComponentType} + */ + static resolveComponentType(key) { + switch (key) { + case 'ACTION_ROW': + return ComponentType.ActionRow; + case 'BUTTON': + return ComponentType.Button; + case 'SELECT_MENU': + return ComponentType.SelectMenu; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link ButtonStyle} enum value. Here are the available types: + * * PRIMARY + * * SECONDARY + * * SUCCESS + * * DANGER + * * LINK + * @typedef {string} ButtonStyleEnumResolvable + */ + + /** + * Resolves enum key to {@link ButtonStyle} enum value + * @param {ButtonStyleEnumResolvable|ButtonStyle} key The key to resolve + * @returns {ButtonStyle} + */ + static resolveButtonStyle(key) { + switch (key) { + case 'PRIMARY': + return ButtonStyle.Primary; + case 'SECONDARY': + return ButtonStyle.Secondary; + case 'SUCCESS': + return ButtonStyle.Success; + case 'DANGER': + return ButtonStyle.Danger; + case 'LINK': + return ButtonStyle.Link; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link MessageType} enum value. Here are the available types: + * * DEFAULT + * * RECIPIENT_ADD + * * RECIPIENT_REMOVE + * * CALL + * * CHANNEL_NAME_CHANGE + * * CHANNEL_ICON_CHANGE + * * CHANNEL_PINNED_MESSAGE + * * GUILD_MEMBER_JOIN + * * USER_PREMIUM_GUILD_SUBSCRIPTION + * * USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1 + * * USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2 + * * USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3 + * * CHANNEL_FOLLOW_ADD + * * GUILD_DISCOVERY_DISQUALIFIED + * * GUILD_DISCOVERY_REQUALIFIED + * * GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING + * * GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING + * * THREAD_CREATED + * * REPLY + * * CHAT_INPUT_COMMAND + * * THREAD_STARTER_MESSAGE + * * GUILD_INVITE_REMINDER + * * CONTEXT_MENU_COMMAND + * @typedef {string} MessageTypeEnumResolvable + */ + + /** + * Resolves enum key to {@link MessageType} enum value + * @param {MessageTypeEnumResolvable|MessageType} key The key to lookup + * @returns {MessageType} + */ + static resolveMessageType(key) { + switch (key) { + case 'DEFAULT': + return MessageType.Default; + case 'RECIPIENT_ADD': + return MessageType.RecipientAdd; + case 'RECIPIENT_REMOVE': + return MessageType.RecipientRemove; + case 'CALL': + return MessageType.Call; + case 'CHANNEL_NAME_CHANGE': + return MessageType.ChannelNameChange; + case 'CHANNEL_ICON_CHANGE': + return MessageType.ChannelIconChange; + case 'CHANNEL_PINNED_MESSAGE': + return MessageType.ChannelPinnedMessage; + case 'GUILD_MEMBER_JOIN': + return MessageType.GuildMemberJoin; + case 'USER_PREMIUM_GUILD_SUBSCRIPTION': + return MessageType.UserPremiumGuildSubscription; + case 'USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1': + return MessageType.UserPremiumGuildSubscriptionTier1; + case 'USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2': + return MessageType.UserPremiumGuildSubscriptionTier2; + case 'USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3': + return MessageType.UserPremiumGuildSubscriptionTier3; + case 'CHANNEL_FOLLOW_ADD': + return MessageType.ChannelFollowAdd; + case 'GUILD_DISCOVERY_DISQUALIFIED': + return MessageType.GuildDiscoveryDisqualified; + case 'GUILD_DISCOVERY_REQUALIFIED': + return MessageType.GuildDiscoveryRequalified; + case 'GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING': + return MessageType.GuildDiscoveryGracePeriodInitialWarning; + case 'GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING': + return MessageType.GuildDiscoveryGracePeriodFinalWarning; + case 'THREAD_CREATED': + return MessageType.ThreadCreated; + case 'REPLY': + return MessageType.Reply; + case 'CHAT_INPUT_COMMAND': + return MessageType.ChatInputCommand; + case 'THREAD_STARTER_MESSAGE': + return MessageType.ThreadStarterMessage; + case 'GUILD_INVITE_REMINDER': + return MessageType.GuildInviteReminder; + case 'CONTEXT_MENU_COMMAND': + return MessageType.ContextMenuCommand; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link GuildNSFWLevel} enum value. Here are the available types: + * * DEFAULT + * * EXPLICIT + * * SAFE + * * AGE_RESTRICTED + * @typedef {string} GuildNSFWLevelEnumResolvable + */ + + /** + * Resolves enum key to {@link GuildNSFWLevel} enum value + * @param {GuildNSFWLevelEnumResolvable|GuildNSFWLevel} key The key to lookup + * @returns {GuildNSFWLevel} + */ + static resolveGuildNSFWLevel(key) { + switch (key) { + case 'DEFAULT': + return GuildNSFWLevel.Default; + case 'EXPLICIT': + return GuildNSFWLevel.Explicit; + case 'SAFE': + return GuildNSFWLevel.Safe; + case 'AGE_RESTRICTED': + return GuildNSFWLevel.AgeRestricted; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link GuildVerificationLevel} enum value. Here are the available types: + * * NONE + * * LOW + * * MEDIUM + * * HIGH + * * VERY_HIGH + * @typedef {string} GuildVerificationLevelEnumResolvable + */ + + /** + * Resolves enum key to {@link GuildVerificationLevel} enum value + * @param {GuildVerificationLevelEnumResolvable|GuildVerificationLevel} key The key to lookup + * @returns {GuildVerificationLevel} + */ + static resolveGuildVerificationLevel(key) { + switch (key) { + case 'NONE': + return GuildVerificationLevel.None; + case 'LOW': + return GuildVerificationLevel.Low; + case 'MEDIUM': + return GuildVerificationLevel.Medium; + case 'HIGH': + return GuildVerificationLevel.High; + case 'VERY_HIGH': + return GuildVerificationLevel.VeryHigh; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link GuildDefaultMessageNotifications} enum value. + * Here are the available types: + * * ALL_MESSAGES + * * ONLY_MENTIONS + * @typedef {string} GuildDefaultMessageNotificationsEnumResolvable + */ + + /** + * Resolves enum key to {@link GuildDefaultMessageNotifications} enum value + * @param {GuildDefaultMessageNotificationsEnumResolvable|GuildDefaultMessageNotifications} key The key to lookup + * @returns {GuildDefaultMessageNotifications} + */ + static resolveGuildDefaultMessageNotifications(key) { + switch (key) { + case 'ALL_MESSAGES': + return GuildDefaultMessageNotifications.AllMessages; + case 'ONLY_MENTIONS': + return GuildDefaultMessageNotifications.OnlyMentions; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link GuildExplicitContentFilter} enum value. Here are the available types: + * * DISABLED + * * MEMBERS_WITHOUT_ROLES + * * ALL_MEMBERS + * @typedef {string} GuildExplicitContentFilterEnumResolvable + */ + + /** + * Resolves enum key to {@link GuildExplicitContentFilter} enum value + * @param {GuildExplicitContentFilterEnumResolvable|GuildExplicitContentFilter} key The key to lookup + * @returns {GuildExplicitContentFilter} + */ + static resolveGuildExplicitContentFilter(key) { + switch (key) { + case 'DISABLED': + return GuildExplicitContentFilter.Disabled; + case 'MEMBERS_WITHOUT_ROLES': + return GuildExplicitContentFilter.MembersWithoutRoles; + case 'ALL_MEMBERS': + return GuildExplicitContentFilter.AllMembers; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link GuildPremiumTier} enum value. Here are the available types: + * * NONE + * * TIER_1 + * * TIER_2 + * * TIER_3 + * @typedef {string} GuildPremiumTierEnumResolvable + */ + + /** + * Resolves enum key to {@link GuildPremiumTier} enum value + * @param {GuildPremiumTierEnumResolvable|GuildPremiumTier} key The key to lookup + * @returns {GuildPremiumTier} + */ + static resolveGuildPremiumTier(key) { + switch (key) { + case 'NONE': + return GuildPremiumTier.None; + case 'TIER_1': + return GuildPremiumTier.Tier1; + case 'TIER_2': + return GuildPremiumTier.Tier2; + case 'TIER_3': + return GuildPremiumTier.Tier3; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link GuildScheduledEventStatus} enum value. Here are the available types: + * * SCHEDULED + * * ACTIVE + * * COMPLETED + * * CANCELED + * @typedef {string} GuildScheduledEventStatusEnumResolvable + */ + + /** + * Resolves enum key to {@link GuildScheduledEventStatus} enum value + * @param {GuildScheduledEventStatusEnumResolvable|GuildScheduledEventStatus} key The key to lookup + * @returns {GuildScheduledEventStatus} + */ + static resolveGuildScheduledEventStatus(key) { + switch (key) { + case 'SCHEDULED': + return GuildScheduledEventStatus.Scheduled; + case 'ACTIVE': + return GuildScheduledEventStatus.Active; + case 'COMPLETED': + return GuildScheduledEventStatus.Completed; + case 'CANCELED': + return GuildScheduledEventStatus.Canceled; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link StageInstancePrivacyLevel} enum value. Here are the available types: + * * PUBLIC + * * GUILD_ONLY + * @typedef {string} StageInstancePrivacyLevelEnumResolvable + */ + + /** + * Resolves enum key to {@link StageInstancePrivacyLevel} enum value + * @param {StageInstancePrivacyLevelEnumResolvable|StageInstancePrivacyLevel} key The key to lookup + * @returns {StageInstancePrivacyLevel} + */ + static resolveStageInstancePrivacyLevel(key) { + switch (key) { + case 'PUBLIC': + return StageInstancePrivacyLevel.Public; + case 'GUILD_ONLY': + return StageInstancePrivacyLevel.GuildOnly; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link GuildMFALevel} enum value. Here are the available types: + * * NONE + * * ELEVATED + * @typedef {string} GuildMFALevelEnumResolvable + */ + + /** + * Resolves enum key to {@link GuildMFALevel} enum value + * @param {GuildMFALevelEnumResolvable|GuildMFALevel} key The key to lookup + * @returns {GuildMFALevel} + */ + static resolveGuildMFALevel(key) { + switch (key) { + case 'NONE': + return GuildMFALevel.None; + case 'ELEVATED': + return GuildMFALevel.Elevated; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link TeamMemberMembershipState} enum value. Here are the available types: + * * INVITED + * * ACCEPTED + * @typedef {string} TeamMemberMembershipStateEnumResolvable + */ + + /** + * Resolves enum key to {@link TeamMemberMembershipState} enum value + * @param {TeamMemberMembershipStateEnumResolvable|TeamMemberMembershipState} key The key to lookup + * @returns {TeamMemberMembershipState} + */ + static resolveTeamMemberMembershipState(key) { + switch (key) { + case 'INVITED': + return TeamMemberMembershipState.Invited; + case 'ACCEPTED': + return TeamMemberMembershipState.Accepted; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link GuildScheduledEventEntityType} enum value. Here are the available types: + * * STAGE_INSTANCE + * * VOICE + * * EXTERNAL + * @typedef {string} GuildScheduledEventEntityTypeEnumResolvable + */ + + /** + * Resolves enum key to {@link GuildScheduledEventEntityType} enum value + * @param {GuildScheduledEventEntityTypeEnumResolvable|GuildScheduledEventEntityType} key The key to lookup + * @returns {GuildScheduledEventEntityType} + */ + static resolveGuildScheduledEventEntityType(key) { + switch (key) { + case 'STAGE_INSTANCE': + return GuildScheduledEventEntityType.StageInstance; + case 'VOICE': + return GuildScheduledEventEntityType.Voice; + case 'EXTERNAL': + return GuildScheduledEventEntityType.External; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link IntegrationExpireBehavior} enum value. Here are the available types: + * * REMOVE_ROLE + * * KICK + * @typedef {string} IntegrationExpireBehaviorEnumResolvable + */ + + /** + * Resolves enum key to {@link IntegrationExpireBehavior} enum value + * @param {IntegrationExpireBehaviorEnumResolvable|IntegrationExpireBehavior} key The key to lookup + * @returns {IntegrationExpireBehavior} + */ + static resolveIntegrationExpireBehavior(key) { + switch (key) { + case 'REMOVE_ROLE': + return IntegrationExpireBehavior.RemoveRole; + case 'KICK': + return IntegrationExpireBehavior.Kick; + default: + return unknownKeyStrategy(key); + } + } + + /** + * A string that can be resolved to a {@link AuditLogEvent} enum value. Here are the available types: + * * GUILD_UPDATE + * * CHANNEL_CREATE + * * CHANNEL_UPDATE + * * CHANNEL_DELETE + * * CHANNEL_OVERWRITE_CREATE + * * CHANNEL_OVERWRITE_UPDATE + * * CHANNEL_OVERWRITE_DELETE + * * MEMBER_KICK + * * MEMBER_PRUNE + * * MEMBER_BAN_ADD + * * MEMBER_BAN_REMOVE + * * MEMBER_UPDATE + * * MEMBER_ROLE_UPDATE + * * MEMBER_MOVE + * * MEMBER_DISCONNECT + * * BOT_ADD + * * ROLE_CREATE + * * ROLE_UPDATE + * * ROLE_DELETE + * * INVITE_CREATE + * * INVITE_UPDATE + * * INVITE_DELETE + * * WEBHOOK_CREATE + * * WEBHOOK_UPDATE + * * WEBHOOK_DELETE + * * INTEGRATION_CREATE + * * INTEGRATION_UPDATE + * * INTEGRATION_DELETE + * * STAGE_INSTANCE_CREATE + * * STAGE_INSTANCE_UPDATE + * * STAGE_INSTANCE_DELETE + * * STICKER_CREATE + * * STICKER_UPDATE + * * STICKER_DELETE + * * GUILD_SCHEDULED_EVENT_CREATE + * * GUILD_SCHEDULED_EVENT_UPDATE + * * GUILD_SCHEDULED_EVENT_DELETE + * * THREAD_CREATE + * * THREAD_UPDATE + * * THREAD_DELETE + * @typedef {string} AuditLogEventEnumResolvable + */ + + /** + * Resolves enum key to {@link AuditLogEvent} enum value + * @param {AuditLogEventEnumResolvable|AuditLogEvent} key The key to lookup + * @returns {AuditLogEvent} + */ + static resolveAuditLogEvent(key) { + switch (key) { + case 'GUILD_UPDATE': + return AuditLogEvent.GuildUpdate; + case 'CHANNEL_CREATE': + return AuditLogEvent.ChannelCreate; + case 'CHANNEL_UPDATE': + return AuditLogEvent.ChannelUpdate; + case 'CHANNEL_DELETE': + return AuditLogEvent.ChannelDelete; + case 'CHANNEL_OVERWRITE_CREATE': + return AuditLogEvent.ChannelOverwriteCreate; + case 'CHANNEL_OVERWRITE_UPDATE': + return AuditLogEvent.ChannelOverwriteUpdate; + case 'CHANNEL_OVERWRITE_DELETE': + return AuditLogEvent.ChannelOverwriteDelete; + case 'MEMBER_KICK': + return AuditLogEvent.MemberKick; + case 'MEMBER_PRUNE': + return AuditLogEvent.MemberPrune; + case 'MEMBER_BAN_ADD': + return AuditLogEvent.MemberBanAdd; + case 'MEMBER_BAN_REMOVE': + return AuditLogEvent.MemberBanRemove; + case 'MEMBER_UPDATE': + return AuditLogEvent.MemberUpdate; + case 'MEMBER_ROLE_UPDATE': + return AuditLogEvent.MemberRoleUpdate; + case 'MEMBER_MOVE': + return AuditLogEvent.MemberMove; + case 'MEMBER_DISCONNECT': + return AuditLogEvent.MemberDisconnect; + case 'BOT_ADD': + return AuditLogEvent.BotAdd; + case 'ROLE_CREATE': + return AuditLogEvent.RoleCreate; + case 'ROLE_UPDATE': + return AuditLogEvent.RoleUpdate; + case 'ROLE_DELETE': + return AuditLogEvent.RoleDelete; + case 'INVITE_CREATE': + return AuditLogEvent.InviteCreate; + case 'INVITE_UPDATE': + return AuditLogEvent.InviteUpdate; + case 'INVITE_DELETE': + return AuditLogEvent.InviteDelete; + case 'WEBHOOK_CREATE': + return AuditLogEvent.WebhookCreate; + case 'WEBHOOK_UPDATE': + return AuditLogEvent.WebhookUpdate; + case 'WEBHOOK_DELETE': + return AuditLogEvent.WebhookDelete; + case 'EMOJI_CREATE': + return AuditLogEvent.EmojiCreate; + case 'EMOJI_UPDATE': + return AuditLogEvent.EmojiUpdate; + case 'EMOJI_DELETE': + return AuditLogEvent.EmojiDelete; + case 'MESSAGE_DELETE': + return AuditLogEvent.MessageDelete; + case 'MESSAGE_BULK_DELETE': + return AuditLogEvent.MessageBulkDelete; + case 'MESSAGE_PIN': + return AuditLogEvent.MessagePin; + case 'MESSAGE_UNPIN': + return AuditLogEvent.MessageUnpin; + case 'INTEGRATION_CREATE': + return AuditLogEvent.IntegrationCreate; + case 'INTEGRATION_UPDATE': + return AuditLogEvent.IntegrationUpdate; + case 'INTEGRATION_DELETE': + return AuditLogEvent.IntegrationDelete; + case 'STAGE_INSTANCE_CREATE': + return AuditLogEvent.StageInstanceCreate; + case 'STAGE_INSTANCE_UPDATE': + return AuditLogEvent.StageInstanceUpdate; + case 'STAGE_INSTANCE_DELETE': + return AuditLogEvent.StageInstanceDelete; + case 'STICKER_CREATE': + return AuditLogEvent.StickerCreate; + case 'STICKER_UPDATE': + return AuditLogEvent.StickerUpdate; + case 'STICKER_DELETE': + return AuditLogEvent.StickerDelete; + case 'GUILD_SCHEDULED_EVENT_CREATE': + return AuditLogEvent.GuildScheduledEventCreate; + case 'GUILD_SCHEDULED_EVENT_UPDATE': + return AuditLogEvent.GuildScheduledEventUpdate; + case 'GUILD_SCHEDULED_EVENT_DELETE': + return AuditLogEvent.GuildScheduledEventDelete; + case 'THREAD_CREATE': + return AuditLogEvent.ThreadCreate; + case 'THREAD_UPDATE': + return AuditLogEvent.ThreadUpdate; + case 'THREAD_DELETE': + return AuditLogEvent.ThreadDelete; + default: + return unknownKeyStrategy(key); + } + } +} + +// Precondition logic wrapper +function preconditioner(func) { + return key => { + if (typeof key !== 'string' && typeof key !== 'number') { + throw new Error('Enum value must be string or number'); + } + + if (typeof key === 'number') { + return key; + } + + return func(key); + }; +} + +// Injects wrapper into class static methods. +function applyPreconditioner(obj) { + for (const name in Object.getOwnPropertyNames(obj)) { + if (typeof obj[name] !== 'function') { + return; + } + + obj[name] = preconditioner(obj[name]); + } +} + +// Apply precondition logic +applyPreconditioner(EnumResolvers); + +module.exports = EnumResolvers; diff --git a/src/util/Enums.js b/src/util/Enums.js new file mode 100644 index 00000000..e3e5cac --- /dev/null +++ b/src/util/Enums.js @@ -0,0 +1,13 @@ +'use strict'; + +function createEnum(keys) { + const obj = {}; + for (const [index, key] of keys.entries()) { + if (key === null) continue; + obj[key] = index; + obj[index] = key; + } + return obj; +} + +module.exports = { createEnum }; diff --git a/src/util/Events.js b/src/util/Events.js new file mode 100644 index 00000000..11d980d --- /dev/null +++ b/src/util/Events.js @@ -0,0 +1,72 @@ +'use strict'; + +module.exports = { + ClientReady: 'ready', + GuildCreate: 'guildCreate', + GuildDelete: 'guildDelete', + GuildUpdate: 'guildUpdate', + GuildUnavailable: 'guildUnavailable', + GuildMemberAdd: 'guildMemberAdd', + GuildMemberRemove: 'guildMemberRemove', + GuildMemberUpdate: 'guildMemberUpdate', + GuildMemberAvailable: 'guildMemberAvailable', + GuildMembersChunk: 'guildMembersChunk', + GuildIntegrationsUpdate: 'guildIntegrationsUpdate', + GuildRoleCreate: 'roleCreate', + GuildRoleDelete: 'roleDelete', + InviteCreate: 'inviteCreate', + InviteDelete: 'inviteDelete', + GuildRoleUpdate: 'roleUpdate', + GuildEmojiCreate: 'emojiCreate', + GuildEmojiDelete: 'emojiDelete', + GuildEmojiUpdate: 'emojiUpdate', + GuildBanAdd: 'guildBanAdd', + GuildBanRemove: 'guildBanRemove', + ChannelCreate: 'channelCreate', + ChannelDelete: 'channelDelete', + ChannelUpdate: 'channelUpdate', + ChannelPinsUpdate: 'channelPinsUpdate', + MessageCreate: 'messageCreate', + MessageDelete: 'messageDelete', + MessageUpdate: 'messageUpdate', + MessageBulkDelete: 'messageDeleteBulk', + MessageReactionAdd: 'messageReactionAdd', + MessageReactionRemove: 'messageReactionRemove', + MessageReactionRemoveAll: 'messageReactionRemoveAll', + MessageReactionRemoveEmoji: 'messageReactionRemoveEmoji', + ThreadCreate: 'threadCreate', + ThreadDelete: 'threadDelete', + ThreadUpdate: 'threadUpdate', + ThreadListSync: 'threadListSync', + ThreadMemberUpdate: 'threadMemberUpdate', + ThreadMembersUpdate: 'threadMembersUpdate', + UserUpdate: 'userUpdate', + PresenceUpdate: 'presenceUpdate', + VoiceServerUpdate: 'voiceServerUpdate', + VoiceStateUpdate: 'voiceStateUpdate', + TypingStart: 'typingStart', + WebhooksUpdate: 'webhookUpdate', + InteractionCreate: 'interactionCreate', + Error: 'error', + Warn: 'warn', + Debug: 'debug', + CacheSweep: 'cacheSweep', + ShardDisconnect: 'shardDisconnect', + ShardError: 'shardError', + ShardReconnecting: 'shardReconnecting', + ShardReady: 'shardReady', + ShardResume: 'shardResume', + Invalidated: 'invalidated', + Raw: 'raw', + StageInstanceCreate: 'stageInstanceCreate', + StageInstanceUpdate: 'stageInstanceUpdate', + StageInstanceDelete: 'stageInstanceDelete', + GuildStickerCreate: 'stickerCreate', + GuildStickerDelete: 'stickerDelete', + GuildStickerUpdate: 'stickerUpdate', + GuildScheduledEventCreate: 'guildScheduledEventCreate', + GuildScheduledEventUpdate: 'guildScheduledEventUpdate', + GuildScheduledEventDelete: 'guildScheduledEventDelete', + GuildScheduledEventUserAdd: 'guildScheduledEventUserAdd', + GuildScheduledEventUserRemove: 'guildScheduledEventUserRemove', +}; diff --git a/src/util/Formatters.js b/src/util/Formatters.js new file mode 100644 index 00000000..94c14b1 --- /dev/null +++ b/src/util/Formatters.js @@ -0,0 +1,208 @@ +'use strict'; + +const { + blockQuote, + bold, + channelMention, + codeBlock, + formatEmoji, + hideLinkEmbed, + hyperlink, + inlineCode, + italic, + memberNicknameMention, + quote, + roleMention, + spoiler, + strikethrough, + time, + TimestampStyles, + underscore, + userMention, +} = require('@discordjs/builders'); + +/** + * Contains various Discord-specific functions for formatting messages. + */ +class Formatters extends null {} + +/** + * Formats the content into a block quote. This needs to be at the start of the line for Discord to format it. + * @method blockQuote + * @memberof Formatters + * @param {string} content The content to wrap. + * @returns {string} + */ +Formatters.blockQuote = blockQuote; + +/** + * Formats the content into bold text. + * @method bold + * @memberof Formatters + * @param {string} content The content to wrap. + * @returns {string} + */ +Formatters.bold = bold; + +/** + * Formats a channel id into a channel mention. + * @method channelMention + * @memberof Formatters + * @param {string} channelId The channel id to format. + * @returns {string} + */ +Formatters.channelMention = channelMention; + +/** + * Wraps the content inside a code block with an optional language. + * @method codeBlock + * @memberof Formatters + * @param {string} contentOrLanguage The language to use, content if a second parameter isn't provided. + * @param {string} [content] The content to wrap. + * @returns {string} + */ +Formatters.codeBlock = codeBlock; + +/** + * Formats an emoji id into a fully qualified emoji identifier + * @method formatEmoji + * @memberof Formatters + * @param {string} emojiId The emoji id to format. + * @param {boolean} [animated] Whether the emoji is animated or not. Defaults to `false` + * @returns {string} + */ +Formatters.formatEmoji = formatEmoji; + +/** + * Wraps the URL into `<>`, which stops it from embedding. + * @method hideLinkEmbed + * @memberof Formatters + * @param {string} content The content to wrap. + * @returns {string} + */ +Formatters.hideLinkEmbed = hideLinkEmbed; + +/** + * Formats the content and the URL into a masked URL with an optional title. + * @method hyperlink + * @memberof Formatters + * @param {string} content The content to display. + * @param {string} url The URL the content links to. + * @param {string} [title] The title shown when hovering on the masked link. + * @returns {string} + */ +Formatters.hyperlink = hyperlink; + +/** + * Wraps the content inside \`backticks\`, which formats it as inline code. + * @method inlineCode + * @memberof Formatters + * @param {string} content The content to wrap. + * @returns {string} + */ +Formatters.inlineCode = inlineCode; + +/** + * Formats the content into italic text. + * @method italic + * @memberof Formatters + * @param {string} content The content to wrap. + * @returns {string} + */ +Formatters.italic = italic; + +/** + * Formats a user id into a member-nickname mention. + * @method memberNicknameMention + * @memberof Formatters + * @param {string} memberId The user id to format. + * @returns {string} + */ +Formatters.memberNicknameMention = memberNicknameMention; + +/** + * Formats the content into a quote. This needs to be at the start of the line for Discord to format it. + * @method quote + * @memberof Formatters + * @param {string} content The content to wrap. + * @returns {string} + */ +Formatters.quote = quote; + +/** + * Formats a role id into a role mention. + * @method roleMention + * @memberof Formatters + * @param {string} roleId The role id to format. + * @returns {string} + */ +Formatters.roleMention = roleMention; + +/** + * Formats the content into spoiler text. + * @method spoiler + * @memberof Formatters + * @param {string} content The content to spoiler. + * @returns {string} + */ +Formatters.spoiler = spoiler; + +/** + * Formats the content into strike-through text. + * @method strikethrough + * @memberof Formatters + * @param {string} content The content to wrap. + * @returns {string} + */ +Formatters.strikethrough = strikethrough; + +/** + * Formats a date into a short date-time string. + * @method time + * @memberof Formatters + * @param {number|Date} [date] The date to format. + * @param {TimestampStylesString} [style] The style to use. + * @returns {string} + */ +Formatters.time = time; + +/** + * A message formatting timestamp style, as defined in + * [here](https://discord.com/developers/docs/reference#message-formatting-timestamp-styles). + * * `t` Short time format, consisting of hours and minutes, e.g. 16:20. + * * `T` Long time format, consisting of hours, minutes, and seconds, e.g. 16:20:30. + * * `d` Short date format, consisting of day, month, and year, e.g. 20/04/2021. + * * `D` Long date format, consisting of day, month, and year, e.g. 20 April 2021. + * * `f` Short date-time format, consisting of short date and short time formats, e.g. 20 April 2021 16:20. + * * `F` Long date-time format, consisting of long date and short time formats, e.g. Tuesday, 20 April 2021 16:20. + * * `R` Relative time format, consisting of a relative duration format, e.g. 2 months ago. + * @typedef {string} TimestampStylesString + */ + +/** + * The message formatting timestamp + * [styles](https://discord.com/developers/docs/reference#message-formatting-timestamp-styles) supported by Discord. + * @memberof Formatters + * @type {Object} + */ +Formatters.TimestampStyles = TimestampStyles; + +/** + * Formats the content into underscored text. + * @method underscore + * @memberof Formatters + * @param {string} content The content to wrap. + * @returns {string} + */ +Formatters.underscore = underscore; + +/** + * Formats a user id into a user mention. + * @method userMention + * @memberof Formatters + * @param {string} userId The user id to format. + * @returns {string} + */ +Formatters.userMention = userMention; + +module.exports = Formatters; diff --git a/src/util/IntentsBitField.js b/src/util/IntentsBitField.js new file mode 100644 index 00000000..a173176 --- /dev/null +++ b/src/util/IntentsBitField.js @@ -0,0 +1,33 @@ +'use strict'; +const { GatewayIntentBits } = require('discord-api-types/v9'); +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to calculate intents. + * @extends {BitField} + */ +class IntentsBitField extends BitField {} + +/** + * @name IntentsBitField + * @kind constructor + * @memberof IntentsBitField + * @param {IntentsResolvable} [bits=0] Bit(s) to read from + */ + +/** + * Data that can be resolved to give a permission number. This can be: + * * A string (see {@link IntentsBitField.Flags}) + * * An intents flag + * * An instance of {@link IntentsBitField} + * * An array of IntentsResolvable + * @typedef {string|number|IntentsBitField|IntentsResolvable[]} IntentsResolvable + */ + +/** + * Numeric WebSocket intents + * @type {GatewayIntentBits} + */ +IntentsBitField.Flags = GatewayIntentBits; + +module.exports = IntentsBitField; diff --git a/src/util/LimitedCollection.js b/src/util/LimitedCollection.js new file mode 100644 index 00000000..1fa6798 --- /dev/null +++ b/src/util/LimitedCollection.js @@ -0,0 +1,68 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { TypeError } = require('../errors/DJSError.js'); + +/** + * Options for defining the behavior of a LimitedCollection + * @typedef {Object} LimitedCollectionOptions + * @property {?number} [maxSize=Infinity] The maximum size of the Collection + * @property {?Function} [keepOverLimit=null] A function, which is passed the value and key of an entry, ran to decide + * to keep an entry past the maximum size + */ + +/** + * A Collection which holds a max amount of entries. + * @extends {Collection} + * @param {LimitedCollectionOptions} [options={}] Options for constructing the Collection. + * @param {Iterable} [iterable=null] Optional entries passed to the Map constructor. + */ +class LimitedCollection extends Collection { + constructor(options = {}, iterable) { + if (typeof options !== 'object' || options === null) { + throw new TypeError('INVALID_TYPE', 'options', 'object', true); + } + const { maxSize = Infinity, keepOverLimit = null } = options; + + if (typeof maxSize !== 'number') { + throw new TypeError('INVALID_TYPE', 'maxSize', 'number'); + } + if (keepOverLimit !== null && typeof keepOverLimit !== 'function') { + throw new TypeError('INVALID_TYPE', 'keepOverLimit', 'function'); + } + + super(iterable); + + /** + * The max size of the Collection. + * @type {number} + */ + this.maxSize = maxSize; + + /** + * A function called to check if an entry should be kept when the Collection is at max size. + * @type {?Function} + */ + this.keepOverLimit = keepOverLimit; + } + + set(key, value) { + if (this.maxSize === 0) return this; + if (this.size >= this.maxSize && !this.has(key)) { + for (const [k, v] of this.entries()) { + const keep = this.keepOverLimit?.(v, k, this) ?? false; + if (!keep) { + this.delete(k); + break; + } + } + } + return super.set(key, value); + } + + static get [Symbol.species]() { + return Collection; + } +} + +module.exports = LimitedCollection; diff --git a/src/util/MessageFlagsBitField.js b/src/util/MessageFlagsBitField.js new file mode 100644 index 00000000..a9f1e7f --- /dev/null +++ b/src/util/MessageFlagsBitField.js @@ -0,0 +1,31 @@ +'use strict'; + +const { MessageFlags } = require('discord-api-types/v9'); +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to interact with a {@link Message#flags} bitfield. + * @extends {BitField} + */ +class MessageFlagsBitField extends BitField {} + +/** + * @name MessageFlagsBitField + * @kind constructor + * @memberof MessageFlagsBitField + * @param {BitFieldResolvable} [bits=0] Bit(s) to read from + */ + +/** + * Bitfield of the packed bits + * @type {number} + * @name MessageFlagsBitField#bitfield + */ + +/** + * Numeric message flags. + * @type {MessageFlags} + */ +MessageFlagsBitField.Flags = MessageFlags; + +module.exports = MessageFlagsBitField; diff --git a/src/util/Options.js b/src/util/Options.js new file mode 100644 index 00000000..31a3101 --- /dev/null +++ b/src/util/Options.js @@ -0,0 +1,220 @@ +'use strict'; + +const process = require('node:process'); +const Transformers = require('./Transformers'); + +/** + * @typedef {Function} CacheFactory + * @param {Function} manager The manager class the cache is being requested from. + * @param {Function} holds The class that the cache will hold. + * @returns {Collection} A Collection used to store the cache of the manager. + */ + +/** + * Options for a client. + * @typedef {Object} ClientOptions + * @property {number|number[]|string} [shards] The shard's id to run, or an array of shard ids. If not specified, + * the client will spawn {@link ClientOptions#shardCount} shards. If set to `auto`, it will fetch the + * recommended amount of shards from Discord and spawn that amount + * @property {number} [shardCount=1] The total amount of shards used by all processes of this bot + * (e.g. recommended shard count, shard count of the ShardingManager) + * @property {CacheFactory} [makeCache] Function to create a cache. + * You can use your own function, or the {@link Options} class to customize the Collection used for the cache. + * Overriding the cache used in `GuildManager`, `ChannelManager`, `GuildChannelManager`, `RoleManager`, + * and `PermissionOverwriteManager` is unsupported and **will** break functionality + * @property {MessageMentionOptions} [allowedMentions] Default value for {@link MessageOptions#allowedMentions} + * @property {Partials[]} [partials] Structures allowed to be partial. This means events can be emitted even when + * they're missing all the data for a particular structure. See the "Partial Structures" topic on the + * [guide](https://discordjs.guide/popular-topics/partials.html) for some + * important usage information, as partials require you to put checks in place when handling data. + * @property {boolean} [failIfNotExists=true] Default value for {@link ReplyMessageOptions#failIfNotExists} + * @property {PresenceData} [presence={}] Presence data to use upon login + * @property {IntentsResolvable} intents Intents to enable for this connection + * @property {number} [waitGuildTimeout=15_000] Time in milliseconds that Clients with the GUILDS intent should wait for + * missing guilds to be received before starting the bot. If not specified, the default is 15 seconds. + * @property {SweeperOptions} [sweepers={}] Options for cache sweeping + * @property {WebsocketOptions} [ws] Options for the WebSocket + * @property {RESTOptions} [rest] Options for the REST manager + * @property {Function} [jsonTransformer] A function used to transform outgoing json data + */ + +/** + * Options for {@link Sweepers} defining the behavior of cache sweeping + * @typedef {Object} SweeperOptions + */ + +/** + * Options for sweeping a single type of item from cache + * @typedef {Object} SweepOptions + * @property {number} interval The interval (in seconds) at which to perform sweeping of the item + * @property {number} [lifetime] How long an item should stay in cache until it is considered sweepable. + * This property is only valid for the `invites`, `messages`, and `threads` keys. The `filter` property + * is mutually exclusive to this property and takes priority + * @property {GlobalSweepFilter} filter The function used to determine the function passed to the sweep method + * This property is optional when the key is `invites`, `messages`, or `threads` and `lifetime` is set + */ + +/** + * WebSocket options (these are left as snake_case to match the API) + * @typedef {Object} WebsocketOptions + * @property {number} [large_threshold=50] Number of members in a guild after which offline users will no longer be + * sent in the initial guild member list, must be between 50 and 250 + */ + +/** + * Contains various utilities for client options. + */ +class Options extends null { + /** + * The default client options. + * @returns {ClientOptions} + */ + static createDefault() { + return { + waitGuildTimeout: 15_000, + shardCount: 1, + makeCache: this.cacheWithLimits(this.defaultMakeCacheSettings), + messageCacheLifetime: 0, + messageSweepInterval: 0, + invalidRequestWarningInterval: 0, + intents: 32767, + partials: [], + restWsBridgeTimeout: 5_000, + restRequestTimeout: 15_000, + restGlobalRateLimit: 0, + retryLimit: 1, + restTimeOffset: 500, + restSweepInterval: 60, + failIfNotExists: true, + userAgentSuffix: [], + presence: {}, + sweepers: {}, + ws: { + large_threshold: 50, + compress: false, + properties: { + $os: 'iPhone14,5', + $browser: 'Discord iOS', + $device: 'iPhone14,5 OS 15.2', + }, + version: 9, + }, + http: { + headers: { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": 'en-US,en;q=0.9', + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Referer": "https://discord.com/channels/@me", + "Sec-Ch-Ua": '" Not A;Brand";v="99" "', + "Sec-Ch-Ua-Mobile": '?0', + "Sec-Ch-Ua-Platform": '"iOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "X-Debug-Options": "bugReporterEnabled", + "X-Discord-Locale": 'en-US', + "Origin": "https://discord.com" + }, + agent: {}, + version: 9, + api: 'https://discord.com/api', + cdn: 'https://cdn.discordapp.com', + invite: 'https://discord.gg', + template: 'https://discord.new', + scheduledEvent: 'https://discord.com/events', + }, + }; + } + + /** + * Create a cache factory using predefined settings to sweep or limit. + * @param {Object} [settings={}] Settings passed to the relevant constructor. + * If no setting is provided for a manager, it uses Collection. + * If a number is provided for a manager, it uses that number as the max size for a LimitedCollection. + * If LimitedCollectionOptions are provided for a manager, it uses those settings to form a LimitedCollection. + * @returns {CacheFactory} + * @example + * // Store up to 200 messages per channel and 200 members per guild, always keeping the client member. + * Options.cacheWithLimits({ + * MessageManager: 200, + * GuildMemberManager: { + * maxSize: 200, + * keepOverLimit: (member) => member.id === client.user.id, + * }, + * }); + */ + static cacheWithLimits(settings = {}) { + const { Collection } = require('@discordjs/collection'); + const LimitedCollection = require('./LimitedCollection'); + + return manager => { + const setting = settings[manager.name]; + /* eslint-disable-next-line eqeqeq */ + if (setting == null) { + return new Collection(); + } + if (typeof setting === 'number') { + if (setting === Infinity) { + return new Collection(); + } + return new LimitedCollection({ maxSize: setting }); + } + /* eslint-disable-next-line eqeqeq */ + const noLimit = setting.maxSize == null || setting.maxSize === Infinity; + if (noLimit) { + return new Collection(); + } + return new LimitedCollection(setting); + }; + } + + /** + * Create a cache factory that always caches everything. + * @returns {CacheFactory} + */ + static cacheEverything() { + const { Collection } = require('@discordjs/collection'); + return () => new Collection(); + } + + /** + * The default settings passed to {@link Options.cacheWithLimits}. + * The caches that this changes are: + * * `MessageManager` - Limit to 200 messages + * * `ChannelManager` - Sweep archived threads + * * `GuildChannelManager` - Sweep archived threads + * * `ThreadManager` - Sweep archived threads + * If you want to keep default behavior and add on top of it you can use this object and add on to it, e.g. + * `makeCache: Options.cacheWithLimits({ ...Options.defaultMakeCacheSettings, ReactionManager: 0 })` + * @type {Object} + */ + static get defaultMakeCacheSettings() { + return { + MessageManager: 200, + }; + } +} + +/** + * The default settings passed to {@link Options.sweepers} (for v14). + * The sweepers that this changes are: + * * `threads` - Sweep archived threads every hour, removing those archived more than 4 hours ago + * If you want to keep default behavior and add on top of it you can use this object and add on to it, e.g. + * `sweepers: { ...Options.defaultSweeperSettings, messages: { interval: 300, lifetime: 600 } })` + * @type {SweeperOptions} + */ +Options.defaultSweeperSettings = { + threads: { + interval: 3600, + lifetime: 14400, + }, +}; + +module.exports = Options; + +/** + * @external RESTOptions + * @see {@link https://discord.js.org/#/docs/rest/main/typedef/RESTOptions} + */ diff --git a/src/util/Partials.js b/src/util/Partials.js new file mode 100644 index 00000000..7bbe517 --- /dev/null +++ b/src/util/Partials.js @@ -0,0 +1,5 @@ +'use strict'; + +const { createEnum } = require('./Enums'); + +module.exports = createEnum(['User', 'Channel', 'GuildMember', 'Message', 'Reaction', 'GuildScheduledEvent']); diff --git a/src/util/PermissionsBitField.js b/src/util/PermissionsBitField.js new file mode 100644 index 00000000..ff101c3 --- /dev/null +++ b/src/util/PermissionsBitField.js @@ -0,0 +1,95 @@ +'use strict'; + +const { PermissionFlagsBits } = require('discord-api-types/v9'); +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to interact with a permission bitfield. All {@link GuildMember}s have a set of + * permissions in their guild, and each channel in the guild may also have {@link PermissionOverwrites} for the member + * that override their default permissions. + * @extends {BitField} + */ +class PermissionsBitField extends BitField { + /** + * Bitfield of the packed bits + * @type {bigint} + * @name Permissions#bitfield + */ + + /** + * Data that can be resolved to give a permission number. This can be: + * * A string (see {@link PermissionsBitField.Flags}) + * * A permission number + * * An instance of {@link PermissionsBitField} + * * An Array of PermissionResolvable + * @typedef {string|bigint|PermissionsBitField|PermissionResolvable[]} PermissionResolvable + */ + + /** + * Gets all given bits that are missing from the bitfield. + * @param {BitFieldResolvable} bits Bit(s) to check for + * @param {boolean} [checkAdmin=true] Whether to allow the administrator permission to override + * @returns {string[]} + */ + missing(bits, checkAdmin = true) { + return checkAdmin && this.has(PermissionFlagsBits.Administrator) ? [] : super.missing(bits); + } + + /** + * Checks whether the bitfield has a permission, or any of multiple permissions. + * @param {PermissionResolvable} permission Permission(s) to check for + * @param {boolean} [checkAdmin=true] Whether to allow the administrator permission to override + * @returns {boolean} + */ + any(permission, checkAdmin = true) { + return (checkAdmin && super.has(PermissionFlagsBits.Administrator)) || super.any(permission); + } + + /** + * Checks whether the bitfield has a permission, or multiple permissions. + * @param {PermissionResolvable} permission Permission(s) to check for + * @param {boolean} [checkAdmin=true] Whether to allow the administrator permission to override + * @returns {boolean} + */ + has(permission, checkAdmin = true) { + return (checkAdmin && super.has(PermissionFlagsBits.Administrator)) || super.has(permission); + } + + /** + * Gets an {@link Array} of bitfield names based on the permissions available. + * @returns {string[]} + */ + toArray() { + return super.toArray(false); + } +} + +/** + * Numeric permission flags. + * @type {PermissionFlagsBits} + * @see {@link https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags} + */ +PermissionsBitField.Flags = PermissionFlagsBits; + +/** + * Bitfield representing every permission combined + * @type {bigint} + */ +PermissionsBitField.All = Object.values(PermissionFlagsBits).reduce((all, p) => all | p, 0n); + +/** + * Bitfield representing the default permissions for users + * @type {bigint} + */ +PermissionsBitField.Default = BigInt(104324673); + +/** + * Bitfield representing the permissions required for moderators of stage channels + * @type {bigint} + */ +PermissionsBitField.StageModerator = + PermissionFlagsBits.ManageChannels | PermissionFlagsBits.MuteMembers | PermissionFlagsBits.MoveMembers; + +PermissionsBitField.defaultBit = BigInt(0); + +module.exports = PermissionsBitField; diff --git a/src/util/ShardEvents.js b/src/util/ShardEvents.js new file mode 100644 index 00000000..102c722 --- /dev/null +++ b/src/util/ShardEvents.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = { + Close: 'close', + Destroyed: 'destroyed', + InvalidSession: 'invalidSession', + Ready: 'ready', + Resumed: 'resumed', + AllReady: 'allReady', +}; diff --git a/src/util/Status.js b/src/util/Status.js new file mode 100644 index 00000000..d614c72 --- /dev/null +++ b/src/util/Status.js @@ -0,0 +1,15 @@ +'use strict'; + +const { createEnum } = require('./Enums'); + +module.exports = createEnum([ + 'Ready', + 'Connecting', + 'Reconnecting', + 'Idle', + 'Nearly', + 'Disconnected', + 'WaitingForGuilds', + 'Identifying', + 'Resuming', +]); diff --git a/src/util/Sweepers.js b/src/util/Sweepers.js new file mode 100644 index 00000000..bcd7df0 --- /dev/null +++ b/src/util/Sweepers.js @@ -0,0 +1,457 @@ +'use strict'; + +const { setInterval, clearInterval } = require('node:timers'); +const { ThreadChannelTypes, SweeperKeys } = require('./Constants'); +const Events = require('./Events'); +const { TypeError } = require('../errors/DJSError.js'); + +/** + * @typedef {Function} GlobalSweepFilter + * @returns {Function|null} Return `null` to skip sweeping, otherwise a function passed to `sweep()`, + * See {@link [Collection#sweep](https://discord.js.org/#/docs/collection/main/class/Collection?scrollTo=sweep)} + * for the definition of this function. + */ + +/** + * A container for all cache sweeping intervals and their associated sweep methods. + */ +class Sweepers { + constructor(client, options) { + /** + * The client that instantiated this + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * The options the sweepers were instantiated with + * @type {SweeperOptions} + */ + this.options = options; + + /** + * A record of interval timeout that is used to sweep the indicated items, or null if not being swept + * @type {Object} + */ + this.intervals = Object.fromEntries(SweeperKeys.map(key => [key, null])); + + for (const key of SweeperKeys) { + if (!(key in options)) continue; + + this._validateProperties(key); + + const clonedOptions = { ...this.options[key] }; + + // Handle cases that have a "lifetime" + if (!('filter' in clonedOptions)) { + switch (key) { + case 'invites': + clonedOptions.filter = this.constructor.expiredInviteSweepFilter(clonedOptions.lifetime); + break; + case 'messages': + clonedOptions.filter = this.constructor.outdatedMessageSweepFilter(clonedOptions.lifetime); + break; + case 'threads': + clonedOptions.filter = this.constructor.archivedThreadSweepFilter(clonedOptions.lifetime); + } + } + + this._initInterval(key, `sweep${key[0].toUpperCase()}${key.slice(1)}`, clonedOptions); + } + } + + /** + * Sweeps all guild and global application commands and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which commands will be removed from the caches. + * @returns {number} Amount of commands that were removed from the caches + */ + sweepApplicationCommands(filter) { + const { guilds, items: guildCommands } = this._sweepGuildDirectProp('commands', filter, { emit: false }); + + const globalCommands = this.client.application?.commands.cache.sweep(filter) ?? 0; + + this.client.emit( + Events.CacheSweep, + `Swept ${globalCommands} global application commands and ${guildCommands} guild commands in ${guilds} guilds.`, + ); + return guildCommands + globalCommands; + } + + /** + * Sweeps all guild bans and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which bans will be removed from the caches. + * @returns {number} Amount of bans that were removed from the caches + */ + sweepBans(filter) { + return this._sweepGuildDirectProp('bans', filter).items; + } + + /** + * Sweeps all guild emojis and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which emojis will be removed from the caches. + * @returns {number} Amount of emojis that were removed from the caches + */ + sweepEmojis(filter) { + return this._sweepGuildDirectProp('emojis', filter).items; + } + + /** + * Sweeps all guild invites and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which invites will be removed from the caches. + * @returns {number} Amount of invites that were removed from the caches + */ + sweepInvites(filter) { + return this._sweepGuildDirectProp('invites', filter).items; + } + + /** + * Sweeps all guild members and removes the ones which are indicated by the filter. + * It is highly recommended to keep the client guild member cached + * @param {Function} filter The function used to determine which guild members will be removed from the caches. + * @returns {number} Amount of guild members that were removed from the caches + */ + sweepGuildMembers(filter) { + return this._sweepGuildDirectProp('members', filter, { outputName: 'guild members' }).items; + } + + /** + * Sweeps all text-based channels' messages and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which messages will be removed from the caches. + * @returns {number} Amount of messages that were removed from the caches + * @example + * // Remove all messages older than 1800 seconds from the messages cache + * const amount = sweepers.sweepMessages( + * Sweepers.filterByLifetime({ + * lifetime: 1800, + * getComparisonTimestamp: m => m.editedTimestamp ?? m.createdTimestamp, + * })(), + * ); + * console.log(`Successfully removed ${amount} messages from the cache.`); + */ + sweepMessages(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + let channels = 0; + let messages = 0; + + for (const channel of this.client.channels.cache.values()) { + if (!channel.isTextBased()) continue; + + channels++; + messages += channel.messages.cache.sweep(filter); + } + this.client.emit(Events.CacheSweep, `Swept ${messages} messages in ${channels} text-based channels.`); + return messages; + } + + /** + * Sweeps all presences and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which presences will be removed from the caches. + * @returns {number} Amount of presences that were removed from the caches + */ + sweepPresences(filter) { + return this._sweepGuildDirectProp('presences', filter).items; + } + + /** + * Sweeps all message reactions and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which reactions will be removed from the caches. + * @returns {number} Amount of reactions that were removed from the caches + */ + sweepReactions(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + let channels = 0; + let messages = 0; + let reactions = 0; + + for (const channel of this.client.channels.cache.values()) { + if (!channel.isTextBased()) continue; + channels++; + + for (const message of channel.messages.cache.values()) { + messages++; + reactions += message.reactions.cache.sweep(filter); + } + } + this.client.emit( + Events.CacheSweep, + `Swept ${reactions} reactions on ${messages} messages in ${channels} text-based channels.`, + ); + return reactions; + } + + /** + * Sweeps all guild stage instances and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which stage instances will be removed from the caches. + * @returns {number} Amount of stage instances that were removed from the caches + */ + sweepStageInstances(filter) { + return this._sweepGuildDirectProp('stageInstances', filter, { outputName: 'stage instances' }).items; + } + + /** + * Sweeps all guild stickers and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which stickers will be removed from the caches. + * @returns {number} Amount of stickers that were removed from the caches + */ + sweepStickers(filter) { + return this._sweepGuildDirectProp('stickers', filter).items; + } + + /** + * Sweeps all thread members and removes the ones which are indicated by the filter. + * It is highly recommended to keep the client thread member cached + * @param {Function} filter The function used to determine which thread members will be removed from the caches. + * @returns {number} Amount of thread members that were removed from the caches + */ + sweepThreadMembers(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + + let threads = 0; + let members = 0; + for (const channel of this.client.channels.cache.values()) { + if (!ThreadChannelTypes.includes(channel.type)) continue; + threads++; + members += channel.members.cache.sweep(filter); + } + this.client.emit(Events.CacheSweep, `Swept ${members} thread members in ${threads} threads.`); + return members; + } + + /** + * Sweeps all threads and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which threads will be removed from the caches. + * @returns {number} filter Amount of threads that were removed from the caches + * @example + * // Remove all threads archived greater than 1 day ago from all the channel caches + * const amount = sweepers.sweepThreads( + * Sweepers.filterByLifetime({ + * getComparisonTimestamp: t => t.archivedTimestamp, + * excludeFromSweep: t => !t.archived, + * })(), + * ); + * console.log(`Successfully removed ${amount} threads from the cache.`); + */ + sweepThreads(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + + let threads = 0; + for (const [key, val] of this.client.channels.cache.entries()) { + if (!ThreadChannelTypes.includes(val.type)) continue; + if (filter(val, key, this.client.channels.cache)) { + threads++; + this.client.channels._remove(key); + } + } + this.client.emit(Events.CacheSweep, `Swept ${threads} threads.`); + return threads; + } + + /** + * Sweeps all users and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which users will be removed from the caches. + * @returns {number} Amount of users that were removed from the caches + */ + sweepUsers(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + + const users = this.client.users.cache.sweep(filter); + + this.client.emit(Events.CacheSweep, `Swept ${users} users.`); + + return users; + } + + /** + * Sweeps all guild voice states and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which voice states will be removed from the caches. + * @returns {number} Amount of voice states that were removed from the caches + */ + sweepVoiceStates(filter) { + return this._sweepGuildDirectProp('voiceStates', filter, { outputName: 'voice states' }).items; + } + + /** + * Cancels all sweeping intervals + * @returns {void} + */ + destroy() { + for (const key of SweeperKeys) { + if (this.intervals[key]) clearInterval(this.intervals[key]); + } + } + + /** + * Options for generating a filter function based on lifetime + * @typedef {Object} LifetimeFilterOptions + * @property {number} [lifetime=14400] How long, in seconds, an entry should stay in the collection + * before it is considered sweepable. + * @property {Function} [getComparisonTimestamp=e => e?.createdTimestamp] A function that takes an entry, key, + * and the collection and returns a timestamp to compare against in order to determine the lifetime of the entry. + * @property {Function} [excludeFromSweep=() => false] A function that takes an entry, key, and the collection + * and returns a boolean, `true` when the entry should not be checked for sweepability. + */ + + /** + * Create a sweepFilter function that uses a lifetime to determine sweepability. + * @param {LifetimeFilterOptions} [options={}] The options used to generate the filter function + * @returns {GlobalSweepFilter} + */ + static filterByLifetime({ + lifetime = 14400, + getComparisonTimestamp = e => e?.createdTimestamp, + excludeFromSweep = () => false, + } = {}) { + if (typeof lifetime !== 'number') { + throw new TypeError('INVALID_TYPE', 'lifetime', 'number'); + } + if (typeof getComparisonTimestamp !== 'function') { + throw new TypeError('INVALID_TYPE', 'getComparisonTimestamp', 'function'); + } + if (typeof excludeFromSweep !== 'function') { + throw new TypeError('INVALID_TYPE', 'excludeFromSweep', 'function'); + } + return () => { + if (lifetime <= 0) return null; + const lifetimeMs = lifetime * 1_000; + const now = Date.now(); + return (entry, key, coll) => { + if (excludeFromSweep(entry, key, coll)) { + return false; + } + const comparisonTimestamp = getComparisonTimestamp(entry, key, coll); + if (!comparisonTimestamp || typeof comparisonTimestamp !== 'number') return false; + return now - comparisonTimestamp > lifetimeMs; + }; + }; + } + + /** + * Creates a sweep filter that sweeps archived threads + * @param {number} [lifetime=14400] How long a thread has to be archived to be valid for sweeping + * @returns {GlobalSweepFilter} + */ + static archivedThreadSweepFilter(lifetime = 14400) { + return this.filterByLifetime({ + lifetime, + getComparisonTimestamp: e => e.archiveTimestamp, + excludeFromSweep: e => !e.archived, + }); + } + + /** + * Creates a sweep filter that sweeps expired invites + * @param {number} [lifetime=14400] How long ago an invite has to have expired to be valid for sweeping + * @returns {GlobalSweepFilter} + */ + static expiredInviteSweepFilter(lifetime = 14400) { + return this.filterByLifetime({ + lifetime, + getComparisonTimestamp: i => i.expiresTimestamp, + }); + } + + /** + * Creates a sweep filter that sweeps outdated messages (edits taken into account) + * @param {number} [lifetime=3600] How long ago a message has to have been sent or edited to be valid for sweeping + * @returns {GlobalSweepFilter} + */ + static outdatedMessageSweepFilter(lifetime = 3600) { + return this.filterByLifetime({ + lifetime, + getComparisonTimestamp: m => m.editedTimestamp ?? m.createdTimestamp, + }); + } + + /** + * Configuration options for emitting the cache sweep client event + * @typedef {Object} SweepEventOptions + * @property {boolean} [emit=true] Whether to emit the client event in this method + * @property {string} [outputName] A name to output in the client event if it should differ from the key + * @private + */ + + /** + * Sweep a direct sub property of all guilds + * @param {string} key The name of the property + * @param {Function} filter Filter function passed to sweep + * @param {SweepEventOptions} [eventOptions={}] Options for the Client event emitted here + * @returns {Object} Object containing the number of guilds swept and the number of items swept + * @private + */ + _sweepGuildDirectProp(key, filter, { emit = true, outputName } = {}) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + + let guilds = 0; + let items = 0; + + for (const guild of this.client.guilds.cache.values()) { + const { cache } = guild[key]; + + guilds++; + items += cache.sweep(filter); + } + + if (emit) { + this.client.emit(Events.CacheSweep, `Swept ${items} ${outputName ?? key} in ${guilds} guilds.`); + } + + return { guilds, items }; + } + + /** + * Validates a set of properties + * @param {string} key Key of the options object to check + * @private + */ + _validateProperties(key) { + const props = this.options[key]; + if (typeof props !== 'object') { + throw new TypeError('INVALID_TYPE', `sweepers.${key}`, 'object', true); + } + if (typeof props.interval !== 'number') { + throw new TypeError('INVALID_TYPE', `sweepers.${key}.interval`, 'number'); + } + // Invites, Messages, and Threads can be provided a lifetime parameter, which we use to generate the filter + if (['invites', 'messages', 'threads'].includes(key) && !('filter' in props)) { + if (typeof props.lifetime !== 'number') { + throw new TypeError('INVALID_TYPE', `sweepers.${key}.lifetime`, 'number'); + } + return; + } + if (typeof props.filter !== 'function') { + throw new TypeError('INVALID_TYPE', `sweepers.${key}.filter`, 'function'); + } + } + + /** + * Initialize an interval for sweeping + * @param {string} intervalKey The name of the property that stores the interval for this sweeper + * @param {string} sweepKey The name of the function that sweeps the desired caches + * @param {Object} opts Validated options for a sweep + * @private + */ + _initInterval(intervalKey, sweepKey, opts) { + if (opts.interval <= 0 || opts.interval === Infinity) return; + this.intervals[intervalKey] = setInterval(() => { + const sweepFn = opts.filter(); + if (sweepFn === null) return; + if (typeof sweepFn !== 'function') throw new TypeError('SWEEP_FILTER_RETURN'); + this[sweepKey](sweepFn); + }, opts.interval * 1_000).unref(); + } +} + +module.exports = Sweepers; diff --git a/src/util/SystemChannelFlagsBitField.js b/src/util/SystemChannelFlagsBitField.js new file mode 100644 index 00000000..90f2cca --- /dev/null +++ b/src/util/SystemChannelFlagsBitField.js @@ -0,0 +1,42 @@ +'use strict'; + +const { GuildSystemChannelFlags } = require('discord-api-types/v9'); +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to interact with a {@link Guild#systemChannelFlags} bitfield. + * Note that all event message types are enabled by default, + * and by setting their corresponding flags you are disabling them + * @extends {BitField} + */ +class SystemChannelFlagsBitField extends BitField {} + +/** + * @name SystemChannelFlagsBitField + * @kind constructor + * @memberof SystemChannelFlagsBitField + * @param {SystemChannelFlagsResolvable} [bits=0] Bit(s) to read from + */ + +/** + * Bitfield of the packed bits + * @type {number} + * @name SystemChannelFlagsBitField#bitfield + */ + +/** + * Data that can be resolved to give a system channel flag bitfield. This can be: + * * A string (see {@link SystemChannelFlagsBitField.Flags}) + * * A system channel flag + * * An instance of SystemChannelFlagsBitField + * * An Array of SystemChannelFlagsResolvable + * @typedef {string|number|SystemChannelFlagsBitField|SystemChannelFlagsResolvable[]} SystemChannelFlagsResolvable + */ + +/** + * Numeric system channel flags. + * @type {GuildSystemChannelFlags} + */ +SystemChannelFlagsBitField.Flags = GuildSystemChannelFlags; + +module.exports = SystemChannelFlagsBitField; diff --git a/src/util/ThreadMemberFlagsBitField.js b/src/util/ThreadMemberFlagsBitField.js new file mode 100644 index 00000000..4f94b52 --- /dev/null +++ b/src/util/ThreadMemberFlagsBitField.js @@ -0,0 +1,30 @@ +'use strict'; + +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to interact with a {@link ThreadMember#flags} bitfield. + * @extends {BitField} + */ +class ThreadMemberFlagsBitField extends BitField {} + +/** + * @name ThreadMemberFlagsBitField + * @kind constructor + * @memberof ThreadMemberFlagsBitField + * @param {BitFieldResolvable} [bits=0] Bit(s) to read from + */ + +/** + * Bitfield of the packed bits + * @type {number} + * @name ThreadMemberFlagsBitField#bitfield + */ + +/** + * Numeric thread member flags. There are currently no bitflags relevant to bots for this. + * @type {Object} + */ +ThreadMemberFlagsBitField.Flags = {}; + +module.exports = ThreadMemberFlagsBitField; diff --git a/src/util/Transformers.js b/src/util/Transformers.js new file mode 100644 index 00000000..f7eed82 --- /dev/null +++ b/src/util/Transformers.js @@ -0,0 +1,20 @@ +'use strict'; + +const snakeCase = require('lodash.snakecase'); + +class Transformers extends null { + /** + * Transforms camel-cased keys into snake cased keys + * @param {*} obj The object to transform + * @returns {*} + */ + static toSnakeCase(obj) { + if (typeof obj !== 'object' || !obj) return obj; + if (Array.isArray(obj)) return obj.map(Transformers.toSnakeCase); + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [snakeCase(key), Transformers.toSnakeCase(value)]), + ); + } +} + +module.exports = Transformers; diff --git a/src/util/UserFlagsBitField.js b/src/util/UserFlagsBitField.js new file mode 100644 index 00000000..dc2a943 --- /dev/null +++ b/src/util/UserFlagsBitField.js @@ -0,0 +1,31 @@ +'use strict'; + +const { UserFlags } = require('discord-api-types/v9'); +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to interact with a {@link User#flags} bitfield. + * @extends {BitField} + */ +class UserFlagsBitField extends BitField {} + +/** + * @name UserFlagsBitField + * @kind constructor + * @memberof UserFlagsBitField + * @param {BitFieldResolvable} [bits=0] Bit(s) to read from + */ + +/** + * Bitfield of the packed bits + * @type {number} + * @name UserFlagsBitField#bitfield + */ + +/** + * Numeric user flags. + * @type {UserFlags} + */ +UserFlagsBitField.Flags = UserFlags; + +module.exports = UserFlagsBitField; diff --git a/src/util/Util.js b/src/util/Util.js new file mode 100644 index 00000000..2ab39b4 --- /dev/null +++ b/src/util/Util.js @@ -0,0 +1,569 @@ +'use strict'; + +const { parse } = require('node:path'); +const { Collection } = require('@discordjs/collection'); +const { ChannelType, RouteBases, Routes } = require('discord-api-types/v9'); +const { fetch } = require('undici'); +const Colors = require('./Colors'); +const { Error: DiscordError, RangeError, TypeError } = require('../errors'); +const isObject = d => typeof d === 'object' && d !== null; + +/** + * Contains various general-purpose utility methods. + */ +class Util extends null { + /** + * Flatten an object. Any properties that are collections will get converted to an array of keys. + * @param {Object} obj The object to flatten. + * @param {...Object} [props] Specific properties to include/exclude. + * @returns {Object} + */ + static flatten(obj, ...props) { + if (!isObject(obj)) return obj; + + const objProps = Object.keys(obj) + .filter(k => !k.startsWith('_')) + .map(k => ({ [k]: true })); + + props = objProps.length ? Object.assign(...objProps, ...props) : Object.assign({}, ...props); + + const out = {}; + + for (let [prop, newProp] of Object.entries(props)) { + if (!newProp) continue; + newProp = newProp === true ? prop : newProp; + + const element = obj[prop]; + const elemIsObj = isObject(element); + const valueOf = elemIsObj && typeof element.valueOf === 'function' ? element.valueOf() : null; + + // If it's a Collection, make the array of keys + if (element instanceof Collection) out[newProp] = Array.from(element.keys()); + // If the valueOf is a Collection, use its array of keys + else if (valueOf instanceof Collection) out[newProp] = Array.from(valueOf.keys()); + // If it's an array, flatten each element + else if (Array.isArray(element)) out[newProp] = element.map(e => Util.flatten(e)); + // If it's an object with a primitive `valueOf`, use that value + else if (typeof valueOf !== 'object') out[newProp] = valueOf; + // If it's a primitive + else if (!elemIsObj) out[newProp] = element; + } + + return out; + } + + /** + * Options for splitting a message. + * @typedef {Object} SplitOptions + * @property {number} [maxLength=2000] Maximum character length per message piece + * @property {string|string[]|RegExp|RegExp[]} [char='\n'] Character(s) or Regex(es) to split the message with, + * an array can be used to split multiple times + * @property {string} [prepend=''] Text to prepend to every piece except the first + * @property {string} [append=''] Text to append to every piece except the last + */ + + /** + * Splits a string into multiple chunks at a designated character that do not exceed a specific length. + * @param {string} text Content to split + * @param {SplitOptions} [options] Options controlling the behavior of the split + * @returns {string[]} + */ + static splitMessage(text, { maxLength = 2_000, char = '\n', prepend = '', append = '' } = {}) { + text = Util.verifyString(text); + if (text.length <= maxLength) return [text]; + let splitText = [text]; + if (Array.isArray(char)) { + while (char.length > 0 && splitText.some(elem => elem.length > maxLength)) { + const currentChar = char.shift(); + if (currentChar instanceof RegExp) { + splitText = splitText.flatMap(chunk => chunk.match(currentChar)); + } else { + splitText = splitText.flatMap(chunk => chunk.split(currentChar)); + } + } + } else { + splitText = text.split(char); + } + if (splitText.some(elem => elem.length > maxLength)) throw new RangeError('SPLIT_MAX_LEN'); + const messages = []; + let msg = ''; + for (const chunk of splitText) { + if (msg && (msg + char + chunk + append).length > maxLength) { + messages.push(msg + append); + msg = prepend; + } + msg += (msg && msg !== prepend ? char : '') + chunk; + } + return messages.concat(msg).filter(m => m); + } + + /** + * Options used to escape markdown. + * @typedef {Object} EscapeMarkdownOptions + * @property {boolean} [codeBlock=true] Whether to escape code blocks or not + * @property {boolean} [inlineCode=true] Whether to escape inline code or not + * @property {boolean} [bold=true] Whether to escape bolds or not + * @property {boolean} [italic=true] Whether to escape italics or not + * @property {boolean} [underline=true] Whether to escape underlines or not + * @property {boolean} [strikethrough=true] Whether to escape strikethroughs or not + * @property {boolean} [spoiler=true] Whether to escape spoilers or not + * @property {boolean} [codeBlockContent=true] Whether to escape text inside code blocks or not + * @property {boolean} [inlineCodeContent=true] Whether to escape text inside inline code or not + */ + + /** + * Escapes any Discord-flavour markdown in a string. + * @param {string} text Content to escape + * @param {EscapeMarkdownOptions} [options={}] Options for escaping the markdown + * @returns {string} + */ + static escapeMarkdown( + text, + { + codeBlock = true, + inlineCode = true, + bold = true, + italic = true, + underline = true, + strikethrough = true, + spoiler = true, + codeBlockContent = true, + inlineCodeContent = true, + } = {}, + ) { + if (!codeBlockContent) { + return text + .split('```') + .map((subString, index, array) => { + if (index % 2 && index !== array.length - 1) return subString; + return Util.escapeMarkdown(subString, { + inlineCode, + bold, + italic, + underline, + strikethrough, + spoiler, + inlineCodeContent, + }); + }) + .join(codeBlock ? '\\`\\`\\`' : '```'); + } + if (!inlineCodeContent) { + return text + .split(/(?<=^|[^`])`(?=[^`]|$)/g) + .map((subString, index, array) => { + if (index % 2 && index !== array.length - 1) return subString; + return Util.escapeMarkdown(subString, { + codeBlock, + bold, + italic, + underline, + strikethrough, + spoiler, + }); + }) + .join(inlineCode ? '\\`' : '`'); + } + if (inlineCode) text = Util.escapeInlineCode(text); + if (codeBlock) text = Util.escapeCodeBlock(text); + if (italic) text = Util.escapeItalic(text); + if (bold) text = Util.escapeBold(text); + if (underline) text = Util.escapeUnderline(text); + if (strikethrough) text = Util.escapeStrikethrough(text); + if (spoiler) text = Util.escapeSpoiler(text); + return text; + } + + /** + * Escapes code block markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeCodeBlock(text) { + return text.replaceAll('```', '\\`\\`\\`'); + } + + /** + * Escapes inline code markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeInlineCode(text) { + return text.replace(/(?<=^|[^`])`(?=[^`]|$)/g, '\\`'); + } + + /** + * Escapes italic markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeItalic(text) { + let i = 0; + text = text.replace(/(?<=^|[^*])\*([^*]|\*\*|$)/g, (_, match) => { + if (match === '**') return ++i % 2 ? `\\*${match}` : `${match}\\*`; + return `\\*${match}`; + }); + i = 0; + return text.replace(/(?<=^|[^_])_([^_]|__|$)/g, (_, match) => { + if (match === '__') return ++i % 2 ? `\\_${match}` : `${match}\\_`; + return `\\_${match}`; + }); + } + + /** + * Escapes bold markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeBold(text) { + let i = 0; + return text.replace(/\*\*(\*)?/g, (_, match) => { + if (match) return ++i % 2 ? `${match}\\*\\*` : `\\*\\*${match}`; + return '\\*\\*'; + }); + } + + /** + * Escapes underline markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeUnderline(text) { + let i = 0; + return text.replace(/__(_)?/g, (_, match) => { + if (match) return ++i % 2 ? `${match}\\_\\_` : `\\_\\_${match}`; + return '\\_\\_'; + }); + } + + /** + * Escapes strikethrough markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeStrikethrough(text) { + return text.replaceAll('~~', '\\~\\~'); + } + + /** + * Escapes spoiler markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeSpoiler(text) { + return text.replaceAll('||', '\\|\\|'); + } + + /** + * @typedef {Object} FetchRecommendedShardsOptions + * @property {number} [guildsPerShard=1000] Number of guilds assigned per shard + * @property {number} [multipleOf=1] The multiple the shard count should round up to. (16 for large bot sharding) + */ + + /** + * Gets the recommended shard count from Discord. + * @param {string} token Discord auth token + * @param {FetchRecommendedShardsOptions} [options] Options for fetching the recommended shard count + * @returns {Promise} The recommended number of shards + */ + static async fetchRecommendedShards(token, { guildsPerShard = 1_000, multipleOf = 1 } = {}) { + if (!token) throw new DiscordError('TOKEN_MISSING'); + const response = await fetch(RouteBases.api + Routes.gatewayBot(), { + method: 'GET', + headers: { Authorization: `Bot ${token.replace(/^Bot\s*/i, '')}` }, + }); + if (!response.ok) { + if (response.status === 401) throw new DiscordError('TOKEN_INVALID'); + throw response; + } + const { shards } = await response.json(); + return Math.ceil((shards * (1_000 / guildsPerShard)) / multipleOf) * multipleOf; + } + + /** + * Parses emoji info out of a string. The string must be one of: + * * A UTF-8 emoji (no id) + * * A URL-encoded UTF-8 emoji (no id) + * * A Discord custom emoji (`<:name:id>` or ``) + * @param {string} text Emoji string to parse + * @returns {APIEmoji} Object with `animated`, `name`, and `id` properties + * @private + */ + static parseEmoji(text) { + if (text.includes('%')) text = decodeURIComponent(text); + if (!text.includes(':')) return { animated: false, name: text, id: null }; + const match = text.match(/?/); + return match && { animated: Boolean(match[1]), name: match[2], id: match[3] ?? null }; + } + + /** + * Resolves a partial emoji object from an {@link EmojiIdentifierResolvable}, without checking a Client. + * @param {EmojiIdentifierResolvable} emoji Emoji identifier to resolve + * @returns {?RawEmoji} + * @private + */ + static resolvePartialEmoji(emoji) { + if (!emoji) return null; + if (typeof emoji === 'string') return /^\d{17,19}$/.test(emoji) ? { id: emoji } : Util.parseEmoji(emoji); + const { id, name, animated } = emoji; + if (!id && !name) return null; + return { id, name, animated: Boolean(animated) }; + } + + /** + * Shallow-copies an object with its class/prototype intact. + * @param {Object} obj Object to clone + * @returns {Object} + * @private + */ + static cloneObject(obj) { + return Object.assign(Object.create(obj), obj); + } + + /** + * Sets default properties on an object that aren't already specified. + * @param {Object} def Default properties + * @param {Object} given Object to assign defaults to + * @returns {Object} + * @private + */ + static mergeDefault(def, given) { + if (!given) return def; + for (const key in def) { + if (!Object.hasOwn(given, key) || given[key] === undefined) { + given[key] = def[key]; + } else if (given[key] === Object(given[key])) { + given[key] = Util.mergeDefault(def[key], given[key]); + } + } + + return given; + } + + /** + * Options used to make an error object. + * @typedef {Object} MakeErrorOptions + * @property {string} name Error type + * @property {string} message Message for the error + * @property {string} stack Stack for the error + */ + + /** + * Makes an Error from a plain info object. + * @param {MakeErrorOptions} obj Error info + * @returns {Error} + * @private + */ + static makeError(obj) { + const err = new Error(obj.message); + err.name = obj.name; + err.stack = obj.stack; + return err; + } + + /** + * Makes a plain error info object from an Error. + * @param {Error} err Error to get info from + * @returns {MakeErrorOptions} + * @private + */ + static makePlainError(err) { + return { + name: err.name, + message: err.message, + stack: err.stack, + }; + } + + /** + * Moves an element in an array *in place*. + * @param {Array<*>} array Array to modify + * @param {*} element Element to move + * @param {number} newIndex Index or offset to move the element to + * @param {boolean} [offset=false] Move the element by an offset amount rather than to a set index + * @returns {number} + * @private + */ + static moveElementInArray(array, element, newIndex, offset = false) { + const index = array.indexOf(element); + newIndex = (offset ? index : 0) + newIndex; + if (newIndex > -1 && newIndex < array.length) { + const removedElement = array.splice(index, 1)[0]; + array.splice(newIndex, 0, removedElement); + } + return array.indexOf(element); + } + + /** + * Verifies the provided data is a string, otherwise throws provided error. + * @param {string} data The string resolvable to resolve + * @param {Function} [error] The Error constructor to instantiate. Defaults to Error + * @param {string} [errorMessage] The error message to throw with. Defaults to "Expected string, got instead." + * @param {boolean} [allowEmpty=true] Whether an empty string should be allowed + * @returns {string} + */ + static verifyString( + data, + error = Error, + errorMessage = `Expected a string, got ${data} instead.`, + allowEmpty = true, + ) { + if (typeof data !== 'string') throw new error(errorMessage); + if (!allowEmpty && data.length === 0) throw new error(errorMessage); + return data; + } + + /** + * Can be a number, hex string, an RGB array like: + * ```js + * [255, 0, 255] // purple + * ``` + * or one of the following strings: + * - `Default` + * - `White` + * - `Aqua` + * - `Green` + * - `Blue` + * - `Yellow` + * - `Purple` + * - `LuminousVividPink` + * - `Fuchsia` + * - `Gold` + * - `Orange` + * - `Red` + * - `Grey` + * - `Navy` + * - `DarkAqua` + * - `DarkGreen` + * - `DarkBlue` + * - `DarkPurple` + * - `DarkVividPink` + * - `DarkGold` + * - `DarkOrange` + * - `DarkRed` + * - `DarkGrey` + * - `DarkerGrey` + * - `LightGrey` + * - `DarkNavy` + * - `Blurple` + * - `Greyple` + * - `DarkButNotBlack` + * - `NotQuiteBlack` + * - `Random` + * @typedef {string|number|number[]} ColorResolvable + */ + + /** + * Resolves a ColorResolvable into a color number. + * @param {ColorResolvable} color Color to resolve + * @returns {number} A color + */ + static resolveColor(color) { + if (typeof color === 'string') { + if (color === 'Random') return Math.floor(Math.random() * (0xffffff + 1)); + if (color === 'Default') return 0; + color = Colors[color] ?? parseInt(color.replace('#', ''), 16); + } else if (Array.isArray(color)) { + color = (color[0] << 16) + (color[1] << 8) + color[2]; + } + + if (color < 0 || color > 0xffffff) throw new RangeError('COLOR_RANGE'); + else if (Number.isNaN(color)) throw new TypeError('COLOR_CONVERT'); + + return color; + } + + /** + * Sorts by Discord's position and id. + * @param {Collection} collection Collection of objects to sort + * @returns {Collection} + */ + static discordSort(collection) { + const isGuildChannel = collection.first() instanceof GuildChannel; + return collection.sorted( + isGuildChannel + ? (a, b) => a.rawPosition - b.rawPosition || Number(BigInt(a.id) - BigInt(b.id)) + : (a, b) => a.rawPosition - b.rawPosition || Number(BigInt(b.id) - BigInt(a.id)), + ); + } + + /** + * Sets the position of a Channel or Role. + * @param {Channel|Role} item Object to set the position of + * @param {number} position New position for the object + * @param {boolean} relative Whether `position` is relative to its current position + * @param {Collection} sorted A collection of the objects sorted properly + * @param {Client} client The client to use to patch the data + * @param {string} route Route to call PATCH on + * @param {string} [reason] Reason for the change + * @returns {Promise} Updated item list, with `id` and `position` properties + * @private + */ + static async setPosition(item, position, relative, sorted, client, route, reason) { + let updatedItems = [...sorted.values()]; + Util.moveElementInArray(updatedItems, item, position, relative); + updatedItems = updatedItems.map((r, i) => ({ id: r.id, position: i })); + await client.rest.patch(route, { body: updatedItems, reason }); + return updatedItems; + } + + /** + * Alternative to Node's `path.basename`, removing query string after the extension if it exists. + * @param {string} path Path to get the basename of + * @param {string} [ext] File extension to remove + * @returns {string} Basename of the path + * @private + */ + static basename(path, ext) { + const res = parse(path); + return ext && res.ext.startsWith(ext) ? res.name : res.base.split('?')[0]; + } + /** + * The content to have all mentions replaced by the equivalent text. + * @param {string} str The string to be converted + * @param {TextBasedChannels} channel The channel the string was sent in + * @returns {string} + */ + static cleanContent(str, channel) { + str = str + .replace(/<@!?[0-9]+>/g, input => { + const id = input.replace(/<|!|>|@/g, ''); + if (channel.type === ChannelType.DM) { + const user = channel.client.users.cache.get(id); + return user ? `@${user.username}` : input; + } + + const member = channel.guild.members.cache.get(id); + if (member) { + return `@${member.displayName}`; + } else { + const user = channel.client.users.cache.get(id); + return user ? `@${user.username}` : input; + } + }) + .replace(/<#[0-9]+>/g, input => { + const mentionedChannel = channel.client.channels.cache.get(input.replace(/<|#|>/g, '')); + return mentionedChannel ? `#${mentionedChannel.name}` : input; + }) + .replace(/<@&[0-9]+>/g, input => { + if (channel.type === ChannelType.DM) return input; + const role = channel.guild.roles.cache.get(input.replace(/<|@|>|&/g, '')); + return role ? `@${role.name}` : input; + }); + return str; + } + + /** + * The content to put in a code block with all code block fences replaced by the equivalent backticks. + * @param {string} text The string to be converted + * @returns {string} + */ + static cleanCodeBlockContent(text) { + return text.replaceAll('```', '`\u200b``'); + } +} + +module.exports = Util; + +// Fixes Circular +const GuildChannel = require('../structures/GuildChannel'); diff --git a/tslint.json b/tslint.json new file mode 100644 index 00000000..d66f7bd --- /dev/null +++ b/tslint.json @@ -0,0 +1,29 @@ +{ + "extends": ["dtslint/dtslint.json"], + "rules": { + "prefer-readonly": false, + "await-promise": false, + "no-for-in-array": false, + "no-null-undefined-union": false, + "no-promise-as-boolean": false, + "no-void-expression": false, + "strict-string-expressions": false, + "strict-comparisons": false, + "use-default-type-parameter": false, + "no-boolean-literal-compare": false, + "no-unnecessary-qualifier": false, + "no-unnecessary-type-assertion": false, + "expect": false, + "no-import-default-of-export-equals": false, + "no-relative-import-in-test": false, + "no-unnecessary-generics": false, + "strict-export-declare-modifiers": false, + "no-single-declare-module": false, + "member-access": true, + "no-unnecessary-class": false, + "array-type": [true, "array"], + "one-line": false, + "no-any-union": false, + "void-return": false + } +} diff --git a/typings/enums.d.ts b/typings/enums.d.ts new file mode 100644 index 00000000..f8efb4a --- /dev/null +++ b/typings/enums.d.ts @@ -0,0 +1,198 @@ +// These are enums that are used in the typings file but do not exist as actual exported values. To prevent them from +// showing up in an editor, they are imported from here instead of exporting them there directly. + +export const enum ActivityTypes { + PLAYING = 0, + STREAMING = 1, + LISTENING = 2, + WATCHING = 3, + CUSTOM = 4, + COMPETING = 5, +} + +export const enum ApplicationCommandTypes { + CHAT_INPUT = 1, + USER = 2, + MESSAGE = 3, +} + +export const enum ApplicationCommandOptionTypes { + SUB_COMMAND = 1, + SUB_COMMAND_GROUP = 2, + STRING = 3, + INTEGER = 4, + BOOLEAN = 5, + USER = 6, + CHANNEL = 7, + ROLE = 8, + MENTIONABLE = 9, + NUMBER = 10, +} + +export const enum ApplicationCommandPermissionTypes { + ROLE = 1, + USER = 2, +} + +export const enum ChannelTypes { + GUILD_TEXT = 0, + DM = 1, + GUILD_VOICE = 2, + GROUP_DM = 3, + GUILD_CATEGORY = 4, + GUILD_NEWS = 5, + GUILD_STORE = 6, + UNKNOWN = 7, + GUILD_NEWS_THREAD = 10, + GUILD_PUBLIC_THREAD = 11, + GUILD_PRIVATE_THREAD = 12, + GUILD_STAGE_VOICE = 13, +} + +export const enum MessageTypes { + DEFAULT, + RECIPIENT_ADD, + RECIPIENT_REMOVE, + CALL, + CHANNEL_NAME_CHANGE, + CHANNEL_ICON_CHANGE, + CHANNEL_PINNED_MESSAGE, + GUILD_MEMBER_JOIN, + USER_PREMIUM_GUILD_SUBSCRIPTION, + USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1, + USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2, + USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3, + CHANNEL_FOLLOW_ADD, + GUILD_DISCOVERY_DISQUALIFIED = 14, + GUILD_DISCOVERY_REQUALIFIED, + GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING, + GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING, + THREAD_CREATED, + REPLY, + APPLICATION_COMMAND, + THREAD_STARTER_MESSAGE, + GUILD_INVITE_REMINDER, + CONTEXT_MENU_COMMAND, +} + +export const enum DefaultMessageNotificationLevels { + ALL_MESSAGES = 0, + ONLY_MENTIONS = 1, +} + +export const enum ExplicitContentFilterLevels { + DISABLED = 0, + MEMBERS_WITHOUT_ROLES = 1, + ALL_MEMBERS = 2, +} + +export const enum GuildScheduledEventEntityTypes { + STAGE_INSTANCE = 1, + VOICE = 2, + EXTERNAL = 3, +} + +export const enum GuildScheduledEventPrivacyLevels { + GUILD_ONLY = 2, +} + +export const enum GuildScheduledEventStatuses { + SCHEDULED = 1, + ACTIVE = 2, + COMPLETED = 3, + CANCELED = 4, +} + +export const enum InteractionResponseTypes { + PONG = 1, + CHANNEL_MESSAGE_WITH_SOURCE = 4, + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, + DEFERRED_MESSAGE_UPDATE = 6, + UPDATE_MESSAGE = 7, + APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8, +} + +export const enum InteractionTypes { + PING = 1, + APPLICATION_COMMAND = 2, + MESSAGE_COMPONENT = 3, + APPLICATION_COMMAND_AUTOCOMPLETE = 4, +} + +export const enum InviteTargetType { + STREAM = 1, + EMBEDDED_APPLICATION = 2, +} + +export const enum MembershipStates { + INVITED = 1, + ACCEPTED = 2, +} + +export const enum MessageButtonStyles { + PRIMARY = 1, + SECONDARY = 2, + SUCCESS = 3, + DANGER = 4, + LINK = 5, +} + +export const enum MessageComponentTypes { + ACTION_ROW = 1, + BUTTON = 2, + SELECT_MENU = 3, +} + +export const enum MFALevels { + NONE = 0, + ELEVATED = 1, +} + +export const enum NSFWLevels { + DEFAULT = 0, + EXPLICIT = 1, + SAFE = 2, + AGE_RESTRICTED = 3, +} + +export const enum OverwriteTypes { + role = 0, + member = 1, +} + +export const enum PremiumTiers { + NONE = 0, + TIER_1 = 1, + TIER_2 = 2, + TIER_3 = 3, +} + +export const enum PrivacyLevels { + PUBLIC = 1, + GUILD_ONLY = 2, +} + +export const enum StickerFormatTypes { + PNG = 1, + APNG = 2, + LOTTIE = 3, +} + +export const enum StickerTypes { + STANDARD = 1, + GUILD = 2, +} + +export const enum VerificationLevels { + NONE = 0, + LOW = 1, + MEDIUM = 2, + HIGH = 3, + VERY_HIGH = 4, +} + +export const enum WebhookTypes { + Incoming = 1, + 'Channel Follower' = 2, + Application = 3, +} diff --git a/typings/index.d.ts b/typings/index.d.ts new file mode 100644 index 00000000..b6ba788 --- /dev/null +++ b/typings/index.d.ts @@ -0,0 +1,5847 @@ +import { + blockQuote, + bold, + channelMention, + codeBlock, + formatEmoji, + hideLinkEmbed, + hyperlink, + inlineCode, + italic, + memberNicknameMention, + quote, + roleMention, + spoiler, + strikethrough, + time, + TimestampStyles, + TimestampStylesString, + underscore, + userMention, +} from '@discordjs/builders'; +import { Collection } from '@discordjs/collection'; +import { + APIActionRowComponent, + APIApplicationCommand, + APIApplicationCommandInteractionData, + APIApplicationCommandOption, + APIApplicationCommandPermission, + APIAuditLogChange, + APIButtonComponent, + APIChannel, + APIEmbed, + APIEmoji, + APIInteractionDataResolvedChannel, + APIInteractionDataResolvedGuildMember, + APIInteractionGuildMember, + APIMessage, + APIMessageComponent, + APIOverwrite, + APIPartialChannel, + APIPartialEmoji, + APIPartialGuild, + APIRole, + APISelectMenuComponent, + APITemplateSerializedSourceGuild, + APIUser, + GatewayVoiceServerUpdateDispatchData, + GatewayVoiceStateUpdateDispatchData, + RESTPostAPIApplicationCommandsJSONBody, + Snowflake, +} from 'discord-api-types/v9'; +import { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { AgentOptions } from 'node:https'; +import { Response } from 'node-fetch'; +import { Stream } from 'node:stream'; +import { MessagePort, Worker } from 'node:worker_threads'; +import * as WebSocket from 'ws'; +import { + ActivityTypes, + ApplicationCommandOptionTypes, + ApplicationCommandPermissionTypes, + ApplicationCommandTypes, + ChannelTypes, + DefaultMessageNotificationLevels, + ExplicitContentFilterLevels, + InteractionResponseTypes, + InteractionTypes, + InviteTargetType, + MembershipStates, + MessageButtonStyles, + MessageComponentTypes, + MessageTypes, + MFALevels, + NSFWLevels, + OverwriteTypes, + PremiumTiers, + PrivacyLevels, + StickerFormatTypes, + StickerTypes, + VerificationLevels, + WebhookTypes, + GuildScheduledEventEntityTypes, + GuildScheduledEventStatuses, + GuildScheduledEventPrivacyLevels, +} from './enums'; +import { + RawActivityData, + RawAnonymousGuildData, + RawApplicationCommandData, + RawApplicationData, + RawBaseGuildData, + RawChannelData, + RawClientApplicationData, + RawDMChannelData, + RawEmojiData, + RawGuildAuditLogData, + RawGuildAuditLogEntryData, + RawGuildBanData, + RawGuildChannelData, + RawGuildData, + RawGuildEmojiData, + RawGuildMemberData, + RawGuildPreviewData, + RawGuildScheduledEventData, + RawGuildTemplateData, + RawIntegrationApplicationData, + RawIntegrationData, + RawInteractionData, + RawInviteData, + RawInviteGuildData, + RawInviteStageInstance, + RawMessageAttachmentData, + RawMessageButtonInteractionData, + RawMessageComponentInteractionData, + RawMessageData, + RawMessagePayloadData, + RawMessageReactionData, + RawMessageSelectMenuInteractionData, + RawOAuth2GuildData, + RawPartialGroupDMChannelData, + RawPartialMessageData, + RawPermissionOverwriteData, + RawPresenceData, + RawReactionEmojiData, + RawRichPresenceAssets, + RawRoleData, + RawStageInstanceData, + RawStickerData, + RawStickerPackData, + RawTeamData, + RawTeamMemberData, + RawThreadChannelData, + RawThreadMemberData, + RawTypingData, + RawUserData, + RawVoiceRegionData, + RawVoiceStateData, + RawWebhookData, + RawWelcomeChannelData, + RawWelcomeScreenData, + RawWidgetData, + RawWidgetMemberData, +} from './rawDataTypes'; + +//#region Classes + +export class Activity { + private constructor(presence: Presence, data?: RawActivityData); + public applicationId: Snowflake | null; + public assets: RichPresenceAssets | null; + public buttons: string[]; + public readonly createdAt: Date; + public createdTimestamp: number; + public details: string | null; + public emoji: Emoji | null; + public flags: Readonly; + public id: string; + public name: string; + public party: { + id: string | null; + size: [number, number]; + } | null; + public platform: ActivityPlatform | null; + public sessionId: string | null; + public state: string | null; + public syncId: string | null; + public timestamps: { + start: Date | null; + end: Date | null; + } | null; + public type: ActivityType; + public url: string | null; + public equals(activity: Activity): boolean; +} + +export class ActivityFlags extends BitField { + public static FLAGS: Record; + public static resolve(bit?: BitFieldResolvable): number; +} + +export abstract class AnonymousGuild extends BaseGuild { + protected constructor(client: Client, data: RawAnonymousGuildData, immediatePatch?: boolean); + public banner: string | null; + public description: string | null; + public nsfwLevel: NSFWLevel; + public splash: string | null; + public vanityURLCode: string | null; + public verificationLevel: VerificationLevel; + public bannerURL(options?: StaticImageURLOptions): string | null; + public splashURL(options?: StaticImageURLOptions): string | null; +} + +export abstract class Application extends Base { + protected constructor(client: Client, data: RawApplicationData); + public readonly createdAt: Date; + public readonly createdTimestamp: number; + public description: string | null; + public icon: string | null; + public id: Snowflake; + public name: string | null; + public coverURL(options?: StaticImageURLOptions): string | null; + public fetchAssets(): Promise; + public iconURL(options?: StaticImageURLOptions): string | null; + public toJSON(): unknown; + public toString(): string | null; +} + +export class ApplicationCommand extends Base { + private constructor(client: Client, data: RawApplicationCommandData, guild?: Guild, guildId?: Snowflake); + public applicationId: Snowflake; + public readonly createdAt: Date; + public readonly createdTimestamp: number; + public defaultPermission: boolean; + public description: string; + public guild: Guild | null; + public guildId: Snowflake | null; + public readonly manager: ApplicationCommandManager; + public id: Snowflake; + public name: string; + public options: ApplicationCommandOption[]; + public permissions: ApplicationCommandPermissionsManager< + PermissionsFetchType, + PermissionsFetchType, + PermissionsFetchType, + Guild | null, + Snowflake + >; + public type: ApplicationCommandType; + public version: Snowflake; + public delete(): Promise>; + public edit(data: ApplicationCommandData): Promise>; + public setName(name: string): Promise>; + public setDescription(description: string): Promise>; + public setDefaultPermission(defaultPermission?: boolean): Promise>; + public setOptions(options: ApplicationCommandOptionData[]): Promise>; + public equals( + command: ApplicationCommand | ApplicationCommandData | RawApplicationCommandData, + enforceOptionorder?: boolean, + ): boolean; + public static optionsEqual( + existing: ApplicationCommandOption[], + options: ApplicationCommandOption[] | ApplicationCommandOptionData[] | APIApplicationCommandOption[], + enforceOptionorder?: boolean, + ): boolean; + private static _optionEquals( + existing: ApplicationCommandOption, + options: ApplicationCommandOption | ApplicationCommandOptionData | APIApplicationCommandOption, + enforceOptionorder?: boolean, + ): boolean; + private static transformOption(option: ApplicationCommandOptionData, received?: boolean): unknown; + private static transformCommand(command: ApplicationCommandData): RESTPostAPIApplicationCommandsJSONBody; + private static isAPICommandData(command: object): command is RESTPostAPIApplicationCommandsJSONBody; +} + +export type ApplicationResolvable = Application | Activity | Snowflake; + +export class ApplicationFlags extends BitField { + public static FLAGS: Record; + public static resolve(bit?: BitFieldResolvable): number; +} + +export abstract class Base { + public constructor(client: Client); + public readonly client: Client; + public toJSON(...props: Record[]): unknown; + public valueOf(): string; +} + +export class BaseClient extends EventEmitter { + public constructor(options?: ClientOptions | WebhookClientOptions); + private readonly api: unknown; + private rest: unknown; + private decrementMaxListeners(): void; + private incrementMaxListeners(): void; + + public on( + event: K, + listener: (...args: BaseClientEvents[K]) => Awaitable, + ): this; + public on( + event: Exclude, + listener: (...args: any[]) => Awaitable, + ): this; + + public once( + event: K, + listener: (...args: BaseClientEvents[K]) => Awaitable, + ): this; + public once( + event: Exclude, + listener: (...args: any[]) => Awaitable, + ): this; + + public emit(event: K, ...args: BaseClientEvents[K]): boolean; + public emit(event: Exclude, ...args: unknown[]): boolean; + + public off( + event: K, + listener: (...args: BaseClientEvents[K]) => Awaitable, + ): this; + public off( + event: Exclude, + listener: (...args: any[]) => Awaitable, + ): this; + + public removeAllListeners(event?: K): this; + public removeAllListeners(event?: Exclude): this; + + public options: ClientOptions | WebhookClientOptions; + public destroy(): void; + public toJSON(...props: Record[]): unknown; +} + +export type GuildCacheMessage = CacheTypeReducer< + Cached, + Message, + APIMessage, + Message | APIMessage, + Message | APIMessage +>; + +export abstract class BaseCommandInteraction extends Interaction { + public readonly command: ApplicationCommand | ApplicationCommand<{ guild: GuildResolvable }> | null; + public options: Omit< + CommandInteractionOptionResolver, + | 'getMessage' + | 'getFocused' + | 'getMentionable' + | 'getRole' + | 'getNumber' + | 'getInteger' + | 'getString' + | 'getChannel' + | 'getBoolean' + | 'getSubcommandGroup' + | 'getSubcommand' + >; + public channelId: Snowflake; + public commandId: Snowflake; + public commandName: string; + public deferred: boolean; + public ephemeral: boolean | null; + public replied: boolean; + public webhook: InteractionWebhook; + public inGuild(): this is BaseCommandInteraction<'present'>; + public inCachedGuild(): this is BaseCommandInteraction<'cached'>; + public inRawGuild(): this is BaseCommandInteraction<'raw'>; + public deferReply(options: InteractionDeferReplyOptions & { fetchReply: true }): Promise>; + public deferReply(options?: InteractionDeferReplyOptions): Promise; + public deleteReply(): Promise; + public editReply(options: string | MessagePayload | WebhookEditMessageOptions): Promise>; + public fetchReply(): Promise>; + public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise>; + public reply(options: InteractionReplyOptions & { fetchReply: true }): Promise>; + public reply(options: string | MessagePayload | InteractionReplyOptions): Promise; + private transformOption( + option: APIApplicationCommandOption, + resolved: APIApplicationCommandInteractionData['resolved'], + ): CommandInteractionOption; + private transformResolved( + resolved: APIApplicationCommandInteractionData['resolved'], + ): CommandInteractionResolvedData; +} + +export abstract class BaseGuild extends Base { + protected constructor(client: Client, data: RawBaseGuildData); + public readonly createdAt: Date; + public readonly createdTimestamp: number; + public features: GuildFeatures[]; + public icon: string | null; + public id: Snowflake; + public name: string; + public readonly nameAcronym: string; + public readonly partnered: boolean; + public readonly verified: boolean; + public fetch(): Promise; + public iconURL(options?: ImageURLOptions): string | null; + public toString(): string; +} + +export class BaseGuildEmoji extends Emoji { + protected constructor(client: Client, data: RawGuildEmojiData, guild: Guild | GuildPreview); + public available: boolean | null; + public readonly createdAt: Date; + public readonly createdTimestamp: number; + public guild: Guild | GuildPreview; + public id: Snowflake; + public managed: boolean | null; + public requiresColons: boolean | null; +} + +export class BaseGuildTextChannel extends TextBasedChannelMixin(GuildChannel) { + protected constructor(guild: Guild, data?: RawGuildChannelData, client?: Client, immediatePatch?: boolean); + public defaultAutoArchiveDuration?: ThreadAutoArchiveDuration; + public messages: MessageManager; + public nsfw: boolean; + public threads: ThreadManager; + public topic: string | null; + public createInvite(options?: CreateInviteOptions): Promise; + public createWebhook(name: string, options?: ChannelWebhookCreateOptions): Promise; + public fetchInvites(cache?: boolean): Promise>; + public setDefaultAutoArchiveDuration( + defaultAutoArchiveDuration: ThreadAutoArchiveDuration, + reason?: string, + ): Promise; + public setNSFW(nsfw?: boolean, reason?: string): Promise; + public setTopic(topic: string | null, reason?: string): Promise; + public setType(type: Pick, reason?: string): Promise; + public setType(type: Pick, reason?: string): Promise; + public fetchWebhooks(): Promise>; +} + +export class BaseGuildVoiceChannel extends GuildChannel { + protected constructor(guild: Guild, data?: RawGuildChannelData); + public readonly members: Collection; + public readonly full: boolean; + public readonly joinable: boolean; + public rtcRegion: string | null; + public bitrate: number; + public userLimit: number; + public createInvite(options?: CreateInviteOptions): Promise; + public setRTCRegion(region: string | null): Promise; + public fetchInvites(cache?: boolean): Promise>; +} + +export class BaseMessageComponent { + protected constructor(data?: BaseMessageComponent | BaseMessageComponentOptions); + public type: MessageComponentType | null; + private static create(data: MessageComponentOptions, client?: Client | WebhookClient): MessageComponent | undefined; + private static resolveType(type: MessageComponentTypeResolvable): MessageComponentType; +} + +export class BitField { + public constructor(bits?: BitFieldResolvable); + public bitfield: N; + public add(...bits: BitFieldResolvable[]): BitField; + public any(bit: BitFieldResolvable): boolean; + public equals(bit: BitFieldResolvable): boolean; + public freeze(): Readonly>; + public has(bit: BitFieldResolvable): boolean; + public missing(bits: BitFieldResolvable, ...hasParams: readonly unknown[]): S[]; + public remove(...bits: BitFieldResolvable[]): BitField; + public serialize(...hasParams: readonly unknown[]): Record; + public toArray(...hasParams: readonly unknown[]): S[]; + public toJSON(): N extends number ? number : string; + public valueOf(): N; + public [Symbol.iterator](): IterableIterator; + public static FLAGS: Record; + public static resolve(bit?: BitFieldResolvable): number | bigint; +} + +export class ButtonInteraction extends MessageComponentInteraction { + private constructor(client: Client, data: RawMessageButtonInteractionData); + public readonly component: CacheTypeReducer< + Cached, + MessageButton, + APIButtonComponent, + MessageButton | APIButtonComponent, + MessageButton | APIButtonComponent + >; + public componentType: 'BUTTON'; + public inGuild(): this is ButtonInteraction<'present'>; + public inCachedGuild(): this is ButtonInteraction<'cached'>; + public inRawGuild(): this is ButtonInteraction<'raw'>; +} + +export type KeyedEnum = { + [Key in keyof K]: T | string; +}; + +export type EnumValueMapped, T extends Partial>> = T & { + [Key in keyof T as E[Key]]: T[Key]; +}; + +export type MappedChannelCategoryTypes = EnumValueMapped< + typeof ChannelTypes, + { + GUILD_NEWS: NewsChannel; + GUILD_VOICE: VoiceChannel; + GUILD_TEXT: TextChannel; + GUILD_STORE: StoreChannel; + GUILD_STAGE_VOICE: StageChannel; + } +>; + +export type CategoryChannelTypes = ExcludeEnum< + typeof ChannelTypes, + | 'DM' + | 'GROUP_DM' + | 'UNKNOWN' + | 'GUILD_PUBLIC_THREAD' + | 'GUILD_NEWS_THREAD' + | 'GUILD_PRIVATE_THREAD' + | 'GUILD_CATEGORY' +>; + +export class CategoryChannel extends GuildChannel { + public readonly children: Collection>; + public type: 'GUILD_CATEGORY'; + + public createChannel>( + name: string, + options: CategoryCreateChannelOptions & { type: T }, + ): Promise; + + /** @deprecated See [Self-serve Game Selling Deprecation](https://support-dev.discord.com/hc/en-us/articles/4414590563479) for more information */ + public createChannel( + name: string, + options: CategoryCreateChannelOptions & { type: 'GUILD_STORE' | ChannelTypes.GUILD_STORE }, + ): Promise; + public createChannel(name: string, options?: CategoryCreateChannelOptions): Promise; +} + +export type CategoryChannelResolvable = Snowflake | CategoryChannel; + +export abstract class Channel extends Base { + public constructor(client: Client, data?: RawChannelData, immediatePatch?: boolean); + public readonly createdAt: Date; + public readonly createdTimestamp: number; + /** @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 */ + public deleted: boolean; + public id: Snowflake; + public readonly partial: false; + public type: keyof typeof ChannelTypes; + public delete(): Promise; + public fetch(force?: boolean): Promise; + public isText(): this is TextBasedChannel; + public isVoice(): this is BaseGuildVoiceChannel; + public isThread(): this is ThreadChannel; + public toString(): ChannelMention; +} + +export type If = T extends true ? A : T extends false ? B : A | B; + +export class Client extends BaseClient { + public constructor(options: ClientOptions); + private actions: unknown; + private presence: ClientPresence; + private _eval(script: string): unknown; + + public application: If; + public bot: Boolean; + public channels: ChannelManager; + // Added + public setting: ClientUserSettingManager; + // End + public readonly emojis: BaseGuildEmojiManager; + public guilds: GuildManager; + public options: ClientOptions; + public readyAt: If; + public readonly readyTimestamp: If; + public sweepers: Sweepers; + public shard: ShardClientUtil | null; + public token: If; + public session_id: String; + public uptime: If; + public user: If; + public users: UserManager; + public friends: FriendsManager; + public blocked: BlockedManager; + public voice: ClientVoiceManager; + public ws: WebSocketManager; + public destroy(): void; + public fetchGuildPreview(guild: GuildResolvable): Promise; + public fetchInvite(invite: InviteResolvable, options?: ClientFetchInviteOptions): Promise; + public fetchGuildTemplate(template: GuildTemplateResolvable): Promise; + public fetchVoiceRegions(): Promise>; + public fetchSticker(id: Snowflake): Promise; + public fetchPremiumStickerPacks(): Promise>; + public fetchWebhook(id: Snowflake, token?: string): Promise; + public fetchGuildWidget(guild: GuildResolvable): Promise; + public login(token?: string, bot?: Boolean): Promise; + public isReady(): this is Client; + /** @deprecated Use {@link Sweepers#sweepMessages} instead */ + public sweepMessages(lifetime?: number): number; + public toJSON(): unknown; + + public on(event: K, listener: (...args: ClientEvents[K]) => Awaitable): this; + public on( + event: Exclude, + listener: (...args: any[]) => Awaitable, + ): this; + + public once(event: K, listener: (...args: ClientEvents[K]) => Awaitable): this; + public once( + event: Exclude, + listener: (...args: any[]) => Awaitable, + ): this; + + public emit(event: K, ...args: ClientEvents[K]): boolean; + public emit(event: Exclude, ...args: unknown[]): boolean; + + public off(event: K, listener: (...args: ClientEvents[K]) => Awaitable): this; + public off( + event: Exclude, + listener: (...args: any[]) => Awaitable, + ): this; + + public removeAllListeners(event?: K): this; + public removeAllListeners(event?: Exclude): this; +} + +export class ClientApplication extends Application { + private constructor(client: Client, data: RawClientApplicationData); + public botPublic: boolean | null; + public botRequireCodeGrant: boolean | null; + public commands: ApplicationCommandManager; + public cover: string | null; + public flags: Readonly; + public owner: User | Team | null; + public readonly partial: boolean; + public rpcOrigins: string[]; + public fetch(): Promise; +} + +export class ClientPresence extends Presence { + private constructor(client: Client, data: RawPresenceData); + private _parse(data: PresenceData): RawPresenceData; + + public set(presence: PresenceData): ClientPresence; +} + +export class ClientUser extends User { + public mfaEnabled: boolean; + public readonly presence: ClientPresence; + public verified: boolean; + public edit(data: ClientUserEditData): Promise; + public setActivity(options?: ActivityOptions): ClientPresence; + public setActivity(name: string, options?: ActivityOptions): ClientPresence; + public setAFK(afk?: boolean, shardId?: number | number[]): ClientPresence; + public setAvatar(avatar: BufferResolvable | Base64Resolvable | null): Promise; + public setPresence(data: PresenceData): ClientPresence; + public setStatus(status: PresenceStatusData, shardId?: number | number[]): ClientPresence; + public setUsername(username: string): Promise; +} + +export class Options extends null { + private constructor(); + public static defaultMakeCacheSettings: CacheWithLimitsOptions; + public static defaultSweeperSettings: SweeperOptions; + public static createDefault(): ClientOptions; + public static cacheWithLimits(settings?: CacheWithLimitsOptions): CacheFactory; + public static cacheEverything(): CacheFactory; +} + +export class ClientVoiceManager { + private constructor(client: Client); + public readonly client: Client; + public adapters: Map; +} + +export { Collection } from '@discordjs/collection'; + +export interface CollectorEventTypes { + collect: [V, ...F]; + dispose: [V, ...F]; + end: [collected: Collection, reason: string]; +} + +export abstract class Collector extends EventEmitter { + protected constructor(client: Client, options?: CollectorOptions<[V, ...F]>); + private _timeout: NodeJS.Timeout | null; + private _idletimeout: NodeJS.Timeout | null; + + public readonly client: Client; + public collected: Collection; + public ended: boolean; + public abstract readonly endReason: string | null; + public filter: CollectorFilter<[V, ...F]>; + public readonly next: Promise; + public options: CollectorOptions<[V, ...F]>; + public checkEnd(): boolean; + public handleCollect(...args: unknown[]): Promise; + public handleDispose(...args: unknown[]): Promise; + public stop(reason?: string): void; + public resetTimer(options?: CollectorResetTimerOptions): void; + public [Symbol.asyncIterator](): AsyncIterableIterator; + public toJSON(): unknown; + + protected listener: (...args: any[]) => void; + public abstract collect(...args: unknown[]): K | null | Promise; + public abstract dispose(...args: unknown[]): K | null; + + public on>( + event: EventKey, + listener: (...args: CollectorEventTypes[EventKey]) => Awaitable, + ): this; + + public once>( + event: EventKey, + listener: (...args: CollectorEventTypes[EventKey]) => Awaitable, + ): this; +} + +export interface ApplicationCommandInteractionOptionResolver + extends CommandInteractionOptionResolver { + getSubcommand(required?: true): string; + getSubcommand(required: boolean): string | null; + getSubcommandGroup(required?: true): string; + getSubcommandGroup(required: boolean): string | null; + getBoolean(name: string, required: true): boolean; + getBoolean(name: string, required?: boolean): boolean | null; + getChannel(name: string, required: true): NonNullable['channel']>; + getChannel(name: string, required?: boolean): NonNullable['channel']> | null; + getString(name: string, required: true): string; + getString(name: string, required?: boolean): string | null; + getInteger(name: string, required: true): number; + getInteger(name: string, required?: boolean): number | null; + getNumber(name: string, required: true): number; + getNumber(name: string, required?: boolean): number | null; + getUser(name: string, required: true): NonNullable['user']>; + getUser(name: string, required?: boolean): NonNullable['user']> | null; + getMember(name: string, required: true): NonNullable['member']>; + getMember(name: string, required?: boolean): NonNullable['member']> | null; + getRole(name: string, required: true): NonNullable['role']>; + getRole(name: string, required?: boolean): NonNullable['role']> | null; + getMentionable( + name: string, + required: true, + ): NonNullable['member' | 'role' | 'user']>; + getMentionable( + name: string, + required?: boolean, + ): NonNullable['member' | 'role' | 'user']> | null; +} + +export class CommandInteraction extends BaseCommandInteraction { + public options: Omit, 'getMessage' | 'getFocused'>; + public inGuild(): this is CommandInteraction<'present'>; + public inCachedGuild(): this is CommandInteraction<'cached'>; + public inRawGuild(): this is CommandInteraction<'raw'>; + public toString(): string; +} + +export class AutocompleteInteraction extends Interaction { + public readonly command: ApplicationCommand | ApplicationCommand<{ guild: GuildResolvable }> | null; + public channelId: Snowflake; + public commandId: Snowflake; + public commandName: string; + public responded: boolean; + public options: Omit, 'getMessage'>; + public inGuild(): this is AutocompleteInteraction<'present'>; + public inCachedGuild(): this is AutocompleteInteraction<'cached'>; + public inRawGuild(): this is AutocompleteInteraction<'raw'>; + private transformOption(option: APIApplicationCommandOption): CommandInteractionOption; + public respond(options: ApplicationCommandOptionChoice[]): Promise; +} + +export class CommandInteractionOptionResolver { + private constructor(client: Client, options: CommandInteractionOption[], resolved: CommandInteractionResolvedData); + public readonly client: Client; + public readonly data: readonly CommandInteractionOption[]; + public readonly resolved: Readonly>; + private _group: string | null; + private _hoistedOptions: CommandInteractionOption[]; + private _subcommand: string | null; + private _getTypedOption( + name: string, + type: ApplicationCommandOptionType, + properties: (keyof ApplicationCommandOption)[], + required: true, + ): CommandInteractionOption; + private _getTypedOption( + name: string, + type: ApplicationCommandOptionType, + properties: (keyof ApplicationCommandOption)[], + required: boolean, + ): CommandInteractionOption | null; + + public get(name: string, required: true): CommandInteractionOption; + public get(name: string, required?: boolean): CommandInteractionOption | null; + + public getSubcommand(required?: true): string; + public getSubcommand(required: boolean): string | null; + public getSubcommandGroup(required?: true): string; + public getSubcommandGroup(required: boolean): string | null; + public getBoolean(name: string, required: true): boolean; + public getBoolean(name: string, required?: boolean): boolean | null; + public getChannel(name: string, required: true): NonNullable['channel']>; + public getChannel(name: string, required?: boolean): NonNullable['channel']> | null; + public getString(name: string, required: true): string; + public getString(name: string, required?: boolean): string | null; + public getInteger(name: string, required: true): number; + public getInteger(name: string, required?: boolean): number | null; + public getNumber(name: string, required: true): number; + public getNumber(name: string, required?: boolean): number | null; + public getUser(name: string, required: true): NonNullable['user']>; + public getUser(name: string, required?: boolean): NonNullable['user']> | null; + public getMember(name: string, required: true): NonNullable['member']>; + public getMember(name: string, required?: boolean): NonNullable['member']> | null; + public getRole(name: string, required: true): NonNullable['role']>; + public getRole(name: string, required?: boolean): NonNullable['role']> | null; + public getMentionable( + name: string, + required: true, + ): NonNullable['member' | 'role' | 'user']>; + public getMentionable( + name: string, + required?: boolean, + ): NonNullable['member' | 'role' | 'user']> | null; + public getMessage(name: string, required: true): NonNullable['message']>; + public getMessage(name: string, required?: boolean): NonNullable['message']> | null; + public getFocused(getFull: true): ApplicationCommandOptionChoice; + public getFocused(getFull?: boolean): string | number; +} + +export class ContextMenuInteraction extends BaseCommandInteraction { + public options: Omit< + CommandInteractionOptionResolver, + | 'getFocused' + | 'getMentionable' + | 'getRole' + | 'getNumber' + | 'getInteger' + | 'getString' + | 'getChannel' + | 'getBoolean' + | 'getSubcommandGroup' + | 'getSubcommand' + >; + public targetId: Snowflake; + public targetType: Exclude; + public inGuild(): this is ContextMenuInteraction<'present'>; + public inCachedGuild(): this is ContextMenuInteraction<'cached'>; + public inRawGuild(): this is ContextMenuInteraction<'raw'>; + private resolveContextMenuOptions(data: APIApplicationCommandInteractionData): CommandInteractionOption[]; +} + +export class DataResolver extends null { + private constructor(); + public static resolveBase64(data: Base64Resolvable): string; + public static resolveCode(data: string, regx: RegExp): string; + public static resolveFile(resource: BufferResolvable | Stream): Promise; + public static resolveFileAsBuffer(resource: BufferResolvable | Stream): Promise; + public static resolveImage(resource: BufferResolvable | Base64Resolvable): Promise; + public static resolveInviteCode(data: InviteResolvable): string; + public static resolveGuildTemplateCode(data: GuildTemplateResolvable): string; +} + +export class DiscordAPIError extends Error { + private constructor(error: unknown, status: number, request: unknown); + private static flattenErrors(obj: unknown, key: string): string[]; + + public code: number; + public method: string; + public path: string; + public httpStatus: number; + public requestData: HTTPErrorData; +} + +export class DMChannel extends TextBasedChannelMixin(Channel, ['bulkDelete']) { + private constructor(client: Client, data?: RawDMChannelData); + public messages: MessageManager; + public recipient: User; + public type: 'DM'; + public fetch(force?: boolean): Promise; +} + +export class Emoji extends Base { + protected constructor(client: Client, emoji: RawEmojiData); + public animated: boolean | null; + public readonly createdAt: Date | null; + public readonly createdTimestamp: number | null; + /** @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 */ + public deleted: boolean; + public id: Snowflake | null; + public name: string | null; + public readonly identifier: string; + public readonly url: string | null; + public toJSON(): unknown; + public toString(): string; +} + +export class Guild extends AnonymousGuild { + private constructor(client: Client, data: RawGuildData); + private _sortedRoles(): Collection; + private _sortedChannels(channel: NonThreadGuildBasedChannel): Collection; + + public readonly afkChannel: VoiceChannel | null; + public afkChannelId: Snowflake | null; + public afkTimeout: number; + public applicationId: Snowflake | null; + public approximateMemberCount: number | null; + public approximatePresenceCount: number | null; + public available: boolean; + public bans: GuildBanManager; + public channels: GuildChannelManager; + public commands: GuildApplicationCommandManager; + public defaultMessageNotifications: DefaultMessageNotificationLevel | number; + /** @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 */ + public deleted: boolean; + public discoverySplash: string | null; + public emojis: GuildEmojiManager; + public explicitContentFilter: ExplicitContentFilterLevel; + public invites: GuildInviteManager; + public readonly joinedAt: Date; + public joinedTimestamp: number; + public large: boolean; + public maximumMembers: number | null; + public maximumPresences: number | null; + public readonly me: GuildMember | null; + public memberCount: number; + public members: GuildMemberManager; + public mfaLevel: MFALevel; + public ownerId: Snowflake; + public preferredLocale: string; + public premiumSubscriptionCount: number | null; + public premiumProgressBarEnabled: boolean; + public premiumTier: PremiumTier; + public presences: PresenceManager; + public readonly publicUpdatesChannel: TextChannel | null; + public publicUpdatesChannelId: Snowflake | null; + public roles: RoleManager; + public readonly rulesChannel: TextChannel | null; + public rulesChannelId: Snowflake | null; + public scheduledEvents: GuildScheduledEventManager; + public readonly shard: WebSocketShard; + public shardId: number; + public stageInstances: StageInstanceManager; + public stickers: GuildStickerManager; + public readonly systemChannel: TextChannel | null; + public systemChannelFlags: Readonly; + public systemChannelId: Snowflake | null; + public vanityURLUses: number | null; + public readonly voiceAdapterCreator: InternalDiscordGatewayAdapterCreator; + public readonly voiceStates: VoiceStateManager; + public readonly widgetChannel: TextChannel | null; + public widgetChannelId: Snowflake | null; + public widgetEnabled: boolean | null; + public readonly maximumBitrate: number; + public createTemplate(name: string, description?: string): Promise; + public delete(): Promise; + public discoverySplashURL(options?: StaticImageURLOptions): string | null; + public edit(data: GuildEditData, reason?: string): Promise; + public editWelcomeScreen(data: WelcomeScreenEditData): Promise; + public equals(guild: Guild): boolean; + public fetchAuditLogs( + options?: GuildAuditLogsFetchOptions, + ): Promise>; + public fetchIntegrations(): Promise>; + public fetchOwner(options?: BaseFetchOptions): Promise; + public fetchPreview(): Promise; + public fetchTemplates(): Promise>; + public fetchVanityData(): Promise; + public fetchWebhooks(): Promise>; + public fetchWelcomeScreen(): Promise; + public fetchWidget(): Promise; + public fetchWidgetSettings(): Promise; + public leave(): Promise; + public setAFKChannel(afkChannel: VoiceChannelResolvable | null, reason?: string): Promise; + public setAFKTimeout(afkTimeout: number, reason?: string): Promise; + public setBanner(banner: BufferResolvable | Base64Resolvable | null, reason?: string): Promise; + /** @deprecated Use {@link GuildChannelManager.setPositions} instead */ + public setChannelPositions(channelPositions: readonly ChannelPosition[]): Promise; + public setDefaultMessageNotifications( + defaultMessageNotifications: DefaultMessageNotificationLevel | number, + reason?: string, + ): Promise; + public setDiscoverySplash( + discoverySplash: BufferResolvable | Base64Resolvable | null, + reason?: string, + ): Promise; + public setExplicitContentFilter( + explicitContentFilter: ExplicitContentFilterLevel | number, + reason?: string, + ): Promise; + public setIcon(icon: BufferResolvable | Base64Resolvable | null, reason?: string): Promise; + public setName(name: string, reason?: string): Promise; + public setOwner(owner: GuildMemberResolvable, reason?: string): Promise; + public setPreferredLocale(preferredLocale: string, reason?: string): Promise; + public setPublicUpdatesChannel(publicUpdatesChannel: TextChannelResolvable | null, reason?: string): Promise; + /** @deprecated Use {@link RoleManager.setPositions} instead */ + public setRolePositions(rolePositions: readonly RolePosition[]): Promise; + public setRulesChannel(rulesChannel: TextChannelResolvable | null, reason?: string): Promise; + public setSplash(splash: BufferResolvable | Base64Resolvable | null, reason?: string): Promise; + public setSystemChannel(systemChannel: TextChannelResolvable | null, reason?: string): Promise; + public setSystemChannelFlags(systemChannelFlags: SystemChannelFlagsResolvable, reason?: string): Promise; + public setVerificationLevel(verificationLevel: VerificationLevel | number, reason?: string): Promise; + public setPremiumProgressBarEnabled(enabled?: boolean, reason?: string): Promise; + public setWidgetSettings(settings: GuildWidgetSettingsData, reason?: string): Promise; + public toJSON(): unknown; +} + +export class GuildAuditLogs { + private constructor(guild: Guild, data: RawGuildAuditLogData); + private webhooks: Collection; + private integrations: Collection; + + public entries: Collection>; + + public static Actions: GuildAuditLogsActions; + public static Targets: GuildAuditLogsTargets; + public static Entry: typeof GuildAuditLogsEntry; + public static actionType(action: number): GuildAuditLogsActionType; + public static build(...args: unknown[]): Promise; + public static targetType(target: number): GuildAuditLogsTarget; + public toJSON(): unknown; +} + +export class GuildAuditLogsEntry< + TActionRaw extends GuildAuditLogsResolvable = 'ALL', + TAction = TActionRaw extends keyof GuildAuditLogsIds + ? GuildAuditLogsIds[TActionRaw] + : TActionRaw extends null + ? 'ALL' + : TActionRaw, + TActionType extends GuildAuditLogsActionType = TAction extends keyof GuildAuditLogsTypes + ? GuildAuditLogsTypes[TAction][1] + : 'ALL', + TTargetType extends GuildAuditLogsTarget = TAction extends keyof GuildAuditLogsTypes + ? GuildAuditLogsTypes[TAction][0] + : 'UNKNOWN', +> { + private constructor(logs: GuildAuditLogs, guild: Guild, data: RawGuildAuditLogEntryData); + public action: TAction; + public actionType: TActionType; + public changes: AuditLogChange[] | null; + public readonly createdAt: Date; + public readonly createdTimestamp: number; + public executor: User | null; + public extra: TAction extends keyof GuildAuditLogsEntryExtraField ? GuildAuditLogsEntryExtraField[TAction] : null; + public id: Snowflake; + public reason: string | null; + public target: TTargetType extends keyof GuildAuditLogsEntryTargetField + ? GuildAuditLogsEntryTargetField[TTargetType] + : Role | GuildEmoji | { id: Snowflake } | null; + public targetType: TTargetType; + public toJSON(): unknown; +} + +export class GuildBan extends Base { + private constructor(client: Client, data: RawGuildBanData, guild: Guild); + public guild: Guild; + public user: User; + public readonly partial: boolean; + public reason?: string | null; + public fetch(force?: boolean): Promise; +} + +export abstract class GuildChannel extends Channel { + public constructor(guild: Guild, data?: RawGuildChannelData, client?: Client, immediatePatch?: boolean); + private memberPermissions(member: GuildMember, checkAdmin: boolean): Readonly; + private rolePermissions(role: Role, checkAdmin: boolean): Readonly; + + public readonly calculatedPosition: number; + public readonly deletable: boolean; + public guild: Guild; + public guildId: Snowflake; + public readonly manageable: boolean; + public readonly members: Collection; + public name: string; + public readonly parent: CategoryChannel | null; + public parentId: Snowflake | null; + public permissionOverwrites: PermissionOverwriteManager; + public readonly permissionsLocked: boolean | null; + public readonly position: number; + public rawPosition: number; + public type: Exclude; + public readonly viewable: boolean; + public clone(options?: GuildChannelCloneOptions): Promise; + public delete(reason?: string): Promise; + public edit(data: ChannelData, reason?: string): Promise; + public equals(channel: GuildChannel): boolean; + public lockPermissions(): Promise; + public permissionsFor(memberOrRole: GuildMember | Role, checkAdmin?: boolean): Readonly; + public permissionsFor( + memberOrRole: GuildMemberResolvable | RoleResolvable, + checkAdmin?: boolean, + ): Readonly | null; + public setName(name: string, reason?: string): Promise; + public setParent(channel: CategoryChannelResolvable | null, options?: SetParentOptions): Promise; + public setPosition(position: number, options?: SetChannelPositionOptions): Promise; + public isText(): this is TextChannel | NewsChannel; +} + +export class GuildEmoji extends BaseGuildEmoji { + private constructor(client: Client, data: RawGuildEmojiData, guild: Guild); + private _roles: Snowflake[]; + + public readonly deletable: boolean; + public guild: Guild; + public author: User | null; + public readonly roles: GuildEmojiRoleManager; + public readonly url: string; + public delete(reason?: string): Promise; + public edit(data: GuildEmojiEditData, reason?: string): Promise; + public equals(other: GuildEmoji | unknown): boolean; + public fetchAuthor(): Promise; + public setName(name: string, reason?: string): Promise; +} + +export class GuildMember extends PartialTextBasedChannel(Base) { + private constructor(client: Client, data: RawGuildMemberData, guild: Guild); + public avatar: string | null; + public readonly bannable: boolean; + /** @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 */ + public deleted: boolean; + public readonly displayColor: number; + public readonly displayHexColor: HexColorString; + public readonly displayName: string; + public guild: Guild; + public readonly id: Snowflake; + public pending: boolean; + public readonly communicationDisabledUntil: Date | null; + public communicationDisabledUntilTimestamp: number | null; + public readonly joinedAt: Date | null; + public joinedTimestamp: number | null; + public readonly kickable: boolean; + public readonly manageable: boolean; + public readonly moderatable: boolean; + public nickname: string | null; + public readonly partial: false; + public readonly permissions: Readonly; + public readonly premiumSince: Date | null; + public premiumSinceTimestamp: number | null; + public readonly presence: Presence | null; + public readonly roles: GuildMemberRoleManager; + public user: User; + public readonly voice: VoiceState; + public avatarURL(options?: ImageURLOptions): string | null; + public ban(options?: BanOptions): Promise; + public disableCommunicationUntil(timeout: DateResolvable | null, reason?: string): Promise; + public timeout(timeout: number | null, reason?: string): Promise; + public fetch(force?: boolean): Promise; + public createDM(force?: boolean): Promise; + public deleteDM(): Promise; + public displayAvatarURL(options?: ImageURLOptions): string; + public edit(data: GuildMemberEditData, reason?: string): Promise; + public kick(reason?: string): Promise; + public permissionsIn(channel: GuildChannelResolvable): Readonly; + public setNickname(nickname: string | null, reason?: string): Promise; + public toJSON(): unknown; + public toString(): MemberMention; + public valueOf(): string; +} + +export class GuildPreview extends Base { + private constructor(client: Client, data: RawGuildPreviewData); + public approximateMemberCount: number; + public approximatePresenceCount: number; + public readonly createdAt: Date; + public readonly createdTimestamp: number; + public description: string | null; + public discoverySplash: string | null; + public emojis: Collection; + public features: GuildFeatures[]; + public icon: string | null; + public id: Snowflake; + public name: string; + public splash: string | null; + public discoverySplashURL(options?: StaticImageURLOptions): string | null; + public iconURL(options?: ImageURLOptions): string | null; + public splashURL(options?: StaticImageURLOptions): string | null; + public fetch(): Promise; + public toJSON(): unknown; + public toString(): string; +} + +export class GuildScheduledEvent extends Base { + private constructor(client: Client, data: RawGuildScheduledEventData); + public id: Snowflake; + public guildId: Snowflake; + public channelId: Snowflake | null; + public creatorId: Snowflake | null; + public name: string; + public description: string | null; + public scheduledStartTimestamp: number | null; + public scheduledEndTimestamp: number | null; + public privacyLevel: GuildScheduledEventPrivacyLevel; + public status: S; + public entityType: GuildScheduledEventEntityType; + public entityId: Snowflake | null; + public entityMetadata: GuildScheduledEventEntityMetadata; + public userCount: number | null; + public creator: User | null; + public readonly createdTimestamp: number; + public readonly createdAt: Date; + public readonly scheduledStartAt: Date; + public readonly scheduledEndAt: Date | null; + public readonly channel: VoiceChannel | StageChannel | null; + public readonly guild: Guild | null; + public readonly url: string; + public createInviteURL(options?: CreateGuildScheduledEventInviteURLOptions): Promise; + public edit>( + options: GuildScheduledEventEditOptions, + ): Promise>; + public delete(): Promise>; + public setName(name: string, reason?: string): Promise>; + public setScheduledStartTime(scheduledStartTime: DateResolvable, reason?: string): Promise>; + public setScheduledEndTime(scheduledEndTime: DateResolvable, reason?: string): Promise>; + public setDescription(description: string, reason?: string): Promise>; + public setStatus>( + status: T, + reason?: string, + ): Promise>; + public setLocation(location: string, reason?: string): Promise>; + public fetchSubscribers( + options?: T, + ): Promise>; + public toString(): string; + public isActive(): this is GuildScheduledEvent<'ACTIVE'>; + public isCanceled(): this is GuildScheduledEvent<'CANCELED'>; + public isCompleted(): this is GuildScheduledEvent<'COMPLETED'>; + public isScheduled(): this is GuildScheduledEvent<'SCHEDULED'>; +} + +export class GuildTemplate extends Base { + private constructor(client: Client, data: RawGuildTemplateData); + public readonly createdTimestamp: number; + public readonly updatedTimestamp: number; + public readonly url: string; + public code: string; + public name: string; + public description: string | null; + public usageCount: number; + public creator: User; + public creatorId: Snowflake; + public createdAt: Date; + public updatedAt: Date; + public guild: Guild | null; + public guildId: Snowflake; + public serializedGuild: APITemplateSerializedSourceGuild; + public unSynced: boolean | null; + public createGuild(name: string, icon?: BufferResolvable | Base64Resolvable): Promise; + public delete(): Promise; + public edit(options?: EditGuildTemplateOptions): Promise; + public sync(): Promise; + public static GUILD_TEMPLATES_PATTERN: RegExp; +} + +export class GuildPreviewEmoji extends BaseGuildEmoji { + private constructor(client: Client, data: RawGuildEmojiData, guild: GuildPreview); + public guild: GuildPreview; + public roles: Snowflake[]; +} + +export class HTTPError extends Error { + private constructor(message: string, name: string, code: number, request: unknown); + public code: number; + public method: string; + public name: string; + public path: string; + public requestData: HTTPErrorData; +} + +// tslint:disable-next-line:no-empty-interface - Merge RateLimitData into RateLimitError to not have to type it again +export interface RateLimitError extends RateLimitData {} +export class RateLimitError extends Error { + private constructor(data: RateLimitData); + public name: 'RateLimitError'; +} + +export class Integration extends Base { + private constructor(client: Client, data: RawIntegrationData, guild: Guild); + public account: IntegrationAccount; + public application: IntegrationApplication | null; + public enabled: boolean; + public expireBehavior: number | undefined; + public expireGracePeriod: number | undefined; + public guild: Guild; + public id: Snowflake | string; + public name: string; + public role: Role | undefined; + public enableEmoticons: boolean | null; + public readonly roles: Collection; + public syncedAt: number | undefined; + public syncing: boolean | undefined; + public type: IntegrationType; + public user: User | null; + public subscriberCount: number | null; + public revoked: boolean | null; + public delete(reason?: string): Promise; +} + +export class IntegrationApplication extends Application { + private constructor(client: Client, data: RawIntegrationApplicationData); + public bot: User | null; + public termsOfServiceURL: string | null; + public privacyPolicyURL: string | null; + public rpcOrigins: string[]; + public summary: string | null; + public hook: boolean | null; + public cover: string | null; + public verifyKey: string | null; +} + +export class Intents extends BitField { + public static FLAGS: Record; + public static resolve(bit?: BitFieldResolvable): number; +} + +export type CacheType = 'cached' | 'raw' | 'present'; + +export type CacheTypeReducer< + State extends CacheType, + CachedType, + RawType = CachedType, + PresentType = CachedType | RawType, + Fallback = PresentType | null, +> = [State] extends ['cached'] + ? CachedType + : [State] extends ['raw'] + ? RawType + : [State] extends ['present'] + ? PresentType + : Fallback; + +export class Interaction extends Base { + // This a technique used to brand different cached types. Or else we'll get `never` errors on typeguard checks. + private readonly _cacheType: Cached; + protected constructor(client: Client, data: RawInteractionData); + public applicationId: Snowflake; + public readonly channel: CacheTypeReducer< + Cached, + GuildTextBasedChannel | null, + GuildTextBasedChannel | null, + GuildTextBasedChannel | null, + TextBasedChannel | null + >; + public channelId: Snowflake | null; + public readonly createdAt: Date; + public readonly createdTimestamp: number; + public readonly guild: CacheTypeReducer; + public guildId: CacheTypeReducer; + public id: Snowflake; + public member: CacheTypeReducer; + public readonly token: string; + public type: InteractionType; + public user: User; + public version: number; + public memberPermissions: CacheTypeReducer>; + public inGuild(): this is Interaction<'present'>; + public inCachedGuild(): this is Interaction<'cached'>; + public inRawGuild(): this is Interaction<'raw'>; + public isApplicationCommand(): this is BaseCommandInteraction; + public isButton(): this is ButtonInteraction; + public isCommand(): this is CommandInteraction; + public isAutocomplete(): this is AutocompleteInteraction; + public isContextMenu(): this is ContextMenuInteraction; + public isUserContextMenu(): this is UserContextMenuInteraction; + public isMessageContextMenu(): this is MessageContextMenuInteraction; + public isMessageComponent(): this is MessageComponentInteraction; + public isSelectMenu(): this is SelectMenuInteraction; +} + +export class InteractionCollector extends Collector { + public constructor(client: Client, options?: InteractionCollectorOptions); + private _handleMessageDeletion(message: Message): void; + private _handleChannelDeletion(channel: NonThreadGuildBasedChannel): void; + private _handleGuildDeletion(guild: Guild): void; + + public channelId: Snowflake | null; + public componentType: MessageComponentType | null; + public readonly endReason: string | null; + public guildId: Snowflake | null; + public interactionType: InteractionType | null; + public messageId: Snowflake | null; + public options: InteractionCollectorOptions; + public total: number; + public users: Collection; + + public collect(interaction: Interaction): Snowflake; + public empty(): void; + public dispose(interaction: Interaction): Snowflake; + public on(event: 'collect' | 'dispose', listener: (interaction: T) => Awaitable): this; + public on(event: 'end', listener: (collected: Collection, reason: string) => Awaitable): this; + public on(event: string, listener: (...args: any[]) => Awaitable): this; + + public once(event: 'collect' | 'dispose', listener: (interaction: T) => Awaitable): this; + public once(event: 'end', listener: (collected: Collection, reason: string) => Awaitable): this; + public once(event: string, listener: (...args: any[]) => Awaitable): this; +} + +export class InteractionWebhook extends PartialWebhookMixin() { + public constructor(client: Client, id: Snowflake, token: string); + public token: string; + public send(options: string | MessagePayload | InteractionReplyOptions): Promise; +} + +export class Invite extends Base { + private constructor(client: Client, data: RawInviteData); + public channel: NonThreadGuildBasedChannel | PartialGroupDMChannel; + public channelId: Snowflake; + public code: string; + public readonly deletable: boolean; + public readonly createdAt: Date | null; + public createdTimestamp: number | null; + public readonly expiresAt: Date | null; + public readonly expiresTimestamp: number | null; + public guild: InviteGuild | Guild | null; + public inviter: User | null; + public inviterId: Snowflake | null; + public maxAge: number | null; + public maxUses: number | null; + public memberCount: number; + public presenceCount: number; + public targetApplication: IntegrationApplication | null; + public targetUser: User | null; + public targetType: InviteTargetType | null; + public temporary: boolean | null; + public readonly url: string; + public uses: number | null; + public delete(reason?: string): Promise; + public toJSON(): unknown; + public toString(): string; + public static INVITES_PATTERN: RegExp; + public stageInstance: InviteStageInstance | null; + public guildScheduledEvent: GuildScheduledEvent | null; +} + +export class InviteStageInstance extends Base { + private constructor(client: Client, data: RawInviteStageInstance, channelId: Snowflake, guildId: Snowflake); + public channelId: Snowflake; + public guildId: Snowflake; + public members: Collection; + public topic: string; + public participantCount: number; + public speakerCount: number; + public readonly channel: StageChannel | null; + public readonly guild: Guild | null; +} + +export class InviteGuild extends AnonymousGuild { + private constructor(client: Client, data: RawInviteGuildData); + public welcomeScreen: WelcomeScreen | null; +} + +export class LimitedCollection extends Collection { + public constructor(options?: LimitedCollectionOptions, iterable?: Iterable); + public maxSize: number; + public keepOverLimit: ((value: V, key: K, collection: this) => boolean) | null; + /** @deprecated Use Global Sweepers instead */ + public interval: NodeJS.Timeout | null; + /** @deprecated Use Global Sweepers instead */ + public sweepFilter: SweepFilter | null; + + /** @deprecated Use `Sweepers.filterByLifetime` instead */ + public static filterByLifetime(options?: LifetimeFilterOptions): SweepFilter; +} + +export type MessageCollectorOptionsParams = + | { + componentType?: T; + } & MessageComponentCollectorOptions[T]>; + +export type MessageChannelCollectorOptionsParams< + T extends MessageComponentTypeResolvable, + Cached extends boolean = boolean, +> = + | { + componentType?: T; + } & MessageChannelComponentCollectorOptions[T]>; + +export type AwaitMessageCollectorOptionsParams< + T extends MessageComponentTypeResolvable, + Cached extends boolean = boolean, +> = + | { componentType?: T } & Pick< + InteractionCollectorOptions[T]>, + keyof AwaitMessageComponentOptions + >; + +export interface StringMappedInteractionTypes { + BUTTON: ButtonInteraction; + SELECT_MENU: SelectMenuInteraction; + ACTION_ROW: MessageComponentInteraction; +} + +export type WrapBooleanCache = If; + +export type MappedInteractionTypes = EnumValueMapped< + typeof MessageComponentTypes, + { + BUTTON: ButtonInteraction>; + SELECT_MENU: SelectMenuInteraction>; + ACTION_ROW: MessageComponentInteraction>; + } +>; + +export class Message extends Base { + private readonly _cacheType: Cached; + private constructor(client: Client, data: RawMessageData); + private _patch(data: RawPartialMessageData | RawMessageData): void; + + public activity: MessageActivity | null; + public applicationId: Snowflake | null; + public attachments: Collection; + public author: User; + public readonly channel: If; + public channelId: Snowflake; + public readonly cleanContent: string; + public components: MessageActionRow[]; + public content: string; + public readonly createdAt: Date; + public createdTimestamp: number; + public readonly crosspostable: boolean; + public readonly deletable: boolean; + /** @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 */ + public deleted: boolean; + public readonly editable: boolean; + public readonly editedAt: Date | null; + public editedTimestamp: number | null; + public embeds: MessageEmbed[]; + public groupActivityApplication: ClientApplication | null; + public guildId: If; + public readonly guild: If; + public readonly hasThread: boolean; + public id: Snowflake; + public interaction: MessageInteraction | null; + public readonly member: GuildMember | null; + public mentions: MessageMentions; + public nonce: string | number | null; + public readonly partial: false; + public readonly pinnable: boolean; + public pinned: boolean; + public reactions: ReactionManager; + public stickers: Collection; + public system: boolean; + public readonly thread: ThreadChannel | null; + public tts: boolean; + public type: MessageType; + public readonly url: string; + public webhookId: Snowflake | null; + public flags: Readonly; + public reference: MessageReference | null; + public awaitMessageComponent( + options?: AwaitMessageCollectorOptionsParams, + ): Promise[T]>; + public awaitReactions(options?: AwaitReactionsOptions): Promise>; + public createReactionCollector(options?: ReactionCollectorOptions): ReactionCollector; + public createMessageComponentCollector( + options?: MessageCollectorOptionsParams, + ): InteractionCollector[T]>; + public delete(): Promise; + public edit(content: string | MessageEditOptions | MessagePayload): Promise; + public equals(message: Message, rawData: unknown): boolean; + public fetchReference(): Promise; + public fetchWebhook(): Promise; + public crosspost(): Promise; + public fetch(force?: boolean): Promise; + public pin(): Promise; + public react(emoji: EmojiIdentifierResolvable): Promise; + public removeAttachments(): Promise; + public reply(options: string | MessagePayload | ReplyMessageOptions): Promise; + public resolveComponent(customId: string): MessageActionRowComponent | null; + public startThread(options: StartThreadOptions): Promise; + public suppressEmbeds(suppress?: boolean): Promise; + public toJSON(): unknown; + public toString(): string; + public unpin(): Promise; + public inGuild(): this is Message & this; +} + +export class MessageActionRow extends BaseMessageComponent { + public constructor(data?: MessageActionRow | MessageActionRowOptions | APIActionRowComponent); + public type: 'ACTION_ROW'; + public components: MessageActionRowComponent[]; + public addComponents( + ...components: MessageActionRowComponentResolvable[] | MessageActionRowComponentResolvable[][] + ): this; + public setComponents( + ...components: MessageActionRowComponentResolvable[] | MessageActionRowComponentResolvable[][] + ): this; + public spliceComponents( + index: number, + deleteCount: number, + ...components: MessageActionRowComponentResolvable[] | MessageActionRowComponentResolvable[][] + ): this; + public toJSON(): APIActionRowComponent; +} + +export class MessageAttachment { + public constructor(attachment: BufferResolvable | Stream, name?: string, data?: RawMessageAttachmentData); + + public attachment: BufferResolvable | Stream; + public contentType: string | null; + public description: string | null; + public ephemeral: boolean; + public height: number | null; + public id: Snowflake; + public name: string | null; + public proxyURL: string; + public size: number; + public readonly spoiler: boolean; + public url: string; + public width: number | null; + public setDescription(description: string): this; + public setFile(attachment: BufferResolvable | Stream, name?: string): this; + public setName(name: string): this; + public setSpoiler(spoiler?: boolean): this; + public toJSON(): unknown; +} + +export class MessageButton extends BaseMessageComponent { + public constructor(data?: MessageButton | MessageButtonOptions | APIButtonComponent); + public customId: string | null; + public disabled: boolean; + public emoji: APIPartialEmoji | null; + public label: string | null; + public style: MessageButtonStyle | null; + public type: 'BUTTON'; + public url: string | null; + public setCustomId(customId: string): this; + public setDisabled(disabled?: boolean): this; + public setEmoji(emoji: EmojiIdentifierResolvable): this; + public setLabel(label: string): this; + public setStyle(style: MessageButtonStyleResolvable): this; + public setURL(url: string): this; + public toJSON(): APIButtonComponent; + private static resolveStyle(style: MessageButtonStyleResolvable): MessageButtonStyle; +} + +export class MessageCollector extends Collector { + public constructor(channel: TextBasedChannel, options?: MessageCollectorOptions); + private _handleChannelDeletion(channel: NonThreadGuildBasedChannel): void; + private _handleGuildDeletion(guild: Guild): void; + + public channel: TextBasedChannel; + public readonly endReason: string | null; + public options: MessageCollectorOptions; + public received: number; + + public collect(message: Message): Snowflake | null; + public dispose(message: Message): Snowflake | null; +} + +export class MessageComponentInteraction extends Interaction { + protected constructor(client: Client, data: RawMessageComponentInteractionData); + public readonly component: CacheTypeReducer< + Cached, + MessageActionRowComponent, + Exclude, + MessageActionRowComponent | Exclude, + MessageActionRowComponent | Exclude + >; + public componentType: Exclude; + public customId: string; + public channelId: Snowflake; + public deferred: boolean; + public ephemeral: boolean | null; + public message: GuildCacheMessage; + public replied: boolean; + public webhook: InteractionWebhook; + public inGuild(): this is MessageComponentInteraction<'present'>; + public inCachedGuild(): this is MessageComponentInteraction<'cached'>; + public inRawGuild(): this is MessageComponentInteraction<'raw'>; + public deferReply(options: InteractionDeferReplyOptions & { fetchReply: true }): Promise>; + public deferReply(options?: InteractionDeferReplyOptions): Promise; + public deferUpdate(options: InteractionDeferUpdateOptions & { fetchReply: true }): Promise>; + public deferUpdate(options?: InteractionDeferUpdateOptions): Promise; + public deleteReply(): Promise; + public editReply(options: string | MessagePayload | WebhookEditMessageOptions): Promise>; + public fetchReply(): Promise>; + public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise>; + public reply(options: InteractionReplyOptions & { fetchReply: true }): Promise>; + public reply(options: string | MessagePayload | InteractionReplyOptions): Promise; + public update(options: InteractionUpdateOptions & { fetchReply: true }): Promise>; + public update(options: string | MessagePayload | InteractionUpdateOptions): Promise; + + public static resolveType(type: MessageComponentTypeResolvable): MessageComponentType; +} + +export class MessageContextMenuInteraction< + Cached extends CacheType = CacheType, +> extends ContextMenuInteraction { + public readonly targetMessage: NonNullable['message']>; + public inGuild(): this is MessageContextMenuInteraction<'present'>; + public inCachedGuild(): this is MessageContextMenuInteraction<'cached'>; + public inRawGuild(): this is MessageContextMenuInteraction<'raw'>; +} + +export class MessageEmbed { + private _fieldEquals(field: EmbedField, other: EmbedField): boolean; + + public constructor(data?: MessageEmbed | MessageEmbedOptions | APIEmbed); + public author: MessageEmbedAuthor | null; + public color: number | null; + public readonly createdAt: Date | null; + public description: string | null; + public fields: EmbedField[]; + public footer: MessageEmbedFooter | null; + public readonly hexColor: HexColorString | null; + public image: MessageEmbedImage | null; + public readonly length: number; + public provider: MessageEmbedProvider | null; + public thumbnail: MessageEmbedThumbnail | null; + public timestamp: number | null; + public title: string | null; + /** @deprecated */ + public type: string; + public url: string | null; + public readonly video: MessageEmbedVideo | null; + public addField(name: string, value: string, inline?: boolean): this; + public addFields(...fields: EmbedFieldData[] | EmbedFieldData[][]): this; + public setFields(...fields: EmbedFieldData[] | EmbedFieldData[][]): this; + public setAuthor(options: string | EmbedAuthorData | null): this; + /** @deprecated Supply a lone object of interface {@link EmbedAuthorData} instead of more parameters. */ + public setAuthor(name: string, iconURL?: string, url?: string): this; + public setColor(color: ColorResolvable): this; + public setDescription(description: string): this; + public setFooter(text: string, iconURL?: string): this; + public setImage(url: string): this; + public setThumbnail(url: string): this; + public setTimestamp(timestamp?: Date | number | null): this; + public setTitle(title: string): this; + public setURL(url: string): this; + public spliceFields(index: number, deleteCount: number, ...fields: EmbedFieldData[] | EmbedFieldData[][]): this; + public equals(embed: MessageEmbed | APIEmbed): boolean; + public toJSON(): APIEmbed; + + public static normalizeField(name: string, value: string, inline?: boolean): Required; + public static normalizeFields(...fields: EmbedFieldData[] | EmbedFieldData[][]): Required[]; +} + +export class MessageFlags extends BitField { + public static FLAGS: Record; + public static resolve(bit?: BitFieldResolvable): number; +} + +export class MessageMentions { + private constructor( + message: Message, + users: APIUser[] | Collection, + roles: Snowflake[] | Collection, + everyone: boolean, + repliedUser?: APIUser | User, + ); + private _channels: Collection | null; + private readonly _content: string; + private _members: Collection | null; + + public readonly channels: Collection; + public readonly client: Client; + public everyone: boolean; + public readonly guild: Guild; + public has(data: UserResolvable | RoleResolvable | ChannelResolvable, options?: MessageMentionsHasOptions): boolean; + public readonly members: Collection | null; + public repliedUser: User | null; + public roles: Collection; + public users: Collection; + public crosspostedChannels: Collection; + public toJSON(): unknown; + + public static CHANNELS_PATTERN: RegExp; + public static EVERYONE_PATTERN: RegExp; + public static ROLES_PATTERN: RegExp; + public static USERS_PATTERN: RegExp; +} + +export class MessagePayload { + public constructor(target: MessageTarget, options: MessageOptions | WebhookMessageOptions); + public data: RawMessagePayloadData | null; + public readonly isUser: boolean; + public readonly isWebhook: boolean; + public readonly isMessage: boolean; + public readonly isMessageManager: boolean; + public readonly isInteraction: boolean; + public files: HTTPAttachmentData[] | null; + public options: MessageOptions | WebhookMessageOptions; + public target: MessageTarget; + + public static create( + target: MessageTarget, + options: string | MessageOptions | WebhookMessageOptions, + extra?: MessageOptions | WebhookMessageOptions, + ): MessagePayload; + public static resolveFile( + fileLike: BufferResolvable | Stream | FileOptions | MessageAttachment, + ): Promise; + + public makeContent(): string | undefined; + public resolveData(): this; + public resolveFiles(): Promise; +} + +export class MessageReaction { + private constructor(client: Client, data: RawMessageReactionData, message: Message); + private _emoji: GuildEmoji | ReactionEmoji; + + public readonly client: Client; + public count: number; + public readonly emoji: GuildEmoji | ReactionEmoji; + public me: boolean; + public message: Message | PartialMessage; + public readonly partial: false; + public users: ReactionUserManager; + public remove(): Promise; + public fetch(): Promise; + public toJSON(): unknown; +} + +export class MessageSelectMenu extends BaseMessageComponent { + public constructor(data?: MessageSelectMenu | MessageSelectMenuOptions | APISelectMenuComponent); + public customId: string | null; + public disabled: boolean; + public maxValues: number | null; + public minValues: number | null; + public options: MessageSelectOption[]; + public placeholder: string | null; + public type: 'SELECT_MENU'; + public addOptions(...options: MessageSelectOptionData[] | MessageSelectOptionData[][]): this; + public setOptions(...options: MessageSelectOptionData[] | MessageSelectOptionData[][]): this; + public setCustomId(customId: string): this; + public setDisabled(disabled?: boolean): this; + public setMaxValues(maxValues: number): this; + public setMinValues(minValues: number): this; + public setPlaceholder(placeholder: string): this; + public spliceOptions( + index: number, + deleteCount: number, + ...options: MessageSelectOptionData[] | MessageSelectOptionData[][] + ): this; + public toJSON(): APISelectMenuComponent; +} + +export class NewsChannel extends BaseGuildTextChannel { + public threads: ThreadManager; + public type: 'GUILD_NEWS'; + public addFollower(channel: TextChannelResolvable, reason?: string): Promise; +} + +export class OAuth2Guild extends BaseGuild { + private constructor(client: Client, data: RawOAuth2GuildData); + public owner: boolean; + public permissions: Readonly; +} + +export class PartialGroupDMChannel extends Channel { + private constructor(client: Client, data: RawPartialGroupDMChannelData); + public name: string | null; + public icon: string | null; + public recipients: PartialRecipient[]; + public iconURL(options?: StaticImageURLOptions): string | null; +} + +export class PermissionOverwrites extends Base { + private constructor(client: Client, data: RawPermissionOverwriteData, channel: NonThreadGuildBasedChannel); + public allow: Readonly; + public readonly channel: NonThreadGuildBasedChannel; + public deny: Readonly; + public id: Snowflake; + public type: OverwriteType; + public edit(options: PermissionOverwriteOptions, reason?: string): Promise; + public delete(reason?: string): Promise; + public toJSON(): unknown; + public static resolveOverwriteOptions( + options: PermissionOverwriteOptions, + initialPermissions: { allow?: PermissionResolvable; deny?: PermissionResolvable }, + ): ResolvedOverwriteOptions; + public static resolve(overwrite: OverwriteResolvable, guild: Guild): APIOverwrite; +} + +export class Permissions extends BitField { + public any(permission: PermissionResolvable, checkAdmin?: boolean): boolean; + public has(permission: PermissionResolvable, checkAdmin?: boolean): boolean; + public missing(bits: BitFieldResolvable, checkAdmin?: boolean): PermissionString[]; + public serialize(checkAdmin?: boolean): Record; + public toArray(checkAdmin?: boolean): PermissionString[]; + + public static ALL: bigint; + public static DEFAULT: bigint; + public static STAGE_MODERATOR: bigint; + public static FLAGS: PermissionFlags; + public static resolve(permission?: PermissionResolvable): bigint; +} + +export class Presence extends Base { + protected constructor(client: Client, data?: RawPresenceData); + public activities: Activity[]; + public clientStatus: ClientPresenceStatusData | null; + public guild: Guild | null; + public readonly member: GuildMember | null; + public status: PresenceStatus; + public readonly user: User | null; + public userId: Snowflake; + public equals(presence: Presence): boolean; +} + +export class ReactionCollector extends Collector { + public constructor(message: Message, options?: ReactionCollectorOptions); + private _handleChannelDeletion(channel: NonThreadGuildBasedChannel): void; + private _handleGuildDeletion(guild: Guild): void; + private _handleMessageDeletion(message: Message): void; + + public readonly endReason: string | null; + public message: Message; + public options: ReactionCollectorOptions; + public total: number; + public users: Collection; + + public static key(reaction: MessageReaction): Snowflake | string; + + public collect(reaction: MessageReaction, user: User): Snowflake | string | null; + public dispose(reaction: MessageReaction, user: User): Snowflake | string | null; + public empty(): void; + + public on(event: 'collect' | 'dispose' | 'remove', listener: (reaction: MessageReaction, user: User) => void): this; + public on(event: 'end', listener: (collected: Collection, reason: string) => void): this; + public on(event: string, listener: (...args: any[]) => void): this; + + public once(event: 'collect' | 'dispose' | 'remove', listener: (reaction: MessageReaction, user: User) => void): this; + public once( + event: 'end', + listener: (collected: Collection, reason: string) => void, + ): this; + public once(event: string, listener: (...args: any[]) => void): this; +} + +export class ReactionEmoji extends Emoji { + private constructor(reaction: MessageReaction, emoji: RawReactionEmojiData); + public reaction: MessageReaction; + public toJSON(): unknown; +} + +export class RichPresenceAssets { + private constructor(activity: Activity, assets: RawRichPresenceAssets); + public largeImage: Snowflake | null; + public largeText: string | null; + public smallImage: Snowflake | null; + public smallText: string | null; + public largeImageURL(options?: StaticImageURLOptions): string | null; + public smallImageURL(options?: StaticImageURLOptions): string | null; +} + +export class Role extends Base { + private constructor(client: Client, data: RawRoleData, guild: Guild); + public color: number; + public readonly createdAt: Date; + public readonly createdTimestamp: number; + /** @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 */ + public deleted: boolean; + public readonly editable: boolean; + public guild: Guild; + public readonly hexColor: HexColorString; + public hoist: boolean; + public id: Snowflake; + public managed: boolean; + public readonly members: Collection; + public mentionable: boolean; + public name: string; + public permissions: Readonly; + public readonly position: number; + public rawPosition: number; + public tags: RoleTagData | null; + public comparePositionTo(role: RoleResolvable): number; + public icon: string | null; + public unicodeEmoji: string | null; + public delete(reason?: string): Promise; + public edit(data: RoleData, reason?: string): Promise; + public equals(role: Role): boolean; + public iconURL(options?: StaticImageURLOptions): string | null; + public permissionsIn(channel: NonThreadGuildBasedChannel | Snowflake, checkAdmin?: boolean): Readonly; + public setColor(color: ColorResolvable, reason?: string): Promise; + public setHoist(hoist?: boolean, reason?: string): Promise; + public setMentionable(mentionable?: boolean, reason?: string): Promise; + public setName(name: string, reason?: string): Promise; + public setPermissions(permissions: PermissionResolvable, reason?: string): Promise; + public setIcon(icon: BufferResolvable | Base64Resolvable | EmojiResolvable | null, reason?: string): Promise; + public setPosition(position: number, options?: SetRolePositionOptions): Promise; + public setUnicodeEmoji(unicodeEmoji: string | null, reason?: string): Promise; + public toJSON(): unknown; + public toString(): RoleMention; + + /** @deprecated Use {@link RoleManager.comparePositions} instead. */ + public static comparePositions(role1: Role, role2: Role): number; +} + +export class SelectMenuInteraction extends MessageComponentInteraction { + public constructor(client: Client, data: RawMessageSelectMenuInteractionData); + public readonly component: CacheTypeReducer< + Cached, + MessageSelectMenu, + APISelectMenuComponent, + MessageSelectMenu | APISelectMenuComponent, + MessageSelectMenu | APISelectMenuComponent + >; + public componentType: 'SELECT_MENU'; + public values: string[]; + public inGuild(): this is SelectMenuInteraction<'present'>; + public inCachedGuild(): this is SelectMenuInteraction<'cached'>; + public inRawGuild(): this is SelectMenuInteraction<'raw'>; +} + +export interface ShardEventTypes { + spawn: [child: ChildProcess]; + death: [child: ChildProcess]; + disconnect: []; + ready: []; + reconnection: []; + error: [error: Error]; + message: [message: any]; +} + +export class Shard extends EventEmitter { + private constructor(manager: ShardingManager, id: number); + private _evals: Map>; + private _exitListener: (...args: any[]) => void; + private _fetches: Map>; + private _handleExit(respawn?: boolean, timeout?: number): void; + private _handleMessage(message: unknown): void; + + public args: string[]; + public execArgv: string[]; + public env: unknown; + public id: number; + public manager: ShardingManager; + public process: ChildProcess | null; + public ready: boolean; + public worker: Worker | null; + public eval(script: string): Promise; + public eval(fn: (client: Client) => T): Promise; + public eval(fn: (client: Client, context: Serialized

) => T, context: P): Promise; + public fetchClientValue(prop: string): Promise; + public kill(): void; + public respawn(options?: { delay?: number; timeout?: number }): Promise; + public send(message: unknown): Promise; + public spawn(timeout?: number): Promise; + + public on( + event: K, + listener: (...args: ShardEventTypes[K]) => Awaitable, + ): this; + + public once( + event: K, + listener: (...args: ShardEventTypes[K]) => Awaitable, + ): this; +} + +export class ShardClientUtil { + private constructor(client: Client, mode: ShardingManagerMode); + private _handleMessage(message: unknown): void; + private _respond(type: string, message: unknown): void; + + public client: Client; + public readonly count: number; + public readonly ids: number[]; + public mode: ShardingManagerMode; + public parentPort: MessagePort | null; + public broadcastEval(fn: (client: Client) => Awaitable): Promise[]>; + public broadcastEval(fn: (client: Client) => Awaitable, options: { shard: number }): Promise>; + public broadcastEval( + fn: (client: Client, context: Serialized

) => Awaitable, + options: { context: P }, + ): Promise[]>; + public broadcastEval( + fn: (client: Client, context: Serialized

) => Awaitable, + options: { context: P; shard: number }, + ): Promise>; + public fetchClientValues(prop: string): Promise; + public fetchClientValues(prop: string, shard: number): Promise; + public respawnAll(options?: MultipleShardRespawnOptions): Promise; + public send(message: unknown): Promise; + + public static singleton(client: Client, mode: ShardingManagerMode): ShardClientUtil; + public static shardIdForGuildId(guildId: Snowflake, shardCount: number): number; +} + +export class ShardingManager extends EventEmitter { + public constructor(file: string, options?: ShardingManagerOptions); + private _performOnShards(method: string, args: unknown[]): Promise; + private _performOnShards(method: string, args: unknown[], shard: number): Promise; + + public file: string; + public respawn: boolean; + public shardArgs: string[]; + public shards: Collection; + public token: string | null; + public totalShards: number | 'auto'; + public shardList: number[] | 'auto'; + public broadcast(message: unknown): Promise; + public broadcastEval(fn: (client: Client) => Awaitable): Promise[]>; + public broadcastEval(fn: (client: Client) => Awaitable, options: { shard: number }): Promise>; + public broadcastEval( + fn: (client: Client, context: Serialized

) => Awaitable, + options: { context: P }, + ): Promise[]>; + public broadcastEval( + fn: (client: Client, context: Serialized

) => Awaitable, + options: { context: P; shard: number }, + ): Promise>; + public createShard(id: number): Shard; + public fetchClientValues(prop: string): Promise; + public fetchClientValues(prop: string, shard: number): Promise; + public respawnAll(options?: MultipleShardRespawnOptions): Promise>; + public spawn(options?: MultipleShardSpawnOptions): Promise>; + + public on(event: 'shardCreate', listener: (shard: Shard) => Awaitable): this; + + public once(event: 'shardCreate', listener: (shard: Shard) => Awaitable): this; +} + +export interface FetchRecommendedShardsOptions { + guildsPerShard?: number; + multipleOf?: number; +} + +export class SnowflakeUtil extends null { + private constructor(); + public static deconstruct(snowflake: Snowflake): DeconstructedSnowflake; + public static generate(timestamp?: number | Date): Snowflake; + public static timestampFrom(snowflake: Snowflake): number; + public static readonly EPOCH: number; +} + +export class StageChannel extends BaseGuildVoiceChannel { + public topic: string | null; + public type: 'GUILD_STAGE_VOICE'; + public readonly stageInstance: StageInstance | null; + public createStageInstance(options: StageInstanceCreateOptions): Promise; + public setTopic(topic: string): Promise; +} + +export class StageInstance extends Base { + private constructor(client: Client, data: RawStageInstanceData, channel: StageChannel); + public id: Snowflake; + /** @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 */ + public deleted: boolean; + public guildId: Snowflake; + public channelId: Snowflake; + public topic: string; + public privacyLevel: PrivacyLevel; + public discoverableDisabled: boolean | null; + public readonly channel: StageChannel | null; + public readonly guild: Guild | null; + public edit(options: StageInstanceEditOptions): Promise; + public delete(): Promise; + public setTopic(topic: string): Promise; + public readonly createdTimestamp: number; + public readonly createdAt: Date; +} + +export class Sticker extends Base { + private constructor(client: Client, data: RawStickerData); + /** @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 */ + public deleted: boolean; + public readonly createdTimestamp: number; + public readonly createdAt: Date; + public available: boolean | null; + public description: string | null; + public format: StickerFormatType; + public readonly guild: Guild | null; + public guildId: Snowflake | null; + public id: Snowflake; + public name: string; + public packId: Snowflake | null; + public readonly partial: boolean; + public sortValue: number | null; + public tags: string[] | null; + public type: StickerType | null; + public user: User | null; + public readonly url: string; + public fetch(): Promise; + public fetchPack(): Promise; + public fetchUser(): Promise; + public edit(data?: GuildStickerEditData, reason?: string): Promise; + public delete(reason?: string): Promise; + public equals(other: Sticker | unknown): boolean; +} + +export class StickerPack extends Base { + private constructor(client: Client, data: RawStickerPackData); + public readonly createdTimestamp: number; + public readonly createdAt: Date; + public bannerId: Snowflake | null; + public readonly coverSticker: Sticker | null; + public coverStickerId: Snowflake | null; + public description: string; + public id: Snowflake; + public name: string; + public skuId: Snowflake; + public stickers: Collection; + public bannerURL(options?: StaticImageURLOptions): string | null; +} + +/** @deprecated See [Self-serve Game Selling Deprecation](https://support-dev.discord.com/hc/en-us/articles/4414590563479) for more information */ +export class StoreChannel extends GuildChannel { + private constructor(guild: Guild, data?: RawGuildChannelData, client?: Client); + public createInvite(options?: CreateInviteOptions): Promise; + public fetchInvites(cache?: boolean): Promise>; + /** @deprecated See [Self-serve Game Selling Deprecation](https://support-dev.discord.com/hc/en-us/articles/4414590563479) for more information */ + public clone(options?: GuildChannelCloneOptions): Promise; + public nsfw: boolean; + public type: 'GUILD_STORE'; +} + +export class Sweepers { + public constructor(client: Client, options: SweeperOptions); + public readonly client: Client; + public intervals: Record; + public options: SweeperOptions; + + public sweepApplicationCommands( + filter: CollectionSweepFilter< + SweeperDefinitions['applicationCommands'][0], + SweeperDefinitions['applicationCommands'][1] + >, + ): number; + public sweepBans(filter: CollectionSweepFilter): number; + public sweepEmojis( + filter: CollectionSweepFilter, + ): number; + public sweepInvites( + filter: CollectionSweepFilter, + ): number; + public sweepGuildMembers( + filter: CollectionSweepFilter, + ): number; + public sweepMessages( + filter: CollectionSweepFilter, + ): number; + public sweepPresences( + filter: CollectionSweepFilter, + ): number; + public sweepReactions( + filter: CollectionSweepFilter, + ): number; + public sweepStageInstnaces( + filter: CollectionSweepFilter, + ): number; + public sweepStickers( + filter: CollectionSweepFilter, + ): number; + public sweepThreadMembers( + filter: CollectionSweepFilter, + ): number; + public sweepThreads( + filter: CollectionSweepFilter, + ): number; + public sweepUsers( + filter: CollectionSweepFilter, + ): number; + public sweepVoiceStates( + filter: CollectionSweepFilter, + ): number; + + public static archivedThreadSweepFilter( + lifetime?: number, + ): GlobalSweepFilter; + public static expiredInviteSweepFilter( + lifetime?: number, + ): GlobalSweepFilter; + public static filterByLifetime(options?: LifetimeFilterOptions): GlobalSweepFilter; + public static outdatedMessageSweepFilter( + lifetime?: number, + ): GlobalSweepFilter; +} + +export class SystemChannelFlags extends BitField { + public static FLAGS: Record; + public static resolve(bit?: BitFieldResolvable): number; +} + +export class Team extends Base { + private constructor(client: Client, data: RawTeamData); + public id: Snowflake; + public name: string; + public icon: string | null; + public ownerId: Snowflake | null; + public members: Collection; + + public readonly owner: TeamMember | null; + public readonly createdAt: Date; + public readonly createdTimestamp: number; + + public iconURL(options?: StaticImageURLOptions): string | null; + public toJSON(): unknown; + public toString(): string; +} + +export class TeamMember extends Base { + private constructor(team: Team, data: RawTeamMemberData); + public team: Team; + public readonly id: Snowflake; + public permissions: string[]; + public membershipState: MembershipState; + public user: User; + + public toString(): UserMention; +} + +export class TextChannel extends BaseGuildTextChannel { + public rateLimitPerUser: number; + public threads: ThreadManager; + public type: 'GUILD_TEXT'; + public setRateLimitPerUser(rateLimitPerUser: number, reason?: string): Promise; +} + +export class ThreadChannel extends TextBasedChannelMixin(Channel) { + private constructor(guild: Guild, data?: RawThreadChannelData, client?: Client, fromInteraction?: boolean); + public archived: boolean | null; + public readonly archivedAt: Date | null; + public archiveTimestamp: number | null; + public autoArchiveDuration: ThreadAutoArchiveDuration | null; + public readonly editable: boolean; + public guild: Guild; + public guildId: Snowflake; + public readonly guildMembers: Collection; + public invitable: boolean | null; + public readonly joinable: boolean; + public readonly joined: boolean; + public locked: boolean | null; + public readonly manageable: boolean; + public readonly viewable: boolean; + public readonly sendable: boolean; + public memberCount: number | null; + public messageCount: number | null; + public messages: MessageManager; + public members: ThreadMemberManager; + public name: string; + public ownerId: Snowflake | null; + public readonly parent: TextChannel | NewsChannel | null; + public parentId: Snowflake | null; + public rateLimitPerUser: number | null; + public type: ThreadChannelTypes; + public readonly unarchivable: boolean; + public delete(reason?: string): Promise; + public edit(data: ThreadEditData, reason?: string): Promise; + public join(): Promise; + public leave(): Promise; + public permissionsFor(memberOrRole: GuildMember | Role, checkAdmin?: boolean): Readonly; + public permissionsFor( + memberOrRole: GuildMemberResolvable | RoleResolvable, + checkAdmin?: boolean, + ): Readonly | null; + public fetchOwner(options?: BaseFetchOptions): Promise; + public fetchStarterMessage(options?: BaseFetchOptions): Promise; + public setArchived(archived?: boolean, reason?: string): Promise; + public setAutoArchiveDuration( + autoArchiveDuration: ThreadAutoArchiveDuration, + reason?: string, + ): Promise; + public setInvitable(invitable?: boolean, reason?: string): Promise; + public setLocked(locked?: boolean, reason?: string): Promise; + public setName(name: string, reason?: string): Promise; + public setRateLimitPerUser(rateLimitPerUser: number, reason?: string): Promise; +} + +export class ThreadMember extends Base { + private constructor(thread: ThreadChannel, data?: RawThreadMemberData); + public flags: ThreadMemberFlags; + public readonly guildMember: GuildMember | null; + public id: Snowflake; + public readonly joinedAt: Date | null; + public joinedTimestamp: number | null; + public readonly manageable: boolean; + public thread: ThreadChannel; + public readonly user: User | null; + public remove(reason?: string): Promise; +} + +export class ThreadMemberFlags extends BitField { + public static FLAGS: Record; + public static resolve(bit?: BitFieldResolvable): number; +} + +export class Typing extends Base { + private constructor(channel: TextBasedChannel, user: PartialUser, data?: RawTypingData); + public channel: TextBasedChannel; + public user: PartialUser; + public startedTimestamp: number; + public readonly startedAt: Date; + public readonly guild: Guild | null; + public readonly member: GuildMember | null; + public inGuild(): this is this & { + channel: TextChannel | NewsChannel | ThreadChannel; + readonly guild: Guild; + }; +} + +export class User extends PartialTextBasedChannel(Base) { + protected constructor(client: Client, data: RawUserData); + private _equals(user: APIUser): boolean; + + public accentColor: number | null | undefined; + public avatar: string | null; + public banner: string | null | undefined; + public bot: boolean; + public readonly createdAt: Date; + public readonly createdTimestamp: number; + public discriminator: string; + public readonly defaultAvatarURL: string; + public readonly dmChannel: DMChannel | null; + public flags: Readonly | null; + public readonly hexAccentColor: HexColorString | null | undefined; + public id: Snowflake; + public readonly partial: false; + public system: boolean; + public readonly tag: string; + public username: string; + public readonly friended: Boolean; + public readonly blocked: Boolean; + public readonly connectedAccounts: Readonly; + public readonly premiumSince: number | null; + public readonly premiumGuildSince: number | null; + public readonly mutualGuilds: Collection; + public avatarURL(options?: ImageURLOptions): string | null; + public bannerURL(options?: ImageURLOptions): string | null; + public createDM(force?: boolean): Promise; + public deleteDM(): Promise; + public displayAvatarURL(options?: ImageURLOptions): string; + public equals(user: User): boolean; + public fetch(force?: boolean): Promise; + public fetchFlags(force?: boolean): Promise; + public friend(): Promise; + public block(): Promise; + public unfriend(): Promise; + public unblock(): Promise; + public getProfile(): Promise; + public toString(): UserMention; +} + +export class UserContextMenuInteraction extends ContextMenuInteraction { + public readonly targetUser: User; + public readonly targetMember: CacheTypeReducer; + public inGuild(): this is UserContextMenuInteraction<'present'>; + public inCachedGuild(): this is UserContextMenuInteraction<'cached'>; + public inRawGuild(): this is UserContextMenuInteraction<'raw'>; +} + +export class UserFlags extends BitField { + public static FLAGS: Record; + public static resolve(bit?: BitFieldResolvable): number; +} + +export class Util extends null { + private constructor(); + /** @deprecated When not using with `makeCache` use `Sweepers.archivedThreadSweepFilter` instead */ + public static archivedThreadSweepFilter(lifetime?: number): SweepFilter; + public static basename(path: string, ext?: string): string; + public static cleanContent(str: string, channel: TextBasedChannel): string; + /** @deprecated Use {@link MessageOptions.allowedMentions} to control mentions in a message instead. */ + public static removeMentions(str: string): string; + public static cloneObject(obj: unknown): unknown; + public static discordSort( + collection: Collection, + ): Collection; + public static escapeMarkdown(text: string, options?: EscapeMarkdownOptions): string; + public static escapeCodeBlock(text: string): string; + public static escapeInlineCode(text: string): string; + public static escapeBold(text: string): string; + public static escapeItalic(text: string): string; + public static escapeUnderline(text: string): string; + public static escapeStrikethrough(text: string): string; + public static escapeSpoiler(text: string): string; + public static cleanCodeBlockContent(text: string): string; + public static fetchRecommendedShards(token: string, options?: FetchRecommendedShardsOptions): Promise; + public static flatten(obj: unknown, ...props: Record[]): unknown; + public static makeError(obj: MakeErrorOptions): Error; + public static makePlainError(err: Error): MakeErrorOptions; + public static mergeDefault(def: unknown, given: unknown): unknown; + public static moveElementInArray(array: unknown[], element: unknown, newIndex: number, offset?: boolean): number; + public static parseEmoji(text: string): { animated: boolean; name: string; id: Snowflake | null } | null; + public static resolveColor(color: ColorResolvable): number; + public static resolvePartialEmoji(emoji: EmojiIdentifierResolvable): Partial | null; + public static verifyString(data: string, error?: typeof Error, errorMessage?: string, allowEmpty?: boolean): string; + public static setPosition( + item: T, + position: number, + relative: boolean, + sorted: Collection, + route: unknown, + reason?: string, + ): Promise<{ id: Snowflake; position: number }[]>; + public static splitMessage(text: string, options?: SplitOptions): string[]; +} + +export class Formatters extends null { + public static blockQuote: typeof blockQuote; + public static bold: typeof bold; + public static channelMention: typeof channelMention; + public static codeBlock: typeof codeBlock; + public static formatEmoji: typeof formatEmoji; + public static hideLinkEmbed: typeof hideLinkEmbed; + public static hyperlink: typeof hyperlink; + public static inlineCode: typeof inlineCode; + public static italic: typeof italic; + public static memberNicknameMention: typeof memberNicknameMention; + public static quote: typeof quote; + public static roleMention: typeof roleMention; + public static spoiler: typeof spoiler; + public static strikethrough: typeof strikethrough; + public static time: typeof time; + public static TimestampStyles: typeof TimestampStyles; + public static TimestampStylesString: TimestampStylesString; + public static underscore: typeof underscore; + public static userMention: typeof userMention; +} + +export class VoiceChannel extends BaseGuildVoiceChannel { + /** @deprecated Use manageable instead */ + public readonly editable: boolean; + public readonly speakable: boolean; + public type: 'GUILD_VOICE'; + public setBitrate(bitrate: number, reason?: string): Promise; + public setUserLimit(userLimit: number, reason?: string): Promise; +} + +export class VoiceRegion { + private constructor(data: RawVoiceRegionData); + public custom: boolean; + public deprecated: boolean; + public id: string; + public name: string; + public optimal: boolean; + public vip: boolean; + public toJSON(): unknown; +} + +export class VoiceState extends Base { + private constructor(guild: Guild, data: RawVoiceStateData); + public readonly channel: VoiceBasedChannel | null; + public channelId: Snowflake | null; + public readonly deaf: boolean | null; + public guild: Guild; + public id: Snowflake; + public readonly member: GuildMember | null; + public readonly mute: boolean | null; + public selfDeaf: boolean | null; + public selfMute: boolean | null; + public serverDeaf: boolean | null; + public serverMute: boolean | null; + public sessionId: string | null; + public streaming: boolean; + public selfVideo: boolean | null; + public suppress: boolean; + public requestToSpeakTimestamp: number | null; + + public setDeaf(deaf?: boolean, reason?: string): Promise; + public setMute(mute?: boolean, reason?: string): Promise; + public disconnect(reason?: string): Promise; + public setChannel(channel: GuildVoiceChannelResolvable | null, reason?: string): Promise; + public setRequestToSpeak(request?: boolean): Promise; + public setSuppressed(suppressed?: boolean): Promise; +} + +export class Webhook extends WebhookMixin() { + private constructor(client: Client, data?: RawWebhookData); + public avatar: string; + public avatarURL(options?: StaticImageURLOptions): string | null; + public channelId: Snowflake; + public client: Client; + public guildId: Snowflake; + public name: string; + public owner: User | APIUser | null; + public sourceGuild: Guild | APIPartialGuild | null; + public sourceChannel: NewsChannel | APIPartialChannel | null; + public token: string | null; + public type: WebhookType; + public isIncoming(): this is this & { token: string }; + public isChannelFollower(): this is this & { + sourceGuild: Guild | APIPartialGuild; + sourceChannel: NewsChannel | APIPartialChannel; + }; +} + +export class WebhookClient extends WebhookMixin(BaseClient) { + public constructor(data: WebhookClientData, options?: WebhookClientOptions); + public client: this; + public options: WebhookClientOptions; + public token: string; + public editMessage( + message: MessageResolvable, + options: string | MessagePayload | WebhookEditMessageOptions, + ): Promise; + public fetchMessage(message: Snowflake, options?: WebhookFetchMessageOptions): Promise; + /* tslint:disable:unified-signatures */ + /** @deprecated */ + public fetchMessage(message: Snowflake, cache?: boolean): Promise; + /* tslint:enable:unified-signatures */ + public send(options: string | MessagePayload | WebhookMessageOptions): Promise; +} + +export class WebSocketManager extends EventEmitter { + private constructor(client: Client); + private totalShards: number | string; + private shardQueue: Set; + private packetQueue: unknown[]; + private destroyed: boolean; + private reconnecting: boolean; + + public readonly client: Client; + public gateway: string | null; + public shards: Collection; + public status: Status; + public readonly ping: number; + + public on(event: WSEventType, listener: (data: any, shardId: number) => void): this; + public once(event: WSEventType, listener: (data: any, shardId: number) => void): this; + + private debug(message: string, shard?: WebSocketShard): void; + private connect(): Promise; + private createShards(): Promise; + private reconnect(): Promise; + private broadcast(packet: unknown): void; + private destroy(): void; + private handlePacket(packet?: unknown, shard?: WebSocketShard): boolean; + private checkShardsReady(): void; + private triggerClientReady(): void; +} + +export interface WebSocketShardEvents { + ready: []; + resumed: []; + invalidSession: []; + close: [event: CloseEvent]; + allReady: [unavailableGuilds?: Set]; +} + +export class WebSocketShard extends EventEmitter { + private constructor(manager: WebSocketManager, id: number); + private sequence: number; + private closeSequence: number; + private sessionId: string | null; + private lastPingTimestamp: number; + private lastHeartbeatAcked: boolean; + private ratelimit: { queue: unknown[]; total: number; remaining: number; time: 60e3; timer: NodeJS.Timeout | null }; + private connection: WebSocket | null; + private helloTimeout: NodeJS.Timeout | null; + private eventsAttached: boolean; + private expectedGuilds: Set | null; + private readyTimeout: NodeJS.Timeout | null; + + public manager: WebSocketManager; + public id: number; + public status: Status; + public ping: number; + + private debug(message: string): void; + private connect(): Promise; + private onOpen(): void; + private onMessage(event: MessageEvent): void; + private onError(error: ErrorEvent | unknown): void; + private onClose(event: CloseEvent): void; + private onPacket(packet: unknown): void; + private checkReady(): void; + private setHelloTimeout(time?: number): void; + private setHeartbeatTimer(time: number): void; + private sendHeartbeat(): void; + private ackHeartbeat(): void; + private identify(): void; + private identifyNew(): void; + private identifyResume(): void; + private _send(data: unknown): void; + private processQueue(): void; + private destroy(destroyOptions?: { closeCode?: number; reset?: boolean; emit?: boolean; log?: boolean }): void; + private _cleanupConnection(): void; + private _emitDestroyed(): void; + + public send(data: unknown, important?: boolean): void; + + public on( + event: K, + listener: (...args: WebSocketShardEvents[K]) => Awaitable, + ): this; + + public once( + event: K, + listener: (...args: WebSocketShardEvents[K]) => Awaitable, + ): this; +} + +export class Widget extends Base { + private constructor(client: Client, data: RawWidgetData); + private _patch(data: RawWidgetData): void; + public fetch(): Promise; + public id: Snowflake; + public instantInvite?: string; + public channels: Collection; + public members: Collection; + public presenceCount: number; +} + +export class WidgetMember extends Base { + private constructor(client: Client, data: RawWidgetMemberData); + public id: string; + public username: string; + public discriminator: string; + public avatar: string | null; + public status: PresenceStatus; + public deaf: boolean | null; + public mute: boolean | null; + public selfDeaf: boolean | null; + public selfMute: boolean | null; + public suppress: boolean | null; + public channelId: Snowflake | null; + public avatarURL: string; + public activity: WidgetActivity | null; +} + +export class WelcomeChannel extends Base { + private constructor(guild: Guild, data: RawWelcomeChannelData); + private _emoji: Omit; + public channelId: Snowflake; + public guild: Guild | InviteGuild; + public description: string; + public readonly channel: TextChannel | NewsChannel | StoreChannel | null; + public readonly emoji: GuildEmoji | Emoji; +} + +export class WelcomeScreen extends Base { + private constructor(guild: Guild, data: RawWelcomeScreenData); + public readonly enabled: boolean; + public guild: Guild | InviteGuild; + public description: string | null; + public welcomeChannels: Collection; +} + +//#endregion + +//#region Constants + +export type EnumHolder = { [P in keyof T]: T[P] }; + +export type ExcludeEnum = Exclude; + +export const Constants: { + Package: { + name: string; + version: string; + description: string; + license: string; + main: string; + types: string; + homepage: string; + keywords: string[]; + bugs: { url: string }; + repository: { type: string; url: string }; + scripts: Record; + engines: Record; + dependencies: Record; + devDependencies: Record; + [key: string]: unknown; + }; + UserAgent: string; + Endpoints: { + botGateway: string; + invite: (root: string, code: string, eventId?: Snowflake) => string; + scheduledEvent: (root: string, guildId: Snowflake, eventId: Snowflake) => string; + CDN: (root: string) => { + Emoji: (emojiId: Snowflake, format: DynamicImageFormat) => string; + Asset: (name: string) => string; + DefaultAvatar: (discriminator: number) => string; + Avatar: ( + userId: Snowflake, + hash: string, + format: DynamicImageFormat, + size: AllowedImageSize, + dynamic: boolean, + ) => string; + Banner: ( + id: Snowflake, + hash: string, + format: DynamicImageFormat, + size: AllowedImageSize, + dynamic: boolean, + ) => string; + GuildMemberAvatar: ( + guildId: Snowflake, + memberId: Snowflake, + hash: string, + format?: DynamicImageFormat, + size?: AllowedImageSize, + dynamic?: boolean, + ) => string; + Icon: ( + guildId: Snowflake, + hash: string, + format: DynamicImageFormat, + size: AllowedImageSize, + dynamic: boolean, + ) => string; + AppIcon: ( + appId: Snowflake, + hash: string, + { format, size }: { format: AllowedImageFormat; size: AllowedImageSize }, + ) => string; + AppAsset: ( + appId: Snowflake, + hash: string, + { format, size }: { format: AllowedImageFormat; size: AllowedImageSize }, + ) => string; + StickerPackBanner: (bannerId: Snowflake, format: AllowedImageFormat, size: AllowedImageSize) => string; + GDMIcon: (channelId: Snowflake, hash: string, format: AllowedImageFormat, size: AllowedImageSize) => string; + Splash: (guildId: Snowflake, hash: string, format: AllowedImageFormat, size: AllowedImageSize) => string; + DiscoverySplash: (guildId: Snowflake, hash: string, format: AllowedImageFormat, size: AllowedImageSize) => string; + TeamIcon: ( + teamId: Snowflake, + hash: string, + { format, size }: { format: AllowedImageFormat; size: AllowedImageSize }, + ) => string; + Sticker: (stickerId: Snowflake, stickerFormat: StickerFormatType) => string; + RoleIcon: (roleId: Snowflake, hash: string, format: AllowedImageFormat, size: AllowedImageSize) => string; + }; + }; + WSCodes: { + 1000: 'WS_CLOSE_REQUESTED'; + 4004: 'TOKEN_INVALID'; + 4010: 'SHARDING_INVALID'; + 4011: 'SHARDING_REQUIRED'; + }; + Events: ConstantsEvents; + ShardEvents: ConstantsShardEvents; + PartialTypes: { + [K in PartialTypes]: K; + }; + WSEvents: { + [K in WSEventType]: K; + }; + Colors: ConstantsColors; + Status: ConstantsStatus; + Opcodes: ConstantsOpcodes; + APIErrors: APIErrors; + ChannelTypes: EnumHolder; + ThreadChannelTypes: ThreadChannelTypes[]; + TextBasedChannelTypes: TextBasedChannelTypes[]; + VoiceBasedChannelTypes: VoiceBasedChannelTypes[]; + ClientApplicationAssetTypes: ConstantsClientApplicationAssetTypes; + IntegrationExpireBehaviors: IntegrationExpireBehaviors[]; + InviteScopes: InviteScope[]; + MessageTypes: MessageType[]; + SystemMessageTypes: SystemMessageType[]; + ActivityTypes: EnumHolder; + StickerTypes: EnumHolder; + StickerFormatTypes: EnumHolder; + OverwriteTypes: EnumHolder; + ExplicitContentFilterLevels: EnumHolder; + DefaultMessageNotificationLevels: EnumHolder; + VerificationLevels: EnumHolder; + MembershipStates: EnumHolder; + ApplicationCommandOptionTypes: EnumHolder; + ApplicationCommandPermissionTypes: EnumHolder; + InteractionTypes: EnumHolder; + InteractionResponseTypes: EnumHolder; + MessageComponentTypes: EnumHolder; + MessageButtonStyles: EnumHolder; + MFALevels: EnumHolder; + NSFWLevels: EnumHolder; + PrivacyLevels: EnumHolder; + WebhookTypes: EnumHolder; + PremiumTiers: EnumHolder; + ApplicationCommandTypes: EnumHolder; + GuildScheduledEventEntityTypes: EnumHolder; + GuildScheduledEventStatuses: EnumHolder; + GuildScheduledEventPrivacyLevels: EnumHolder; +}; + +export const version: string; + +//#endregion + +//#region Managers + +export abstract class BaseManager { + protected constructor(client: Client); + public readonly client: Client; +} + +export abstract class DataManager extends BaseManager { + protected constructor(client: Client, holds: Constructable); + public readonly holds: Constructable; + public readonly cache: Collection; + public resolve(resolvable: Holds): Holds; + public resolve(resolvable: R): Holds | null; + public resolveId(resolvable: K | Holds): K; + public resolveId(resolvable: R): K | null; + public valueOf(): Collection; +} + +export abstract class CachedManager extends DataManager { + protected constructor(client: Client, holds: Constructable); + private _add(data: unknown, cache?: boolean, { id, extras }?: { id: K; extras: unknown[] }): Holds; +} + +export type ApplicationCommandDataResolvable = ApplicationCommandData | RESTPostAPIApplicationCommandsJSONBody; + +export class ApplicationCommandManager< + ApplicationCommandScope = ApplicationCommand<{ guild: GuildResolvable }>, + PermissionsOptionsExtras = { guild: GuildResolvable }, + PermissionsGuildType = null, +> extends CachedManager { + protected constructor(client: Client, iterable?: Iterable); + public permissions: ApplicationCommandPermissionsManager< + { command?: ApplicationCommandResolvable } & PermissionsOptionsExtras, + { command: ApplicationCommandResolvable } & PermissionsOptionsExtras, + PermissionsOptionsExtras, + PermissionsGuildType, + null + >; + private commandPath({ id, guildId }: { id?: Snowflake; guildId?: Snowflake }): unknown; + public create(command: ApplicationCommandDataResolvable, guildId?: Snowflake): Promise; + public delete(command: ApplicationCommandResolvable, guildId?: Snowflake): Promise; + public edit( + command: ApplicationCommandResolvable, + data: ApplicationCommandDataResolvable, + ): Promise; + public edit( + command: ApplicationCommandResolvable, + data: ApplicationCommandDataResolvable, + guildId: Snowflake, + ): Promise; + public fetch( + id: Snowflake, + options: FetchApplicationCommandOptions & { guildId: Snowflake }, + ): Promise; + public fetch(options: FetchApplicationCommandOptions): Promise>; + public fetch(id: Snowflake, options?: FetchApplicationCommandOptions): Promise; + public fetch( + id?: Snowflake, + options?: FetchApplicationCommandOptions, + ): Promise>; + public set(commands: ApplicationCommandDataResolvable[]): Promise>; + public set( + commands: ApplicationCommandDataResolvable[], + guildId: Snowflake, + ): Promise>; + private static transformCommand( + command: ApplicationCommandData, + ): Omit; +} + +export class ApplicationCommandPermissionsManager< + BaseOptions, + FetchSingleOptions, + FullPermissionsOptions, + GuildType, + CommandIdType, +> extends BaseManager { + private constructor(manager: ApplicationCommandManager | GuildApplicationCommandManager | ApplicationCommand); + private manager: ApplicationCommandManager | GuildApplicationCommandManager | ApplicationCommand; + + public client: Client; + public commandId: CommandIdType; + public guild: GuildType; + public guildId: Snowflake | null; + public add( + options: FetchSingleOptions & { permissions: ApplicationCommandPermissionData[] }, + ): Promise; + public has(options: FetchSingleOptions & { permissionId: UserResolvable | RoleResolvable }): Promise; + public fetch(options: FetchSingleOptions): Promise; + public fetch(options: BaseOptions): Promise>; + public remove( + options: + | (FetchSingleOptions & { + users: UserResolvable | UserResolvable[]; + roles?: RoleResolvable | RoleResolvable[]; + }) + | (FetchSingleOptions & { + users?: UserResolvable | UserResolvable[]; + roles: RoleResolvable | RoleResolvable[]; + }), + ): Promise; + public set( + options: FetchSingleOptions & { permissions: ApplicationCommandPermissionData[] }, + ): Promise; + public set( + options: FullPermissionsOptions & { + fullPermissions: GuildApplicationCommandPermissionData[]; + }, + ): Promise>; + private permissionsPath(guildId: Snowflake, commandId?: Snowflake): unknown; + private static transformPermissions( + permissions: ApplicationCommandPermissionData, + received: true, + ): Omit & { type: keyof ApplicationCommandPermissionTypes }; + private static transformPermissions(permissions: ApplicationCommandPermissionData): APIApplicationCommandPermission; +} + +export class BaseGuildEmojiManager extends CachedManager { + protected constructor(client: Client, iterable?: Iterable); + public resolveIdentifier(emoji: EmojiIdentifierResolvable): string | null; +} + +export class ChannelManager extends CachedManager { + private constructor(client: Client, iterable: Iterable); + public fetch(id: Snowflake, options?: FetchChannelOptions): Promise; +} + +export class ClientUserSettingManager extends CachedManager { + private constructor(client: Client, iterable?: Iterable); + public fetch(): Promise; + public setDisplayCompactMode(value?: boolean): Promise; + public setTheme(value?: 'dark' | 'light'): Promise; + public setLocale(value: LocaleStrings): Promise; +} + +export class GuildApplicationCommandManager extends ApplicationCommandManager { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public create(command: ApplicationCommandDataResolvable): Promise; + public delete(command: ApplicationCommandResolvable): Promise; + public edit( + command: ApplicationCommandResolvable, + data: ApplicationCommandDataResolvable, + ): Promise; + public fetch(id: Snowflake, options?: BaseFetchOptions): Promise; + public fetch(options: BaseFetchOptions): Promise>; + public fetch(id?: undefined, options?: BaseFetchOptions): Promise>; + public set(commands: ApplicationCommandDataResolvable[]): Promise>; +} + +export type MappedGuildChannelTypes = EnumValueMapped< + typeof ChannelTypes, + { + GUILD_CATEGORY: CategoryChannel; + } +> & + MappedChannelCategoryTypes; + +export type GuildChannelTypes = CategoryChannelTypes | ChannelTypes.GUILD_CATEGORY | 'GUILD_CATEGORY'; + +export class GuildChannelManager extends CachedManager { + private constructor(guild: Guild, iterable?: Iterable); + public readonly channelCountWithoutThreads: number; + public guild: Guild; + /** @deprecated See [Self-serve Game Selling Deprecation](https://support-dev.discord.com/hc/en-us/articles/4414590563479) for more information */ + public create(name: string, options: GuildChannelCreateOptions & { type: 'GUILD_STORE' }): Promise; + public create( + name: string, + options: GuildChannelCreateOptions & { type: T }, + ): Promise; + public create(name: string, options: GuildChannelCreateOptions): Promise; + public fetch(id: Snowflake, options?: BaseFetchOptions): Promise; + public fetch(id?: undefined, options?: BaseFetchOptions): Promise>; + public setPositions(channelPositions: readonly ChannelPosition[]): Promise; + public fetchActiveThreads(cache?: boolean): Promise; +} + +export class GuildEmojiManager extends BaseGuildEmojiManager { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public create( + attachment: BufferResolvable | Base64Resolvable, + name: string, + options?: GuildEmojiCreateOptions, + ): Promise; + public fetch(id: Snowflake, options?: BaseFetchOptions): Promise; + public fetch(id?: undefined, options?: BaseFetchOptions): Promise>; +} + +export class GuildEmojiRoleManager extends DataManager { + private constructor(emoji: GuildEmoji); + public emoji: GuildEmoji; + public guild: Guild; + public add( + roleOrRoles: RoleResolvable | readonly RoleResolvable[] | Collection, + ): Promise; + public set(roles: readonly RoleResolvable[] | Collection): Promise; + public remove( + roleOrRoles: RoleResolvable | readonly RoleResolvable[] | Collection, + ): Promise; +} + +export class GuildManager extends CachedManager { + private constructor(client: Client, iterable?: Iterable); + public create(name: string, options?: GuildCreateOptions): Promise; + public fetch(options: Snowflake | FetchGuildOptions): Promise; + public fetch(options?: FetchGuildsOptions): Promise>; +} + +export class GuildMemberManager extends CachedManager { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public add( + user: UserResolvable, + options: AddGuildMemberOptions & { fetchWhenExisting: false }, + ): Promise; + public add(user: UserResolvable, options: AddGuildMemberOptions): Promise; + public ban(user: UserResolvable, options?: BanOptions): Promise; + public edit(user: UserResolvable, data: GuildMemberEditData, reason?: string): Promise; + public fetch( + options: UserResolvable | FetchMemberOptions | (FetchMembersOptions & { user: UserResolvable }), + ): Promise; + public fetch(options?: FetchMembersOptions): Promise>; + public kick(user: UserResolvable, reason?: string): Promise; + public list(options?: GuildListMembersOptions): Promise>; + public prune(options: GuildPruneMembersOptions & { dry?: false; count: false }): Promise; + public prune(options?: GuildPruneMembersOptions): Promise; + public search(options: GuildSearchMembersOptions): Promise>; + public unban(user: UserResolvable, reason?: string): Promise; +} + +export class GuildBanManager extends CachedManager { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public create(user: UserResolvable, options?: BanOptions): Promise; + public fetch(options: UserResolvable | FetchBanOptions): Promise; + public fetch(options?: FetchBansOptions): Promise>; + public remove(user: UserResolvable, reason?: string): Promise; +} + +export class GuildInviteManager extends DataManager { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public create(channel: GuildInvitableChannelResolvable, options?: CreateInviteOptions): Promise; + public fetch(options: InviteResolvable | FetchInviteOptions): Promise; + public fetch(options?: FetchInvitesOptions): Promise>; + public delete(invite: InviteResolvable, reason?: string): Promise; +} + +export class GuildScheduledEventManager extends CachedManager< + Snowflake, + GuildScheduledEvent, + GuildScheduledEventResolvable +> { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public create(options: GuildScheduledEventCreateOptions): Promise; + public fetch(): Promise>; + public fetch< + T extends GuildScheduledEventResolvable | FetchGuildScheduledEventOptions | FetchGuildScheduledEventsOptions, + >(options?: T): Promise>; + public edit>( + guildScheduledEvent: GuildScheduledEventResolvable, + options: GuildScheduledEventEditOptions, + ): Promise>; + public delete(guildScheduledEvent: GuildScheduledEventResolvable): Promise; + public fetchSubscribers( + guildScheduledEvent: GuildScheduledEventResolvable, + options?: T, + ): Promise>; +} + +export class GuildStickerManager extends CachedManager { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public create( + file: BufferResolvable | Stream | FileOptions | MessageAttachment, + name: string, + tags: string, + options?: GuildStickerCreateOptions, + ): Promise; + public edit(sticker: StickerResolvable, data?: GuildStickerEditData, reason?: string): Promise; + public delete(sticker: StickerResolvable, reason?: string): Promise; + public fetch(id: Snowflake, options?: BaseFetchOptions): Promise; + public fetch(id?: Snowflake, options?: BaseFetchOptions): Promise>; +} + +export class GuildMemberRoleManager extends DataManager { + private constructor(member: GuildMember); + public readonly hoist: Role | null; + public readonly icon: Role | null; + public readonly color: Role | null; + public readonly highest: Role; + public readonly premiumSubscriberRole: Role | null; + public readonly botRole: Role | null; + public member: GuildMember; + public guild: Guild; + + public add( + roleOrRoles: RoleResolvable | readonly RoleResolvable[] | Collection, + reason?: string, + ): Promise; + public set(roles: readonly RoleResolvable[] | Collection, reason?: string): Promise; + public remove( + roleOrRoles: RoleResolvable | readonly RoleResolvable[] | Collection, + reason?: string, + ): Promise; +} + +export class MessageManager extends CachedManager { + private constructor(channel: TextBasedChannel, iterable?: Iterable); + public channel: TextBasedChannel; + public cache: Collection; + public crosspost(message: MessageResolvable): Promise; + public delete(message: MessageResolvable): Promise; + public edit(message: MessageResolvable, options: string | MessagePayload | MessageEditOptions): Promise; + public fetch(message: Snowflake, options?: BaseFetchOptions): Promise; + public fetch( + options?: ChannelLogsQueryOptions, + cacheOptions?: BaseFetchOptions, + ): Promise>; + public fetchPinned(cache?: boolean): Promise>; + public react(message: MessageResolvable, emoji: EmojiIdentifierResolvable): Promise; + public pin(message: MessageResolvable): Promise; + public unpin(message: MessageResolvable): Promise; +} + +export class PermissionOverwriteManager extends CachedManager< + Snowflake, + PermissionOverwrites, + PermissionOverwriteResolvable +> { + private constructor(client: Client, iterable?: Iterable); + public set( + overwrites: readonly OverwriteResolvable[] | Collection, + reason?: string, + ): Promise; + private upsert( + userOrRole: RoleResolvable | UserResolvable, + options: PermissionOverwriteOptions, + overwriteOptions?: GuildChannelOverwriteOptions, + existing?: PermissionOverwrites, + ): Promise; + public create( + userOrRole: RoleResolvable | UserResolvable, + options: PermissionOverwriteOptions, + overwriteOptions?: GuildChannelOverwriteOptions, + ): Promise; + public edit( + userOrRole: RoleResolvable | UserResolvable, + options: PermissionOverwriteOptions, + overwriteOptions?: GuildChannelOverwriteOptions, + ): Promise; + public delete(userOrRole: RoleResolvable | UserResolvable, reason?: string): Promise; +} + +export class PresenceManager extends CachedManager { + private constructor(client: Client, iterable?: Iterable); +} + +export class ReactionManager extends CachedManager { + private constructor(message: Message, iterable?: Iterable); + public message: Message; + public removeAll(): Promise; +} + +export class ReactionUserManager extends CachedManager { + private constructor(reaction: MessageReaction, iterable?: Iterable); + public reaction: MessageReaction; + public fetch(options?: FetchReactionUsersOptions): Promise>; + public remove(user?: UserResolvable): Promise; +} + +export class RoleManager extends CachedManager { + private constructor(guild: Guild, iterable?: Iterable); + public readonly everyone: Role; + public readonly highest: Role; + public guild: Guild; + public readonly premiumSubscriberRole: Role | null; + public botRoleFor(user: UserResolvable): Role | null; + public fetch(id: Snowflake, options?: BaseFetchOptions): Promise; + public fetch(id?: undefined, options?: BaseFetchOptions): Promise>; + public create(options?: CreateRoleOptions): Promise; + public edit(role: RoleResolvable, options: RoleData, reason?: string): Promise; + public delete(role: RoleResolvable, reason?: string): Promise; + public setPositions(rolePositions: readonly RolePosition[]): Promise; + public comparePositions(role1: RoleResolvable, role2: RoleResolvable): number; +} + +export class StageInstanceManager extends CachedManager { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public create(channel: StageChannelResolvable, options: StageInstanceCreateOptions): Promise; + public fetch(channel: StageChannelResolvable, options?: BaseFetchOptions): Promise; + public edit(channel: StageChannelResolvable, options: StageInstanceEditOptions): Promise; + public delete(channel: StageChannelResolvable): Promise; +} + +export class ThreadManager extends CachedManager { + private constructor(channel: TextChannel | NewsChannel, iterable?: Iterable); + public channel: TextChannel | NewsChannel; + public create(options: ThreadCreateOptions): Promise; + public fetch(options: ThreadChannelResolvable, cacheOptions?: BaseFetchOptions): Promise; + public fetch(options?: FetchThreadsOptions, cacheOptions?: { cache?: boolean }): Promise; + public fetchArchived(options?: FetchArchivedThreadOptions, cache?: boolean): Promise; + public fetchActive(cache?: boolean): Promise; +} + +export class ThreadMemberManager extends CachedManager { + private constructor(thread: ThreadChannel, iterable?: Iterable); + public thread: ThreadChannel; + public add(member: UserResolvable | '@me', reason?: string): Promise; + public fetch(member?: UserResolvable, options?: BaseFetchOptions): Promise; + /** @deprecated Use `fetch(member, options)` instead. */ + public fetch(cache?: boolean): Promise>; + public remove(id: Snowflake | '@me', reason?: string): Promise; +} + +export class UserManager extends CachedManager { + private constructor(client: Client, iterable?: Iterable); + private dmChannel(userId: Snowflake): DMChannel | null; + public createDM(user: UserResolvable, options?: BaseFetchOptions): Promise; + public deleteDM(user: UserResolvable): Promise; + public fetch(user: UserResolvable, options?: BaseFetchOptions): Promise; + public fetchFlags(user: UserResolvable, options?: BaseFetchOptions): Promise; + public send(user: UserResolvable, options: string | MessagePayload | MessageOptions): Promise; +} + +export class FriendsManager extends CachedManager { + private constructor(client: Client, iterable?: Iterable); + private dmChannel(userId: Snowflake): DMChannel | null; + public createDM(user: UserResolvable, options?: BaseFetchOptions): Promise; + public deleteDM(user: UserResolvable): Promise; + public fetch(user: UserResolvable, options?: BaseFetchOptions): Promise; + public fetchFlags(user: UserResolvable, options?: BaseFetchOptions): Promise; + public send(user: UserResolvable, options: string | MessagePayload | MessageOptions): Promise; +} + +export class BlockedManager extends CachedManager { + private constructor(client: Client, iterable?: Iterable); + private dmChannel(userId: Snowflake): DMChannel | null; + public createDM(user: UserResolvable, options?: BaseFetchOptions): Promise; + public deleteDM(user: UserResolvable): Promise; + public fetch(user: UserResolvable, options?: BaseFetchOptions): Promise; + public fetchFlags(user: UserResolvable, options?: BaseFetchOptions): Promise; + public send(user: UserResolvable, options: string | MessagePayload | MessageOptions): Promise; +} + +export class VoiceStateManager extends CachedManager { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; +} + +//#endregion + +//#region Mixins + +// Model the TextBasedChannel mixin system, allowing application of these fields +// to the classes that use these methods without having to manually add them +// to each of those classes + +export type Constructable = abstract new (...args: any[]) => T; +export function PartialTextBasedChannel(Base?: Constructable): Constructable; +export function TextBasedChannelMixin( + Base?: Constructable, + ignore?: I[], +): Constructable>; + +export interface PartialTextBasedChannelFields { + send(options: string | MessagePayload | MessageOptions): Promise; +} + +export interface TextBasedChannelFields extends PartialTextBasedChannelFields { + lastMessageId: Snowflake | null; + readonly lastMessage: Message | null; + lastPinTimestamp: number | null; + readonly lastPinAt: Date | null; + awaitMessageComponent( + options?: AwaitMessageCollectorOptionsParams, + ): Promise; + awaitMessages(options?: AwaitMessagesOptions): Promise>; + bulkDelete( + messages: Collection | readonly MessageResolvable[] | number, + filterOld?: boolean, + ): Promise>; + createMessageComponentCollector( + options?: MessageChannelCollectorOptionsParams, + ): InteractionCollector; + createMessageCollector(options?: MessageCollectorOptions): MessageCollector; + sendTyping(): Promise; +} + +export function PartialWebhookMixin(Base?: Constructable): Constructable; +export function WebhookMixin(Base?: Constructable): Constructable; + +export interface PartialWebhookFields { + id: Snowflake; + readonly url: string; + deleteMessage(message: MessageResolvable | APIMessage | '@original', threadId?: Snowflake): Promise; + editMessage( + message: MessageResolvable | '@original', + options: string | MessagePayload | WebhookEditMessageOptions, + ): Promise; + fetchMessage(message: Snowflake | '@original', options?: WebhookFetchMessageOptions): Promise; + /* tslint:disable:unified-signatures */ + /** @deprecated */ + fetchMessage(message: Snowflake | '@original', cache?: boolean): Promise; + /* tslint:enable:unified-signatures */ + send(options: string | MessagePayload | WebhookMessageOptions): Promise; +} + +export interface WebhookFields extends PartialWebhookFields { + readonly createdAt: Date; + readonly createdTimestamp: number; + delete(reason?: string): Promise; + edit(options: WebhookEditData, reason?: string): Promise; + sendSlackMessage(body: unknown): Promise; +} + +//#endregion + +//#region Typedefs + +export type ActivityFlagsString = + | 'INSTANCE' + | 'JOIN' + | 'SPECTATE' + | 'JOIN_REQUEST' + | 'SYNC' + | 'PLAY' + | 'PARTY_PRIVACY_FRIENDS' + | 'PARTY_PRIVACY_VOICE_CHANNEL' + | 'EMBEDDED'; + +export type ActivitiesOptions = Omit; + +export interface ActivityOptions { + name?: string; + url?: string; + type?: ExcludeEnum; + shardId?: number | readonly number[]; +} + +export type ActivityPlatform = 'desktop' | 'samsung' | 'xbox'; + +export type ActivityType = keyof typeof ActivityTypes; + +export interface AddGuildMemberOptions { + accessToken: string; + nick?: string; + roles?: Collection | RoleResolvable[]; + mute?: boolean; + deaf?: boolean; + force?: boolean; + fetchWhenExisting?: boolean; +} + +export type AllowedImageFormat = 'webp' | 'png' | 'jpg' | 'jpeg'; + +export type AllowedImageSize = 16 | 32 | 56 | 64 | 96 | 128 | 256 | 300 | 512 | 600 | 1024 | 2048 | 4096; + +export type AllowedPartial = User | Channel | GuildMember | Message | MessageReaction; + +export type AllowedThreadTypeForNewsChannel = 'GUILD_NEWS_THREAD' | 10; + +export type AllowedThreadTypeForTextChannel = 'GUILD_PUBLIC_THREAD' | 'GUILD_PRIVATE_THREAD' | 11 | 12; + +export interface APIErrors { + UNKNOWN_ACCOUNT: 10001; + UNKNOWN_APPLICATION: 10002; + UNKNOWN_CHANNEL: 10003; + UNKNOWN_GUILD: 10004; + UNKNOWN_INTEGRATION: 10005; + UNKNOWN_INVITE: 10006; + UNKNOWN_MEMBER: 10007; + UNKNOWN_MESSAGE: 10008; + UNKNOWN_OVERWRITE: 10009; + UNKNOWN_PROVIDER: 10010; + UNKNOWN_ROLE: 10011; + UNKNOWN_TOKEN: 10012; + UNKNOWN_USER: 10013; + UNKNOWN_EMOJI: 10014; + UNKNOWN_WEBHOOK: 10015; + UNKNOWN_WEBHOOK_SERVICE: 10016; + UNKNOWN_SESSION: 10020; + UNKNOWN_BAN: 10026; + UNKNOWN_SKU: 10027; + UNKNOWN_STORE_LISTING: 10028; + UNKNOWN_ENTITLEMENT: 10029; + UNKNOWN_BUILD: 10030; + UNKNOWN_LOBBY: 10031; + UNKNOWN_BRANCH: 10032; + UNKNOWN_STORE_DIRECTORY_LAYOUT: 10033; + UNKNOWN_REDISTRIBUTABLE: 10036; + UNKNOWN_GIFT_CODE: 10038; + UNKNOWN_STREAM: 10049; + UNKNOWN_PREMIUM_SERVER_SUBSCRIBE_COOLDOWN: 10050; + UNKNOWN_GUILD_TEMPLATE: 10057; + UNKNOWN_DISCOVERABLE_SERVER_CATEGORY: 10059; + UNKNOWN_STICKER: 10060; + UNKNOWN_INTERACTION: 10062; + UNKNOWN_APPLICATION_COMMAND: 10063; + UNKNOWN_APPLICATION_COMMAND_PERMISSIONS: 10066; + UNKNOWN_STAGE_INSTANCE: 10067; + UNKNOWN_GUILD_MEMBER_VERIFICATION_FORM: 10068; + UNKNOWN_GUILD_WELCOME_SCREEN: 10069; + UNKNOWN_GUILD_SCHEDULED_EVENT: 10070; + UNKNOWN_GUILD_SCHEDULED_EVENT_USER: 10071; + BOT_PROHIBITED_ENDPOINT: 20001; + BOT_ONLY_ENDPOINT: 20002; + CANNOT_SEND_EXPLICIT_CONTENT: 20009; + NOT_AUTHORIZED: 20012; + SLOWMODE_RATE_LIMIT: 20016; + ACCOUNT_OWNER_ONLY: 20018; + ANNOUNCEMENT_EDIT_LIMIT_EXCEEDED: 20022; + CHANNEL_HIT_WRITE_RATELIMIT: 20028; + SERVER_HIT_WRITE_RATELIMIT: 20029; + CONTENT_NOT_ALLOWED: 20031; + GUILD_PREMIUM_LEVEL_TOO_LOW: 20035; + MAXIMUM_GUILDS: 30001; + MAXIMUM_FRIENDS: 30002; + MAXIMUM_PINS: 30003; + MAXIMUM_RECIPIENTS: 30004; + MAXIMUM_ROLES: 30005; + MAXIMUM_WEBHOOKS: 30007; + MAXIMUM_EMOJIS: 30008; + MAXIMUM_REACTIONS: 30010; + MAXIMUM_CHANNELS: 30013; + MAXIMUM_ATTACHMENTS: 30015; + MAXIMUM_INVITES: 30016; + MAXIMUM_ANIMATED_EMOJIS: 30018; + MAXIMUM_SERVER_MEMBERS: 30019; + MAXIMUM_NUMBER_OF_SERVER_CATEGORIES: 30030; + GUILD_ALREADY_HAS_TEMPLATE: 30031; + MAXIMUM_THREAD_PARICIPANTS: 30033; + MAXIMUM_NON_GUILD_MEMBERS_BANS: 30035; + MAXIMUM_BAN_FETCHES: 30037; + MAXIMUM_NUMBER_OF_UNCOMPLETED_GUILD_SCHEDULED_EVENTS_REACHED: 30038; + MAXIMUM_NUMBER_OF_STICKERS_REACHED: 30039; + MAXIMUM_PRUNE_REQUESTS: 30040; + MAXIMUM_GUILD_WIDGET_SETTINGS_UPDATE: 30042; + UNAUTHORIZED: 40001; + ACCOUNT_VERIFICATION_REQUIRED: 40002; + DIRECT_MESSAGES_TOO_FAST: 40003; + REQUEST_ENTITY_TOO_LARGE: 40005; + FEATURE_TEMPORARILY_DISABLED: 40006; + USER_BANNED: 40007; + TARGET_USER_NOT_CONNECTED_TO_VOICE: 40032; + ALREADY_CROSSPOSTED: 40033; + MISSING_ACCESS: 50001; + INVALID_ACCOUNT_TYPE: 50002; + CANNOT_EXECUTE_ON_DM: 50003; + EMBED_DISABLED: 50004; + CANNOT_EDIT_MESSAGE_BY_OTHER: 50005; + CANNOT_SEND_EMPTY_MESSAGE: 50006; + CANNOT_MESSAGE_USER: 50007; + CANNOT_SEND_MESSAGES_IN_VOICE_CHANNEL: 50008; + CHANNEL_VERIFICATION_LEVEL_TOO_HIGH: 50009; + OAUTH2_APPLICATION_BOT_ABSENT: 50010; + MAXIMUM_OAUTH2_APPLICATIONS: 50011; + INVALID_OAUTH_STATE: 50012; + MISSING_PERMISSIONS: 50013; + INVALID_AUTHENTICATION_TOKEN: 50014; + NOTE_TOO_LONG: 50015; + INVALID_BULK_DELETE_QUANTITY: 50016; + CANNOT_PIN_MESSAGE_IN_OTHER_CHANNEL: 50019; + INVALID_OR_TAKEN_INVITE_CODE: 50020; + CANNOT_EXECUTE_ON_SYSTEM_MESSAGE: 50021; + CANNOT_EXECUTE_ON_CHANNEL_TYPE: 50024; + INVALID_OAUTH_TOKEN: 50025; + MISSING_OAUTH_SCOPE: 50026; + INVALID_WEBHOOK_TOKEN: 50027; + INVALID_ROLE: 50028; + INVALID_RECIPIENTS: 50033; + BULK_DELETE_MESSAGE_TOO_OLD: 50034; + INVALID_FORM_BODY: 50035; + INVITE_ACCEPTED_TO_GUILD_NOT_CONTAINING_BOT: 50036; + INVALID_API_VERSION: 50041; + FILE_UPLOADED_EXCEEDS_MAXIMUM_SIZE: 50045; + INVALID_FILE_UPLOADED: 50046; + CANNOT_SELF_REDEEM_GIFT: 50054; + INVALID_GUILD: 50055; + PAYMENT_SOURCE_REQUIRED: 50070; + CANNOT_DELETE_COMMUNITY_REQUIRED_CHANNEL: 50074; + INVALID_STICKER_SENT: 50081; + INVALID_THREAD_ARCHIVE_STATE: 50083; + INVALID_THREAD_NOTIFICATION_SETTINGS: 50084; + PARAMETER_EARLIER_THAN_CREATION: 50085; + GUILD_NOT_AVAILABLE_IN_LOCATION: 50095; + GUILD_MONETIZATION_REQUIRED: 50097; + INSUFFICIENT_BOOSTS: 50101; + INVALID_JSON: 50109; + TWO_FACTOR_REQUIRED: 60003; + NO_USERS_WITH_DISCORDTAG_EXIST: 80004; + REACTION_BLOCKED: 90001; + RESOURCE_OVERLOADED: 130000; + STAGE_ALREADY_OPEN: 150006; + CANNOT_REPLY_WITHOUT_READ_MESSAGE_HISTORY_PERMISSION: 160002; + MESSAGE_ALREADY_HAS_THREAD: 160004; + THREAD_LOCKED: 160005; + MAXIMUM_ACTIVE_THREADS: 160006; + MAXIMUM_ACTIVE_ANNOUNCEMENT_THREADS: 160007; + INVALID_JSON_FOR_UPLOADED_LOTTIE_FILE: 170001; + UPLOADED_LOTTIES_CANNOT_CONTAIN_RASTERIZED_IMAGES: 170002; + STICKER_MAXIMUM_FRAMERATE_EXCEEDED: 170003; + STICKER_FRAME_COUNT_EXCEEDS_MAXIMUM_OF_1000_FRAMES: 170004; + LOTTIE_ANIMATION_MAXIMUM_DIMENSIONS_EXCEEDED: 170005; + STICKER_FRAME_RATE_IS_TOO_SMALL_OR_TOO_LARGE: 170006; + STICKER_ANIMATION_DURATION_EXCEEDS_MAXIMUM_OF_5_SECONDS: 170007; + CANNOT_UPDATE_A_FINISHED_EVENT: 180000; + FAILED_TO_CREATE_STAGE_NEEDED_FOR_STAGE_EVENT: 180002; +} + +export interface APIRequest { + method: 'get' | 'post' | 'delete' | 'patch' | 'put'; + options: unknown; + path: string; + retries: number; + route: string; +} + +export interface ApplicationAsset { + name: string; + id: Snowflake; + type: 'BIG' | 'SMALL'; +} + +export interface BaseApplicationCommandData { + name: string; + defaultPermission?: boolean; +} + +export type CommandOptionDataTypeResolvable = ApplicationCommandOptionType | ApplicationCommandOptionTypes; + +export type CommandOptionChannelResolvableType = ApplicationCommandOptionTypes.CHANNEL | 'CHANNEL'; + +export type CommandOptionChoiceResolvableType = + | ApplicationCommandOptionTypes.STRING + | 'STRING' + | CommandOptionNumericResolvableType; + +export type CommandOptionNumericResolvableType = + | ApplicationCommandOptionTypes.NUMBER + | 'NUMBER' + | ApplicationCommandOptionTypes.INTEGER + | 'INTEGER'; + +export type CommandOptionSubOptionResolvableType = + | ApplicationCommandOptionTypes.SUB_COMMAND + | 'SUB_COMMAND' + | ApplicationCommandOptionTypes.SUB_COMMAND_GROUP + | 'SUB_COMMAND_GROUP'; + +export type CommandOptionNonChoiceResolvableType = Exclude< + CommandOptionDataTypeResolvable, + CommandOptionChoiceResolvableType | CommandOptionSubOptionResolvableType | CommandOptionChannelResolvableType +>; + +export interface BaseApplicationCommandOptionsData { + name: string; + description: string; + required?: boolean; + autocomplete?: never; +} + +export interface UserApplicationCommandData extends BaseApplicationCommandData { + type: 'USER' | ApplicationCommandTypes.USER; +} + +export interface MessageApplicationCommandData extends BaseApplicationCommandData { + type: 'MESSAGE' | ApplicationCommandTypes.MESSAGE; +} + +export interface ChatInputApplicationCommandData extends BaseApplicationCommandData { + description: string; + type?: 'CHAT_INPUT' | ApplicationCommandTypes.CHAT_INPUT; + options?: ApplicationCommandOptionData[]; +} + +export type ApplicationCommandData = + | UserApplicationCommandData + | MessageApplicationCommandData + | ChatInputApplicationCommandData; + +export interface ApplicationCommandChannelOptionData extends BaseApplicationCommandOptionsData { + type: CommandOptionChannelResolvableType; + channelTypes?: ExcludeEnum[]; + channel_types?: Exclude[]; +} + +export interface ApplicationCommandChannelOption extends BaseApplicationCommandOptionsData { + type: 'CHANNEL'; + channelTypes?: (keyof typeof ChannelTypes)[]; +} + +export interface ApplicationCommandAutocompleteOption extends Omit { + type: + | 'STRING' + | 'NUMBER' + | 'INTEGER' + | ApplicationCommandOptionTypes.STRING + | ApplicationCommandOptionTypes.NUMBER + | ApplicationCommandOptionTypes.INTEGER; + autocomplete: true; +} + +export interface ApplicationCommandChoicesData extends Omit { + type: CommandOptionChoiceResolvableType; + choices?: ApplicationCommandOptionChoice[]; + autocomplete?: false; +} + +export interface ApplicationCommandChoicesOption extends Omit { + type: Exclude; + choices?: ApplicationCommandOptionChoice[]; + autocomplete?: false; +} + +export interface ApplicationCommandNumericOptionData extends ApplicationCommandChoicesData { + type: CommandOptionNumericResolvableType; + minValue?: number; + min_value?: number; + maxValue?: number; + max_value?: number; +} + +export interface ApplicationCommandNumericOption extends ApplicationCommandChoicesOption { + type: Exclude; + minValue?: number; + maxValue?: number; +} + +export interface ApplicationCommandSubGroupData extends Omit { + type: 'SUB_COMMAND_GROUP' | ApplicationCommandOptionTypes.SUB_COMMAND_GROUP; + options?: ApplicationCommandSubCommandData[]; +} + +export interface ApplicationCommandSubGroup extends Omit { + type: 'SUB_COMMAND_GROUP'; + options?: ApplicationCommandSubCommand[]; +} + +export interface ApplicationCommandSubCommandData extends Omit { + type: 'SUB_COMMAND' | ApplicationCommandOptionTypes.SUB_COMMAND; + options?: ( + | ApplicationCommandChoicesData + | ApplicationCommandNonOptionsData + | ApplicationCommandChannelOptionData + | ApplicationCommandAutocompleteOption + | ApplicationCommandNumericOptionData + )[]; +} + +export interface ApplicationCommandSubCommand extends Omit { + type: 'SUB_COMMAND'; + options?: (ApplicationCommandChoicesOption | ApplicationCommandNonOptions | ApplicationCommandChannelOption)[]; +} + +export interface ApplicationCommandNonOptionsData extends BaseApplicationCommandOptionsData { + type: CommandOptionNonChoiceResolvableType; +} + +export interface ApplicationCommandNonOptions extends BaseApplicationCommandOptionsData { + type: Exclude; +} + +export type ApplicationCommandOptionData = + | ApplicationCommandSubGroupData + | ApplicationCommandNonOptionsData + | ApplicationCommandChannelOptionData + | ApplicationCommandChoicesData + | ApplicationCommandAutocompleteOption + | ApplicationCommandNumericOptionData + | ApplicationCommandSubCommandData; + +export type ApplicationCommandOption = + | ApplicationCommandSubGroup + | ApplicationCommandNonOptions + | ApplicationCommandChannelOption + | ApplicationCommandChoicesOption + | ApplicationCommandNumericOption + | ApplicationCommandSubCommand; + +export interface ApplicationCommandOptionChoice { + name: string; + value: string | number; +} + +export type ApplicationCommandType = keyof typeof ApplicationCommandTypes; + +export type ApplicationCommandOptionType = keyof typeof ApplicationCommandOptionTypes; + +export interface ApplicationCommandPermissionData { + id: Snowflake; + type: ApplicationCommandPermissionType | ApplicationCommandPermissionTypes; + permission: boolean; +} + +export interface ApplicationCommandPermissions extends ApplicationCommandPermissionData { + type: ApplicationCommandPermissionType; +} + +export type ApplicationCommandPermissionType = keyof typeof ApplicationCommandPermissionTypes; + +export type ApplicationCommandResolvable = ApplicationCommand | Snowflake; + +export type ApplicationFlagsString = + | 'GATEWAY_PRESENCE' + | 'GATEWAY_PRESENCE_LIMITED' + | 'GATEWAY_GUILD_MEMBERS' + | 'GATEWAY_GUILD_MEMBERS_LIMITED' + | 'VERIFICATION_PENDING_GUILD_LIMIT' + | 'EMBEDDED' + | 'GATEWAY_MESSAGE_CONTENT' + | 'GATEWAY_MESSAGE_CONTENT_LIMITED'; + +export interface AuditLogChange { + key: APIAuditLogChange['key']; + old?: APIAuditLogChange['old_value']; + new?: APIAuditLogChange['new_value']; +} + +export type Awaitable = T | PromiseLike; + +export type AwaitMessageComponentOptions = Omit< + MessageComponentCollectorOptions, + 'max' | 'maxComponents' | 'maxUsers' +>; + +export interface AwaitMessagesOptions extends MessageCollectorOptions { + errors?: string[]; +} + +export interface AwaitReactionsOptions extends ReactionCollectorOptions { + errors?: string[]; +} + +export interface BanOptions { + days?: number; + reason?: string; +} + +export type Base64Resolvable = Buffer | Base64String; + +export type Base64String = string; + +export interface BaseFetchOptions { + cache?: boolean; + force?: boolean; +} + +export interface BaseMessageComponentOptions { + type?: MessageComponentType | MessageComponentTypes; +} + +export type BitFieldResolvable = + | RecursiveReadonlyArray>> + | T + | N + | `${bigint}` + | Readonly>; + +export type BufferResolvable = Buffer | string; + +export interface Caches { + ApplicationCommandManager: [manager: typeof ApplicationCommandManager, holds: typeof ApplicationCommand]; + BaseGuildEmojiManager: [manager: typeof BaseGuildEmojiManager, holds: typeof GuildEmoji]; + GuildEmojiManager: [manager: typeof GuildEmojiManager, holds: typeof GuildEmoji]; + // TODO: ChannelManager: [manager: typeof ChannelManager, holds: typeof Channel]; + // TODO: GuildChannelManager: [manager: typeof GuildChannelManager, holds: typeof GuildChannel]; + // TODO: GuildManager: [manager: typeof GuildManager, holds: typeof Guild]; + GuildMemberManager: [manager: typeof GuildMemberManager, holds: typeof GuildMember]; + GuildBanManager: [manager: typeof GuildBanManager, holds: typeof GuildBan]; + GuildInviteManager: [manager: typeof GuildInviteManager, holds: typeof Invite]; + GuildScheduledEventManager: [manager: typeof GuildScheduledEventManager, holds: typeof GuildScheduledEvent]; + GuildStickerManager: [manager: typeof GuildStickerManager, holds: typeof Sticker]; + MessageManager: [manager: typeof MessageManager, holds: typeof Message]; + // TODO: PermissionOverwriteManager: [manager: typeof PermissionOverwriteManager, holds: typeof PermissionOverwrites]; + PresenceManager: [manager: typeof PresenceManager, holds: typeof Presence]; + ReactionManager: [manager: typeof ReactionManager, holds: typeof MessageReaction]; + ReactionUserManager: [manager: typeof ReactionUserManager, holds: typeof User]; + // TODO: RoleManager: [manager: typeof RoleManager, holds: typeof Role]; + StageInstanceManager: [manager: typeof StageInstanceManager, holds: typeof StageInstance]; + ThreadManager: [manager: typeof ThreadManager, holds: typeof ThreadChannel]; + ThreadMemberManager: [manager: typeof ThreadMemberManager, holds: typeof ThreadMember]; + UserManager: [manager: typeof UserManager, holds: typeof User]; + VoiceStateManager: [manager: typeof VoiceStateManager, holds: typeof VoiceState]; +} + +export type CacheConstructors = { + [K in keyof Caches]: Caches[K][0] & { name: K }; +}; + +// This doesn't actually work the way it looks 😢. +// Narrowing the type of `manager.name` doesn't propagate type information to `holds` and the return type. +export type CacheFactory = ( + manager: CacheConstructors[keyof Caches], + holds: Caches[typeof manager['name']][1], +) => typeof manager['prototype'] extends DataManager ? Collection : never; + +export type CacheWithLimitsOptions = { + [K in keyof Caches]?: Caches[K][0]['prototype'] extends DataManager + ? LimitedCollectionOptions | number + : never; +}; + +export interface CategoryCreateChannelOptions { + permissionOverwrites?: OverwriteResolvable[] | Collection; + topic?: string; + type?: ExcludeEnum< + typeof ChannelTypes, + | 'DM' + | 'GROUP_DM' + | 'UNKNOWN' + | 'GUILD_PUBLIC_THREAD' + | 'GUILD_NEWS_THREAD' + | 'GUILD_PRIVATE_THREAD' + | 'GUILD_CATEGORY' + >; + nsfw?: boolean; + bitrate?: number; + userLimit?: number; + rateLimitPerUser?: number; + position?: number; + rtcRegion?: string; + reason?: string; +} + +export interface ChannelCreationOverwrites { + allow?: PermissionResolvable; + deny?: PermissionResolvable; + id: RoleResolvable | UserResolvable; +} + +export interface ChannelData { + name?: string; + type?: Pick; + position?: number; + topic?: string; + nsfw?: boolean; + bitrate?: number; + userLimit?: number; + parent?: CategoryChannelResolvable | null; + rateLimitPerUser?: number; + lockPermissions?: boolean; + permissionOverwrites?: readonly OverwriteResolvable[] | Collection; + defaultAutoArchiveDuration?: ThreadAutoArchiveDuration; + rtcRegion?: string | null; +} + +export interface ChannelLogsQueryOptions { + limit?: number; + before?: Snowflake; + after?: Snowflake; + around?: Snowflake; +} + +export type ChannelMention = `<#${Snowflake}>`; + +export interface ChannelPosition { + channel: NonThreadGuildBasedChannel | Snowflake; + lockPermissions?: boolean; + parent?: CategoryChannelResolvable | null; + position?: number; +} + +export type GuildTextChannelResolvable = TextChannel | NewsChannel | Snowflake; +export type ChannelResolvable = AnyChannel | Snowflake; + +export interface ChannelWebhookCreateOptions { + avatar?: BufferResolvable | Base64Resolvable | null; + reason?: string; +} + +export interface BaseClientEvents { + apiResponse: [request: APIRequest, response: Response]; + apiRequest: [request: APIRequest]; + debug: [message: string]; + rateLimit: [rateLimitData: RateLimitData]; + invalidRequestWarning: [invalidRequestWarningData: InvalidRequestWarningData]; +} + +export interface ClientEvents extends BaseClientEvents { + /** @deprecated See [this issue](https://github.com/discord/discord-api-docs/issues/3690) for more information. */ + applicationCommandCreate: [command: ApplicationCommand]; + /** @deprecated See [this issue](https://github.com/discord/discord-api-docs/issues/3690) for more information. */ + applicationCommandDelete: [command: ApplicationCommand]; + /** @deprecated See [this issue](https://github.com/discord/discord-api-docs/issues/3690) for more information. */ + applicationCommandUpdate: [oldCommand: ApplicationCommand | null, newCommand: ApplicationCommand]; + cacheSweep: [message: string]; + channelCreate: [channel: NonThreadGuildBasedChannel]; + channelDelete: [channel: DMChannel | NonThreadGuildBasedChannel]; + channelPinsUpdate: [channel: TextBasedChannel, date: Date]; + channelUpdate: [ + oldChannel: DMChannel | NonThreadGuildBasedChannel, + newChannel: DMChannel | NonThreadGuildBasedChannel, + ]; + warn: [message: string]; + emojiCreate: [emoji: GuildEmoji]; + emojiDelete: [emoji: GuildEmoji]; + emojiUpdate: [oldEmoji: GuildEmoji, newEmoji: GuildEmoji]; + error: [error: Error]; + guildBanAdd: [ban: GuildBan]; + guildBanRemove: [ban: GuildBan]; + guildCreate: [guild: Guild]; + guildDelete: [guild: Guild]; + guildUnavailable: [guild: Guild]; + guildIntegrationsUpdate: [guild: Guild]; + guildMemberAdd: [member: GuildMember]; + guildMemberAvailable: [member: GuildMember | PartialGuildMember]; + guildMemberRemove: [member: GuildMember | PartialGuildMember]; + guildMembersChunk: [ + members: Collection, + guild: Guild, + data: { count: number; index: number; nonce: string | undefined }, + ]; + guildMemberUpdate: [oldMember: GuildMember | PartialGuildMember, newMember: GuildMember]; + guildUpdate: [oldGuild: Guild, newGuild: Guild]; + inviteCreate: [invite: Invite]; + inviteDelete: [invite: Invite]; + /** @deprecated Use messageCreate instead */ + message: [message: Message]; + messageCreate: [message: Message]; + messageDelete: [message: Message | PartialMessage]; + messageReactionRemoveAll: [ + message: Message | PartialMessage, + reactions: Collection, + ]; + messageReactionRemoveEmoji: [reaction: MessageReaction | PartialMessageReaction]; + messageDeleteBulk: [messages: Collection]; + messageReactionAdd: [reaction: MessageReaction | PartialMessageReaction, user: User | PartialUser]; + messageReactionRemove: [reaction: MessageReaction | PartialMessageReaction, user: User | PartialUser]; + messageUpdate: [oldMessage: Message | PartialMessage, newMessage: Message | PartialMessage]; + presenceUpdate: [oldPresence: Presence | null, newPresence: Presence]; + ready: [client: Client]; + invalidated: []; + roleCreate: [role: Role]; + roleDelete: [role: Role]; + roleUpdate: [oldRole: Role, newRole: Role]; + threadCreate: [thread: ThreadChannel]; + threadDelete: [thread: ThreadChannel]; + threadListSync: [threads: Collection]; + threadMemberUpdate: [oldMember: ThreadMember, newMember: ThreadMember]; + threadMembersUpdate: [ + oldMembers: Collection, + newMembers: Collection, + ]; + threadUpdate: [oldThread: ThreadChannel, newThread: ThreadChannel]; + typingStart: [typing: Typing]; + userUpdate: [oldUser: User | PartialUser, newUser: User]; + voiceStateUpdate: [oldState: VoiceState, newState: VoiceState]; + webhookUpdate: [channel: TextChannel | NewsChannel]; + /** @deprecated Use interactionCreate instead */ + interaction: [interaction: Interaction]; + interactionCreate: [interaction: Interaction]; + shardDisconnect: [closeEvent: CloseEvent, shardId: number]; + shardError: [error: Error, shardId: number]; + shardReady: [shardId: number, unavailableGuilds: Set | undefined]; + shardReconnecting: [shardId: number]; + shardResume: [shardId: number, replayedEvents: number]; + stageInstanceCreate: [stageInstance: StageInstance]; + stageInstanceUpdate: [oldStageInstance: StageInstance | null, newStageInstance: StageInstance]; + stageInstanceDelete: [stageInstance: StageInstance]; + stickerCreate: [sticker: Sticker]; + stickerDelete: [sticker: Sticker]; + stickerUpdate: [oldSticker: Sticker, newSticker: Sticker]; + guildScheduledEventCreate: [guildScheduledEvent: GuildScheduledEvent]; + guildScheduledEventUpdate: [oldGuildScheduledEvent: GuildScheduledEvent, newGuildScheduledEvent: GuildScheduledEvent]; + guildScheduledEventDelete: [guildScheduledEvent: GuildScheduledEvent]; + guildScheduledEventUserAdd: [guildScheduledEvent: GuildScheduledEvent, user: User]; + guildScheduledEventUserRemove: [guildScheduledEvent: GuildScheduledEvent, user: User]; +} + +export interface ClientFetchInviteOptions { + guildScheduledEventId?: Snowflake; +} + +export interface ClientOptions { + shards?: number | number[] | 'auto'; + shardCount?: number; + makeCache?: CacheFactory; + /** @deprecated Pass the value of this property as `lifetime` to `sweepers.messages` instead. */ + messageCacheLifetime?: number; + /** @deprecated Pass the value of this property as `interval` to `sweepers.messages` instead. */ + messageSweepInterval?: number; + allowedMentions?: MessageMentionOptions; + invalidRequestWarningInterval?: number; + partials?: PartialTypes[]; + restWsBridgeTimeout?: number; + restTimeOffset?: number; + restRequestTimeout?: number; + restGlobalRateLimit?: number; + restSweepInterval?: number; + retryLimit?: number; + failIfNotExists?: boolean; + userAgentSuffix?: string[]; + presence?: PresenceData; + intents: BitFieldResolvable; + sweepers?: SweeperOptions; + ws?: WebSocketOptions; + http?: HTTPOptions; + rejectOnRateLimit?: string[] | ((data: RateLimitData) => boolean | Promise); +} + +export type ClientPresenceStatus = 'online' | 'idle' | 'dnd'; + +export interface ClientPresenceStatusData { + web?: ClientPresenceStatus; + mobile?: ClientPresenceStatus; + desktop?: ClientPresenceStatus; +} + +export interface ClientUserEditData { + username?: string; + avatar?: BufferResolvable | Base64Resolvable | null; +} + +export interface CloseEvent { + wasClean: boolean; + code: number; + reason: string; + target: WebSocket; +} + +export type CollectorFilter = (...args: T) => boolean | Promise; + +export interface CollectorOptions { + filter?: CollectorFilter; + time?: number; + idle?: number; + dispose?: boolean; +} + +export interface CollectorResetTimerOptions { + time?: number; + idle?: number; +} + +export type ColorResolvable = + | 'DEFAULT' + | 'WHITE' + | 'AQUA' + | 'GREEN' + | 'BLUE' + | 'YELLOW' + | 'PURPLE' + | 'LUMINOUS_VIVID_PINK' + | 'FUCHSIA' + | 'GOLD' + | 'ORANGE' + | 'RED' + | 'GREY' + | 'DARKER_GREY' + | 'NAVY' + | 'DARK_AQUA' + | 'DARK_GREEN' + | 'DARK_BLUE' + | 'DARK_PURPLE' + | 'DARK_VIVID_PINK' + | 'DARK_GOLD' + | 'DARK_ORANGE' + | 'DARK_RED' + | 'DARK_GREY' + | 'LIGHT_GREY' + | 'DARK_NAVY' + | 'BLURPLE' + | 'GREYPLE' + | 'DARK_BUT_NOT_BLACK' + | 'NOT_QUITE_BLACK' + | 'RANDOM' + | readonly [number, number, number] + | number + | HexColorString; + +export interface CommandInteractionOption { + name: string; + type: ApplicationCommandOptionType; + value?: string | number | boolean; + focused?: boolean; + autocomplete?: boolean; + options?: CommandInteractionOption[]; + user?: User; + member?: CacheTypeReducer; + channel?: CacheTypeReducer; + role?: CacheTypeReducer; + message?: GuildCacheMessage; +} + +export interface CommandInteractionResolvedData { + users?: Collection; + members?: Collection>; + roles?: Collection>; + channels?: Collection>; + messages?: Collection>; +} + +export interface ConstantsClientApplicationAssetTypes { + SMALL: 1; + BIG: 2; +} + +export interface ConstantsColors { + DEFAULT: 0x000000; + WHITE: 0xffffff; + AQUA: 0x1abc9c; + GREEN: 0x57f287; + BLUE: 0x3498db; + YELLOW: 0xfee75c; + PURPLE: 0x9b59b6; + LUMINOUS_VIVID_PINK: 0xe91e63; + FUCHSIA: 0xeb459e; + GOLD: 0xf1c40f; + ORANGE: 0xe67e22; + RED: 0xed4245; + GREY: 0x95a5a6; + NAVY: 0x34495e; + DARK_AQUA: 0x11806a; + DARK_GREEN: 0x1f8b4c; + DARK_BLUE: 0x206694; + DARK_PURPLE: 0x71368a; + DARK_VIVID_PINK: 0xad1457; + DARK_GOLD: 0xc27c0e; + DARK_ORANGE: 0xa84300; + DARK_RED: 0x992d22; + DARK_GREY: 0x979c9f; + DARKER_GREY: 0x7f8c8d; + LIGHT_GREY: 0xbcc0c0; + DARK_NAVY: 0x2c3e50; + BLURPLE: 0x5865f2; + GREYPLE: 0x99aab5; + DARK_BUT_NOT_BLACK: 0x2c2f33; + NOT_QUITE_BLACK: 0x23272a; +} + +export interface ConstantsEvents { + RATE_LIMIT: 'rateLimit'; + INVALID_REQUEST_WARNING: 'invalidRequestWarning'; + API_RESPONSE: 'apiResponse'; + API_REQUEST: 'apiRequest'; + CLIENT_READY: 'ready'; + /** @deprecated See [this issue](https://github.com/discord/discord-api-docs/issues/3690) for more information. */ + APPLICATION_COMMAND_CREATE: 'applicationCommandCreate'; + /** @deprecated See [this issue](https://github.com/discord/discord-api-docs/issues/3690) for more information. */ + APPLICATION_COMMAND_DELETE: 'applicationCommandDelete'; + /** @deprecated See [this issue](https://github.com/discord/discord-api-docs/issues/3690) for more information. */ + APPLICATION_COMMAND_UPDATE: 'applicationCommandUpdate'; + GUILD_CREATE: 'guildCreate'; + GUILD_DELETE: 'guildDelete'; + GUILD_UPDATE: 'guildUpdate'; + INVITE_CREATE: 'inviteCreate'; + INVITE_DELETE: 'inviteDelete'; + GUILD_UNAVAILABLE: 'guildUnavailable'; + GUILD_MEMBER_ADD: 'guildMemberAdd'; + GUILD_MEMBER_REMOVE: 'guildMemberRemove'; + GUILD_MEMBER_UPDATE: 'guildMemberUpdate'; + GUILD_MEMBER_AVAILABLE: 'guildMemberAvailable'; + GUILD_MEMBERS_CHUNK: 'guildMembersChunk'; + GUILD_INTEGRATIONS_UPDATE: 'guildIntegrationsUpdate'; + GUILD_ROLE_CREATE: 'roleCreate'; + GUILD_ROLE_DELETE: 'roleDelete'; + GUILD_ROLE_UPDATE: 'roleUpdate'; + GUILD_EMOJI_CREATE: 'emojiCreate'; + GUILD_EMOJI_DELETE: 'emojiDelete'; + GUILD_EMOJI_UPDATE: 'emojiUpdate'; + GUILD_BAN_ADD: 'guildBanAdd'; + GUILD_BAN_REMOVE: 'guildBanRemove'; + CHANNEL_CREATE: 'channelCreate'; + CHANNEL_DELETE: 'channelDelete'; + CHANNEL_UPDATE: 'channelUpdate'; + CHANNEL_PINS_UPDATE: 'channelPinsUpdate'; + MESSAGE_CREATE: 'messageCreate'; + MESSAGE_DELETE: 'messageDelete'; + MESSAGE_UPDATE: 'messageUpdate'; + MESSAGE_BULK_DELETE: 'messageDeleteBulk'; + MESSAGE_REACTION_ADD: 'messageReactionAdd'; + MESSAGE_REACTION_REMOVE: 'messageReactionRemove'; + MESSAGE_REACTION_REMOVE_ALL: 'messageReactionRemoveAll'; + MESSAGE_REACTION_REMOVE_EMOJI: 'messageReactionRemoveEmoji'; + THREAD_CREATE: 'threadCreate'; + THREAD_DELETE: 'threadDelete'; + THREAD_UPDATE: 'threadUpdate'; + THREAD_LIST_SYNC: 'threadListSync'; + THREAD_MEMBER_UPDATE: 'threadMemberUpdate'; + THREAD_MEMBERS_UPDATE: 'threadMembersUpdate'; + USER_UPDATE: 'userUpdate'; + PRESENCE_UPDATE: 'presenceUpdate'; + VOICE_SERVER_UPDATE: 'voiceServerUpdate'; + VOICE_STATE_UPDATE: 'voiceStateUpdate'; + TYPING_START: 'typingStart'; + WEBHOOKS_UPDATE: 'webhookUpdate'; + INTERACTION_CREATE: 'interactionCreate'; + ERROR: 'error'; + WARN: 'warn'; + DEBUG: 'debug'; + CACHE_SWEEP: 'cacheSweep'; + SHARD_DISCONNECT: 'shardDisconnect'; + SHARD_ERROR: 'shardError'; + SHARD_RECONNECTING: 'shardReconnecting'; + SHARD_READY: 'shardReady'; + SHARD_RESUME: 'shardResume'; + INVALIDATED: 'invalidated'; + RAW: 'raw'; + STAGE_INSTANCE_CREATE: 'stageInstanceCreate'; + STAGE_INSTANCE_UPDATE: 'stageInstanceUpdate'; + STAGE_INSTANCE_DELETE: 'stageInstanceDelete'; + GUILD_STICKER_CREATE: 'stickerCreate'; + GUILD_STICKER_DELETE: 'stickerDelete'; + GUILD_STICKER_UPDATE: 'stickerUpdate'; + GUILD_SCHEDULED_EVENT_CREATE: 'guildScheduledEventCreate'; + GUILD_SCHEDULED_EVENT_UPDATE: 'guildScheduledEventUpdate'; + GUILD_SCHEDULED_EVENT_DELETE: 'guildScheduledEventDelete'; + GUILD_SCHEDULED_EVENT_USER_ADD: 'guildScheduledEventUserAdd'; + GUILD_SCHEDULED_EVENT_USER_REMOVE: 'guildScheduledEventUserRemove'; +} + +export interface ConstantsOpcodes { + DISPATCH: 0; + HEARTBEAT: 1; + IDENTIFY: 2; + STATUS_UPDATE: 3; + VOICE_STATE_UPDATE: 4; + VOICE_GUILD_PING: 5; + RESUME: 6; + RECONNECT: 7; + REQUEST_GUILD_MEMBERS: 8; + INVALID_SESSION: 9; + HELLO: 10; + HEARTBEAT_ACK: 11; +} + +export interface ConstantsShardEvents { + CLOSE: 'close'; + DESTROYED: 'destroyed'; + INVALID_SESSION: 'invalidSession'; + READY: 'ready'; + RESUMED: 'resumed'; +} + +export interface ConstantsStatus { + READY: 0; + CONNECTING: 1; + RECONNECTING: 2; + IDLE: 3; + NEARLY: 4; + DISCONNECTED: 5; +} + +export interface CreateGuildScheduledEventInviteURLOptions extends CreateInviteOptions { + channel?: GuildInvitableChannelResolvable; +} + +export interface CreateRoleOptions extends RoleData { + reason?: string; +} + +export interface StageInstanceCreateOptions { + topic: string; + privacyLevel?: PrivacyLevel | number; +} + +export interface CrosspostedChannel { + channelId: Snowflake; + guildId: Snowflake; + type: keyof typeof ChannelTypes; + name: string; +} + +export type DateResolvable = Date | number | string; + +export interface DeconstructedSnowflake { + timestamp: number; + readonly date: Date; + workerId: number; + processId: number; + increment: number; + binary: string; +} + +export type DefaultMessageNotificationLevel = keyof typeof DefaultMessageNotificationLevels; + +export type DynamicImageFormat = AllowedImageFormat | 'gif'; + +export interface EditGuildTemplateOptions { + name?: string; + description?: string; +} + +export interface EmbedAuthorData { + name: string; + url?: string; + iconURL?: string; +} + +export interface EmbedField { + name: string; + value: string; + inline: boolean; +} + +export interface EmbedFieldData { + name: string; + value: string; + inline?: boolean; +} + +export type EmojiIdentifierResolvable = string | EmojiResolvable; + +export type EmojiResolvable = Snowflake | GuildEmoji | ReactionEmoji; + +export interface ErrorEvent { + error: unknown; + message: string; + type: string; + target: WebSocket; +} + +export interface EscapeMarkdownOptions { + codeBlock?: boolean; + inlineCode?: boolean; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + spoiler?: boolean; + inlineCodeContent?: boolean; + codeBlockContent?: boolean; +} + +export type ExplicitContentFilterLevel = keyof typeof ExplicitContentFilterLevels; + +export interface FetchApplicationCommandOptions extends BaseFetchOptions { + guildId?: Snowflake; +} + +export interface FetchArchivedThreadOptions { + type?: 'public' | 'private'; + fetchAll?: boolean; + before?: ThreadChannelResolvable | DateResolvable; + limit?: number; +} + +export interface FetchBanOptions extends BaseFetchOptions { + user: UserResolvable; +} + +export interface FetchBansOptions { + cache: boolean; +} + +export interface FetchChannelOptions extends BaseFetchOptions { + allowUnknownGuild?: boolean; +} + +export interface FetchedThreads { + threads: Collection; + hasMore?: boolean; +} + +export interface FetchGuildOptions extends BaseFetchOptions { + guild: GuildResolvable; + withCounts?: boolean; +} + +export interface FetchGuildsOptions { + before?: Snowflake; + after?: Snowflake; + limit?: number; +} + +export interface FetchGuildScheduledEventOptions extends BaseFetchOptions { + guildScheduledEvent: GuildScheduledEventResolvable; + withUserCount?: boolean; +} + +export interface FetchGuildScheduledEventsOptions { + cache?: boolean; + withUserCount?: boolean; +} + +export interface FetchGuildScheduledEventSubscribersOptions { + limit?: number; + withMember?: boolean; +} + +interface FetchInviteOptions extends BaseFetchOptions { + code: string; +} + +interface FetchInvitesOptions { + channelId?: GuildInvitableChannelResolvable; + cache?: boolean; +} + +export interface FetchMemberOptions extends BaseFetchOptions { + user: UserResolvable; +} + +export interface FetchMembersOptions { + user?: UserResolvable | UserResolvable[]; + query?: string; + limit?: number; + withPresences?: boolean; + time?: number; + nonce?: string; + force?: boolean; +} + +export interface FetchReactionUsersOptions { + limit?: number; + after?: Snowflake; +} + +export interface FetchThreadsOptions { + archived?: FetchArchivedThreadOptions; + active?: boolean; +} + +export interface FileOptions { + attachment: BufferResolvable | Stream; + name?: string; + description?: string; +} + +export type GlobalSweepFilter = () => ((value: V, key: K, collection: Collection) => boolean) | null; + +export interface GuildApplicationCommandPermissionData { + id: Snowflake; + permissions: ApplicationCommandPermissionData[]; +} + +interface GuildAuditLogsTypes { + GUILD_UPDATE: ['GUILD', 'UPDATE']; + CHANNEL_CREATE: ['CHANNEL', 'CREATE']; + CHANNEL_UPDATE: ['CHANNEL', 'UPDATE']; + CHANNEL_DELETE: ['CHANNEL', 'DELETE']; + CHANNEL_OVERWRITE_CREATE: ['CHANNEL', 'CREATE']; + CHANNEL_OVERWRITE_UPDATE: ['CHANNEL', 'UPDATE']; + CHANNEL_OVERWRITE_DELETE: ['CHANNEL', 'DELETE']; + MEMBER_KICK: ['USER', 'DELETE']; + MEMBER_PRUNE: ['USER', 'DELETE']; + MEMBER_BAN_ADD: ['USER', 'DELETE']; + MEMBER_BAN_REMOVE: ['USER', 'CREATE']; + MEMBER_UPDATE: ['USER', 'UPDATE']; + MEMBER_ROLE_UPDATE: ['USER', 'UPDATE']; + MEMBER_MOVE: ['USER', 'UPDATE']; + MEMBER_DISCONNECT: ['USER', 'DELETE']; + BOT_ADD: ['USER', 'CREATE']; + ROLE_CREATE: ['ROLE', 'CREATE']; + ROLE_UPDATE: ['ROLE', 'UPDATE']; + ROLE_DELETE: ['ROLE', 'DELETE']; + INVITE_CREATE: ['INVITE', 'CREATE']; + INVITE_UPDATE: ['INVITE', 'UPDATE']; + INVITE_DELETE: ['INVITE', 'DELETE']; + WEBHOOK_CREATE: ['WEBHOOK', 'CREATE']; + WEBHOOK_UPDATE: ['WEBHOOK', 'UPDATE']; + WEBHOOK_DELETE: ['WEBHOOK', 'DELETE']; + EMOJI_CREATE: ['EMOJI', 'CREATE']; + EMOJI_UPDATE: ['EMOJI', 'UPDATE']; + EMOJI_DELETE: ['EMOJI', 'DELETE']; + MESSAGE_DELETE: ['MESSAGE', 'DELETE']; + MESSAGE_BULK_DELETE: ['MESSAGE', 'DELETE']; + MESSAGE_PIN: ['MESSAGE', 'CREATE']; + MESSAGE_UNPIN: ['MESSAGE', 'DELETE']; + INTEGRATION_CREATE: ['INTEGRATION', 'CREATE']; + INTEGRATION_UPDATE: ['INTEGRATION', 'UPDATE']; + INTEGRATION_DELETE: ['INTEGRATION', 'DELETE']; + STAGE_INSTANCE_CREATE: ['STAGE_INSTANCE', 'CREATE']; + STAGE_INSTANCE_UPDATE: ['STAGE_INSTANCE', 'UPDATE']; + STAGE_INSTANCE_DELETE: ['STAGE_INSTANCE', 'DELETE']; + STICKER_CREATE: ['STICKER', 'CREATE']; + STICKER_UPDATE: ['STICKER', 'UPDATE']; + STICKER_DELETE: ['STICKER', 'DELETE']; + GUILD_SCHEDULED_EVENT_CREATE: ['GUILD_SCHEDULED_EVENT', 'CREATE']; + GUILD_SCHEDULED_EVENT_UPDATE: ['GUILD_SCHEDULED_EVENT', 'UPDATE']; + GUILD_SCHEDULED_EVENT_DELETE: ['GUILD_SCHEDULED_EVENT', 'DELETE']; + THREAD_CREATE: ['THREAD', 'CREATE']; + THREAD_UPDATE: ['THREAD', 'UPDATE']; + THREAD_DELETE: ['THREAD', 'DELETE']; +} + +export interface GuildAuditLogsIds { + 1: 'GUILD_UPDATE'; + 10: 'CHANNEL_CREATE'; + 11: 'CHANNEL_UPDATE'; + 12: 'CHANNEL_DELETE'; + 13: 'CHANNEL_OVERWRITE_CREATE'; + 14: 'CHANNEL_OVERWRITE_UPDATE'; + 15: 'CHANNEL_OVERWRITE_DELETE'; + 20: 'MEMBER_KICK'; + 21: 'MEMBER_PRUNE'; + 22: 'MEMBER_BAN_ADD'; + 23: 'MEMBER_BAN_REMOVE'; + 24: 'MEMBER_UPDATE'; + 25: 'MEMBER_ROLE_UPDATE'; + 26: 'MEMBER_MOVE'; + 27: 'MEMBER_DISCONNECT'; + 28: 'BOT_ADD'; + 30: 'ROLE_CREATE'; + 31: 'ROLE_UPDATE'; + 32: 'ROLE_DELETE'; + 40: 'INVITE_CREATE'; + 41: 'INVITE_UPDATE'; + 42: 'INVITE_DELETE'; + 50: 'WEBHOOK_CREATE'; + 51: 'WEBHOOK_UPDATE'; + 52: 'WEBHOOK_DELETE'; + 60: 'EMOJI_CREATE'; + 61: 'EMOJI_UPDATE'; + 62: 'EMOJI_DELETE'; + 72: 'MESSAGE_DELETE'; + 73: 'MESSAGE_BULK_DELETE'; + 74: 'MESSAGE_PIN'; + 75: 'MESSAGE_UNPIN'; + 80: 'INTEGRATION_CREATE'; + 81: 'INTEGRATION_UPDATE'; + 82: 'INTEGRATION_DELETE'; + 83: 'STAGE_INSTANCE_CREATE'; + 84: 'STAGE_INSTANCE_UPDATE'; + 85: 'STAGE_INSTANCE_DELETE'; + 90: 'STICKER_CREATE'; + 91: 'STICKER_UPDATE'; + 92: 'STICKER_DELETE'; + 100: 'GUILD_SCHEDULED_EVENT_CREATE'; + 101: 'GUILD_SCHEDULED_EVENT_UPDATE'; + 102: 'GUILD_SCHEDULED_EVENT_DELETE'; + 110: 'THREAD_CREATE'; + 111: 'THREAD_UPDATE'; + 112: 'THREAD_DELETE'; +} + +export type GuildAuditLogsActions = { [Key in keyof GuildAuditLogsIds as GuildAuditLogsIds[Key]]: Key } & { ALL: null }; + +export type GuildAuditLogsAction = keyof GuildAuditLogsActions; + +export type GuildAuditLogsActionType = GuildAuditLogsTypes[keyof GuildAuditLogsTypes][1] | 'ALL'; + +export interface GuildAuditLogsEntryExtraField { + MEMBER_PRUNE: { removed: number; days: number }; + MEMBER_MOVE: { channel: VoiceBasedChannel | { id: Snowflake }; count: number }; + MESSAGE_DELETE: { channel: GuildTextBasedChannel | { id: Snowflake }; count: number }; + MESSAGE_BULK_DELETE: { channel: GuildTextBasedChannel | { id: Snowflake }; count: number }; + MESSAGE_PIN: { channel: GuildTextBasedChannel | { id: Snowflake }; messageId: Snowflake }; + MESSAGE_UNPIN: { channel: GuildTextBasedChannel | { id: Snowflake }; messageId: Snowflake }; + MEMBER_DISCONNECT: { count: number }; + CHANNEL_OVERWRITE_CREATE: + | Role + | GuildMember + | { id: Snowflake; name: string; type: OverwriteTypes.role } + | { id: Snowflake; type: OverwriteTypes.member }; + CHANNEL_OVERWRITE_UPDATE: + | Role + | GuildMember + | { id: Snowflake; name: string; type: OverwriteTypes.role } + | { id: Snowflake; type: OverwriteTypes.member }; + CHANNEL_OVERWRITE_DELETE: + | Role + | GuildMember + | { id: Snowflake; name: string; type: OverwriteTypes.role } + | { id: Snowflake; type: OverwriteTypes.member }; + STAGE_INSTANCE_CREATE: StageChannel | { id: Snowflake }; + STAGE_INSTANCE_DELETE: StageChannel | { id: Snowflake }; + STAGE_INSTANCE_UPDATE: StageChannel | { id: Snowflake }; +} + +export interface GuildAuditLogsEntryTargetField { + USER: User | null; + GUILD: Guild; + WEBHOOK: Webhook; + INVITE: Invite; + MESSAGE: TActionType extends 'MESSAGE_BULK_DELETE' ? Guild | { id: Snowflake } : User; + INTEGRATION: Integration; + CHANNEL: NonThreadGuildBasedChannel | { id: Snowflake; [x: string]: unknown }; + THREAD: ThreadChannel | { id: Snowflake; [x: string]: unknown }; + STAGE_INSTANCE: StageInstance; + STICKER: Sticker; + GUILD_SCHEDULED_EVENT: GuildScheduledEvent; +} + +export interface GuildAuditLogsFetchOptions { + before?: Snowflake | GuildAuditLogsEntry; + limit?: number; + user?: UserResolvable; + type?: T; +} + +export type GuildAuditLogsResolvable = keyof GuildAuditLogsIds | GuildAuditLogsAction | null; + +export type GuildAuditLogsTarget = GuildAuditLogsTypes[keyof GuildAuditLogsTypes][0] | 'ALL' | 'UNKNOWN'; + +export type GuildAuditLogsTargets = { + [key in GuildAuditLogsTarget]?: string; +}; + +export type GuildBanResolvable = GuildBan | UserResolvable; + +export interface GuildChannelOverwriteOptions { + reason?: string; + type?: number; +} + +export type GuildChannelResolvable = Snowflake | GuildBasedChannel; + +export interface GuildChannelCreateOptions extends Omit { + parent?: CategoryChannelResolvable; + type?: ExcludeEnum< + typeof ChannelTypes, + 'DM' | 'GROUP_DM' | 'UNKNOWN' | 'GUILD_PUBLIC_THREAD' | 'GUILD_NEWS_THREAD' | 'GUILD_PRIVATE_THREAD' + >; +} + +export interface GuildChannelCloneOptions extends GuildChannelCreateOptions { + name?: string; +} + +export interface GuildChannelOverwriteOptions { + reason?: string; + type?: number; +} + +export interface GuildCreateOptions { + afkChannelId?: Snowflake | number; + afkTimeout?: number; + channels?: PartialChannelData[]; + defaultMessageNotifications?: DefaultMessageNotificationLevel | number; + explicitContentFilter?: ExplicitContentFilterLevel | number; + icon?: BufferResolvable | Base64Resolvable | null; + roles?: PartialRoleData[]; + systemChannelFlags?: SystemChannelFlagsResolvable; + systemChannelId?: Snowflake | number; + verificationLevel?: VerificationLevel | number; +} + +export interface GuildWidgetSettings { + enabled: boolean; + channel: NonThreadGuildBasedChannel | null; +} + +export interface GuildEditData { + name?: string; + verificationLevel?: VerificationLevel | number; + explicitContentFilter?: ExplicitContentFilterLevel | number; + defaultMessageNotifications?: DefaultMessageNotificationLevel | number; + afkChannel?: VoiceChannelResolvable; + systemChannel?: TextChannelResolvable; + systemChannelFlags?: SystemChannelFlagsResolvable; + afkTimeout?: number; + icon?: BufferResolvable | Base64Resolvable | null; + owner?: GuildMemberResolvable; + splash?: BufferResolvable | Base64Resolvable | null; + discoverySplash?: BufferResolvable | Base64Resolvable | null; + banner?: BufferResolvable | Base64Resolvable | null; + rulesChannel?: TextChannelResolvable; + publicUpdatesChannel?: TextChannelResolvable; + preferredLocale?: string; + premiumProgressBarEnabled?: boolean; + description?: string | null; + features?: GuildFeatures[]; +} + +export interface GuildEmojiCreateOptions { + roles?: Collection | RoleResolvable[]; + reason?: string; +} + +export interface GuildEmojiEditData { + name?: string; + roles?: Collection | RoleResolvable[]; +} + +export interface GuildStickerCreateOptions { + description?: string | null; + reason?: string; +} + +export interface GuildStickerEditData { + name?: string; + description?: string | null; + tags?: string; +} + +export type GuildFeatures = + | 'ANIMATED_ICON' + | 'BANNER' + | 'COMMERCE' + | 'COMMUNITY' + | 'DISCOVERABLE' + | 'FEATURABLE' + | 'INVITE_SPLASH' + | 'MEMBER_VERIFICATION_GATE_ENABLED' + | 'NEWS' + | 'PARTNERED' + | 'PREVIEW_ENABLED' + | 'VANITY_URL' + | 'VERIFIED' + | 'VIP_REGIONS' + | 'WELCOME_SCREEN_ENABLED' + | 'TICKETED_EVENTS_ENABLED' + | 'MONETIZATION_ENABLED' + | 'MORE_STICKERS' + | 'THREE_DAY_THREAD_ARCHIVE' + | 'SEVEN_DAY_THREAD_ARCHIVE' + | 'PRIVATE_THREADS' + | 'ROLE_ICONS'; + +export interface GuildMemberEditData { + nick?: string | null; + roles?: Collection | readonly RoleResolvable[]; + mute?: boolean; + deaf?: boolean; + channel?: GuildVoiceChannelResolvable | null; + communicationDisabledUntil?: DateResolvable | null; +} + +export type GuildMemberResolvable = GuildMember | UserResolvable; + +export type GuildResolvable = Guild | NonThreadGuildBasedChannel | GuildMember | GuildEmoji | Invite | Role | Snowflake; + +export interface GuildPruneMembersOptions { + count?: boolean; + days?: number; + dry?: boolean; + reason?: string; + roles?: RoleResolvable[]; +} + +export interface GuildWidgetSettingsData { + enabled: boolean; + channel: GuildChannelResolvable | null; +} + +export interface GuildSearchMembersOptions { + query: string; + limit?: number; + cache?: boolean; +} + +export interface GuildListMembersOptions { + after?: Snowflake; + limit?: number; + cache?: boolean; +} + +// TODO: use conditional types for better TS support +export interface GuildScheduledEventCreateOptions { + name: string; + scheduledStartTime: DateResolvable; + scheduledEndTime?: DateResolvable; + privacyLevel: GuildScheduledEventPrivacyLevel | number; + entityType: GuildScheduledEventEntityType | number; + description?: string; + channel?: GuildVoiceChannelResolvable; + entityMetadata?: GuildScheduledEventEntityMetadataOptions; + reason?: string; +} + +export interface GuildScheduledEventEditOptions< + S extends GuildScheduledEventStatus, + T extends GuildScheduledEventSetStatusArg, +> extends Omit, 'channel'> { + channel?: GuildVoiceChannelResolvable | null; + status?: T | number; +} + +export interface GuildScheduledEventEntityMetadata { + location: string | null; +} + +export interface GuildScheduledEventEntityMetadataOptions { + location?: string; +} + +export type GuildScheduledEventEntityType = keyof typeof GuildScheduledEventEntityTypes; + +export type GuildScheduledEventManagerFetchResult< + T extends GuildScheduledEventResolvable | FetchGuildScheduledEventOptions | FetchGuildScheduledEventsOptions, +> = T extends GuildScheduledEventResolvable | FetchGuildScheduledEventOptions + ? GuildScheduledEvent + : Collection; + +export type GuildScheduledEventManagerFetchSubscribersResult = + T extends { withMember: true } + ? Collection> + : Collection>; + +export type GuildScheduledEventPrivacyLevel = keyof typeof GuildScheduledEventPrivacyLevels; + +export type GuildScheduledEventResolvable = Snowflake | GuildScheduledEvent; + +export type GuildScheduledEventSetStatusArg = T extends 'SCHEDULED' + ? 'ACTIVE' | 'CANCELED' + : T extends 'ACTIVE' + ? 'COMPLETED' + : never; + +export type GuildScheduledEventStatus = keyof typeof GuildScheduledEventStatuses; + +export interface GuildScheduledEventUser { + guildScheduledEventId: Snowflake; + user: User; + member: T extends true ? GuildMember : null; +} + +export type GuildTemplateResolvable = string; + +export type GuildVoiceChannelResolvable = VoiceBasedChannel | Snowflake; + +export type HexColorString = `#${string}`; + +export interface HTTPAttachmentData { + attachment: string | Buffer | Stream; + name: string; + file: Buffer | Stream; +} + +export interface HTTPErrorData { + json: unknown; + files: HTTPAttachmentData[]; +} + +export interface HTTPOptions { + agent?: Omit; + api?: string; + version?: number; + host?: string; + cdn?: string; + invite?: string; + template?: string; + headers?: Record; + scheduledEvent?: string; +} + +export interface ImageURLOptions extends Omit { + dynamic?: boolean; + format?: DynamicImageFormat; +} + +export interface IntegrationAccount { + id: string | Snowflake; + name: string; +} + +export type IntegrationType = 'twitch' | 'youtube' | 'discord'; + +export interface InteractionCollectorOptions + extends CollectorOptions<[T]> { + channel?: TextBasedChannel; + componentType?: MessageComponentType | MessageComponentTypes; + guild?: Guild; + interactionType?: InteractionType | InteractionTypes; + max?: number; + maxComponents?: number; + maxUsers?: number; + message?: CacheTypeReducer; +} + +export interface InteractionDeferReplyOptions { + ephemeral?: boolean; + fetchReply?: boolean; +} + +export type InteractionDeferUpdateOptions = Omit; + +export interface InteractionReplyOptions extends Omit { + ephemeral?: boolean; + fetchReply?: boolean; +} + +export type InteractionResponseType = keyof typeof InteractionResponseTypes; + +export type InteractionType = keyof typeof InteractionTypes; + +export interface InteractionUpdateOptions extends MessageEditOptions { + fetchReply?: boolean; +} + +export type IntentsString = + | 'GUILDS' + | 'GUILD_MEMBERS' + | 'GUILD_BANS' + | 'GUILD_EMOJIS_AND_STICKERS' + | 'GUILD_INTEGRATIONS' + | 'GUILD_WEBHOOKS' + | 'GUILD_INVITES' + | 'GUILD_VOICE_STATES' + | 'GUILD_PRESENCES' + | 'GUILD_MESSAGES' + | 'GUILD_MESSAGE_REACTIONS' + | 'GUILD_MESSAGE_TYPING' + | 'DIRECT_MESSAGES' + | 'DIRECT_MESSAGE_REACTIONS' + | 'DIRECT_MESSAGE_TYPING' + | 'GUILD_SCHEDULED_EVENTS'; + +export interface InviteGenerationOptions { + permissions?: PermissionResolvable; + guild?: GuildResolvable; + disableGuildSelect?: boolean; + scopes: InviteScope[]; +} + +export type GuildInvitableChannelResolvable = + | TextChannel + | VoiceChannel + | NewsChannel + | StoreChannel + | StageChannel + | Snowflake; + +export interface CreateInviteOptions { + temporary?: boolean; + maxAge?: number; + maxUses?: number; + unique?: boolean; + reason?: string; + targetApplication?: ApplicationResolvable; + targetUser?: UserResolvable; + targetType?: InviteTargetType; +} + +export type IntegrationExpireBehaviors = 'REMOVE_ROLE' | 'KICK'; + +export type InviteResolvable = string; + +export type InviteScope = + | 'applications.builds.read' + | 'applications.commands' + | 'applications.entitlements' + | 'applications.store.update' + | 'bot' + | 'connections' + | 'email' + | 'identify' + | 'guilds' + | 'guilds.join' + | 'gdm.join' + | 'webhook.incoming'; + +export interface LifetimeFilterOptions { + excludeFromSweep?: (value: V, key: K, collection: LimitedCollection) => boolean; + getComparisonTimestamp?: (value: V, key: K, collection: LimitedCollection) => number; + lifetime?: number; +} + +export interface MakeErrorOptions { + name: string; + message: string; + stack: string; +} + +export type MemberMention = UserMention | `<@!${Snowflake}>`; + +export type MembershipState = keyof typeof MembershipStates; + +export type MessageActionRowComponent = MessageButton | MessageSelectMenu; + +export type MessageActionRowComponentOptions = + | (Required & MessageButtonOptions) + | (Required & MessageSelectMenuOptions); + +export type MessageActionRowComponentResolvable = MessageActionRowComponent | MessageActionRowComponentOptions; + +export interface MessageActionRowOptions extends BaseMessageComponentOptions { + components: MessageActionRowComponentResolvable[]; +} + +export interface MessageActivity { + partyId: string; + type: number; +} + +export interface BaseButtonOptions extends BaseMessageComponentOptions { + disabled?: boolean; + emoji?: EmojiIdentifierResolvable; + label?: string; +} + +export interface LinkButtonOptions extends BaseButtonOptions { + style: 'LINK' | MessageButtonStyles.LINK; + url: string; +} + +export interface InteractionButtonOptions extends BaseButtonOptions { + style: ExcludeEnum; + customId: string; +} + +export type MessageButtonOptions = InteractionButtonOptions | LinkButtonOptions; + +export type MessageButtonStyle = keyof typeof MessageButtonStyles; + +export type MessageButtonStyleResolvable = MessageButtonStyle | MessageButtonStyles; + +export interface MessageCollectorOptions extends CollectorOptions<[Message]> { + max?: number; + maxProcessed?: number; +} + +export type MessageComponent = BaseMessageComponent | MessageActionRow | MessageButton | MessageSelectMenu; + +export type MessageComponentCollectorOptions = Omit< + InteractionCollectorOptions, + 'channel' | 'message' | 'guild' | 'interactionType' +>; + +export type MessageChannelComponentCollectorOptions = Omit< + InteractionCollectorOptions, + 'channel' | 'guild' | 'interactionType' +>; + +export type MessageComponentOptions = + | BaseMessageComponentOptions + | MessageActionRowOptions + | MessageButtonOptions + | MessageSelectMenuOptions; + +export type MessageComponentType = keyof typeof MessageComponentTypes; + +export type MessageComponentTypeResolvable = MessageComponentType | MessageComponentTypes; + +export interface MessageEditOptions { + attachments?: MessageAttachment[]; + content?: string | null; + embeds?: (MessageEmbed | MessageEmbedOptions | APIEmbed)[] | null; + files?: (FileOptions | BufferResolvable | Stream | MessageAttachment)[]; + flags?: BitFieldResolvable; + allowedMentions?: MessageMentionOptions; + components?: (MessageActionRow | (Required & MessageActionRowOptions))[]; +} + +export interface MessageEmbedAuthor { + name: string; + url?: string; + iconURL?: string; + proxyIconURL?: string; +} + +export interface MessageEmbedFooter { + text: string; + iconURL?: string; + proxyIconURL?: string; +} + +export interface MessageEmbedImage { + url: string; + proxyURL?: string; + height?: number; + width?: number; +} + +export interface MessageEmbedOptions { + title?: string; + description?: string; + url?: string; + timestamp?: Date | number; + color?: ColorResolvable; + fields?: EmbedFieldData[]; + author?: Partial & { icon_url?: string; proxy_icon_url?: string }; + thumbnail?: Partial & { proxy_url?: string }; + image?: Partial & { proxy_url?: string }; + video?: Partial & { proxy_url?: string }; + footer?: Partial & { icon_url?: string; proxy_icon_url?: string }; +} + +export interface MessageEmbedProvider { + name: string; + url: string; +} + +export interface MessageEmbedThumbnail { + url: string; + proxyURL?: string; + height?: number; + width?: number; +} + +export interface MessageEmbedVideo { + url?: string; + proxyURL?: string; + height?: number; + width?: number; +} + +export interface MessageEvent { + data: WebSocket.Data; + type: string; + target: WebSocket; +} + +export type MessageFlagsString = + | 'CROSSPOSTED' + | 'IS_CROSSPOST' + | 'SUPPRESS_EMBEDS' + | 'SOURCE_MESSAGE_DELETED' + | 'URGENT' + | 'HAS_THREAD' + | 'EPHEMERAL' + | 'LOADING'; + +export interface MessageInteraction { + id: Snowflake; + type: InteractionType; + commandName: string; + user: User; +} + +export interface MessageMentionsHasOptions { + ignoreDirect?: boolean; + ignoreRoles?: boolean; + ignoreEveryone?: boolean; +} + +export interface MessageMentionOptions { + parse?: MessageMentionTypes[]; + roles?: Snowflake[]; + users?: Snowflake[]; + repliedUser?: boolean; +} + +export type MessageMentionTypes = 'roles' | 'users' | 'everyone'; + +export interface MessageOptions { + tts?: boolean; + nonce?: string | number; + content?: string | null; + embeds?: (MessageEmbed | MessageEmbedOptions | APIEmbed)[]; + components?: (MessageActionRow | (Required & MessageActionRowOptions))[]; + allowedMentions?: MessageMentionOptions; + files?: (FileOptions | BufferResolvable | Stream | MessageAttachment)[]; + reply?: ReplyOptions; + stickers?: StickerResolvable[]; + attachments?: MessageAttachment[]; +} + +export type MessageReactionResolvable = + | MessageReaction + | Snowflake + | `${string}:${Snowflake}` + | `<:${string}:${Snowflake}>` + | `` + | string; + +export interface MessageReference { + channelId: Snowflake; + guildId: Snowflake | undefined; + messageId: Snowflake | undefined; +} + +export type MessageResolvable = Message | Snowflake; + +export interface MessageSelectMenuOptions extends BaseMessageComponentOptions { + customId?: string; + disabled?: boolean; + maxValues?: number; + minValues?: number; + options?: MessageSelectOptionData[]; + placeholder?: string; +} + +export interface MessageSelectOption { + default: boolean; + description: string | null; + emoji: APIPartialEmoji | null; + label: string; + value: string; +} + +export interface MessageSelectOptionData { + default?: boolean; + description?: string; + emoji?: EmojiIdentifierResolvable; + label: string; + value: string; +} + +export type MessageTarget = + | Interaction + | InteractionWebhook + | TextBasedChannel + | User + | GuildMember + | Webhook + | WebhookClient + | Message + | MessageManager; + +export type MessageType = keyof typeof MessageTypes; + +export type MFALevel = keyof typeof MFALevels; + +export interface MultipleShardRespawnOptions { + shardDelay?: number; + respawnDelay?: number; + timeout?: number; +} + +export interface MultipleShardSpawnOptions { + amount?: number | 'auto'; + delay?: number; + timeout?: number; +} + +export type NSFWLevel = keyof typeof NSFWLevels; + +export interface OverwriteData { + allow?: PermissionResolvable; + deny?: PermissionResolvable; + id: GuildMemberResolvable | RoleResolvable; + type?: OverwriteType; +} + +export type OverwriteResolvable = PermissionOverwrites | OverwriteData; + +export type OverwriteType = 'member' | 'role'; + +export type PermissionFlags = Record; + +export type PermissionOverwriteOptions = Partial>; + +export type PermissionResolvable = BitFieldResolvable; + +export type PermissionOverwriteResolvable = UserResolvable | RoleResolvable | PermissionOverwrites; + +export type PermissionString = + | 'CREATE_INSTANT_INVITE' + | 'KICK_MEMBERS' + | 'BAN_MEMBERS' + | 'ADMINISTRATOR' + | 'MANAGE_CHANNELS' + | 'MANAGE_GUILD' + | 'ADD_REACTIONS' + | 'VIEW_AUDIT_LOG' + | 'PRIORITY_SPEAKER' + | 'STREAM' + | 'VIEW_CHANNEL' + | 'SEND_MESSAGES' + | 'SEND_TTS_MESSAGES' + | 'MANAGE_MESSAGES' + | 'EMBED_LINKS' + | 'ATTACH_FILES' + | 'READ_MESSAGE_HISTORY' + | 'MENTION_EVERYONE' + | 'USE_EXTERNAL_EMOJIS' + | 'VIEW_GUILD_INSIGHTS' + | 'CONNECT' + | 'SPEAK' + | 'MUTE_MEMBERS' + | 'DEAFEN_MEMBERS' + | 'MOVE_MEMBERS' + | 'USE_VAD' + | 'CHANGE_NICKNAME' + | 'MANAGE_NICKNAMES' + | 'MANAGE_ROLES' + | 'MANAGE_WEBHOOKS' + | 'MANAGE_EMOJIS_AND_STICKERS' + | 'USE_APPLICATION_COMMANDS' + | 'REQUEST_TO_SPEAK' + | 'MANAGE_THREADS' + | 'USE_PUBLIC_THREADS' + | 'CREATE_PUBLIC_THREADS' + | 'USE_PRIVATE_THREADS' + | 'CREATE_PRIVATE_THREADS' + | 'USE_EXTERNAL_STICKERS' + | 'SEND_MESSAGES_IN_THREADS' + | 'START_EMBEDDED_ACTIVITIES' + | 'MODERATE_MEMBERS' + | 'MANAGE_EVENTS'; + +export type RecursiveArray = ReadonlyArray>; + +export type RecursiveReadonlyArray = ReadonlyArray>; + +export interface PartialRecipient { + username: string; +} + +export type PremiumTier = keyof typeof PremiumTiers; + +export interface PresenceData { + status?: PresenceStatusData; + afk?: boolean; + activities?: ActivitiesOptions[]; + shardId?: number | number[]; +} + +export type PresenceResolvable = Presence | UserResolvable | Snowflake; + +export interface PartialChannelData { + id?: Snowflake | number; + parentId?: Snowflake | number; + type?: ExcludeEnum< + typeof ChannelTypes, + | 'DM' + | 'GROUP_DM' + | 'GUILD_NEWS' + | 'GUILD_STORE' + | 'UNKNOWN' + | 'GUILD_NEWS_THREAD' + | 'GUILD_PUBLIC_THREAD' + | 'GUILD_PRIVATE_THREAD' + | 'GUILD_STAGE_VOICE' + >; + name: string; + topic?: string; + nsfw?: boolean; + bitrate?: number; + userLimit?: number; + rtcRegion?: string | null; + permissionOverwrites?: PartialOverwriteData[]; + rateLimitPerUser?: number; +} + +export type Partialize< + T extends AllowedPartial, + N extends keyof T | null = null, + M extends keyof T | null = null, + E extends keyof T | '' = '', +> = { + readonly client: Client; + id: Snowflake; + partial: true; +} & { + [K in keyof Omit]: K extends N ? null : K extends M ? T[K] | null : T[K]; +}; + +export interface PartialDMChannel extends Partialize { + lastMessageId: undefined; +} + +export interface PartialGuildMember extends Partialize {} + +export interface PartialMessage + extends Partialize {} + +export interface PartialMessageReaction extends Partialize {} + +export interface PartialOverwriteData { + id: Snowflake | number; + type?: OverwriteType; + allow?: PermissionResolvable; + deny?: PermissionResolvable; +} + +export interface PartialRoleData extends RoleData { + id?: Snowflake | number; +} + +export type PartialTypes = 'USER' | 'CHANNEL' | 'GUILD_MEMBER' | 'MESSAGE' | 'REACTION' | 'GUILD_SCHEDULED_EVENT'; + +export interface PartialUser extends Partialize {} + +export type PresenceStatusData = ClientPresenceStatus | 'invisible'; + +export type PresenceStatus = PresenceStatusData | 'offline'; + +export type PrivacyLevel = keyof typeof PrivacyLevels; + +export type LocaleStrings = "DANISH" | "GERMAN" | "ENGLISH_UK" | "ENGLISH_US" | "SPANISH" | "FRENCH" | "CROATIAN" | "ITALIAN" | "LITHUANIAN" | "HUNGARIAN" | "DUTCH" | "NORWEGIAN" | "POLISH" | "BRAZILIAN_PORTUGUESE" | "ROMANIA_ROMANIAN" | "FINNISH" | "SWEDISH" | "VIETNAMESE" | "TURKISH" | "CZECH" | "GREEK" | "BULGARIAN" | "RUSSIAN" | "UKRAINIAN" | "HINDI" | "THAI" | "CHINA_CHINESE" | "JAPANESE" | "TAIWAN_CHINESE" | "KOREAN" +export interface RateLimitData { + timeout: number; + limit: number; + method: string; + path: string; + route: string; + global: boolean; +} + +export interface InvalidRequestWarningData { + count: number; + remainingTime: number; +} + +export interface ReactionCollectorOptions extends CollectorOptions<[MessageReaction, User]> { + max?: number; + maxEmojis?: number; + maxUsers?: number; +} + +export interface ReplyOptions { + messageReference: MessageResolvable; + failIfNotExists?: boolean; +} + +export interface ReplyMessageOptions extends Omit { + failIfNotExists?: boolean; +} + +export interface ResolvedOverwriteOptions { + allow: Permissions; + deny: Permissions; +} + +export interface RoleData { + name?: string; + color?: ColorResolvable; + hoist?: boolean; + position?: number; + permissions?: PermissionResolvable; + mentionable?: boolean; + icon?: BufferResolvable | Base64Resolvable | EmojiResolvable | null; + unicodeEmoji?: string | null; +} + +export type RoleMention = '@everyone' | `<@&${Snowflake}>`; + +export interface RolePosition { + role: RoleResolvable; + position: number; +} + +export type RoleResolvable = Role | Snowflake; + +export interface RoleTagData { + botId?: Snowflake; + integrationId?: Snowflake; + premiumSubscriberRole?: true; +} + +export interface SetChannelPositionOptions { + relative?: boolean; + reason?: string; +} + +export interface SetParentOptions { + lockPermissions?: boolean; + reason?: string; +} + +export interface SetRolePositionOptions { + relative?: boolean; + reason?: string; +} + +export type ShardingManagerMode = 'process' | 'worker'; + +export interface ShardingManagerOptions { + totalShards?: number | 'auto'; + shardList?: number[] | 'auto'; + mode?: ShardingManagerMode; + respawn?: boolean; + shardArgs?: string[]; + token?: string; + execArgv?: string[]; +} + +export { Snowflake }; + +export interface SplitOptions { + maxLength?: number; + char?: string | string[] | RegExp | RegExp[]; + prepend?: string; + append?: string; +} + +export interface StaticImageURLOptions { + format?: AllowedImageFormat; + size?: AllowedImageSize; +} + +export type StageInstanceResolvable = StageInstance | Snowflake; + +export interface StartThreadOptions { + name: string; + autoArchiveDuration?: ThreadAutoArchiveDuration; + reason?: string; + rateLimitPerUser?: number; +} + +export type Status = number; + +export type StickerFormatType = keyof typeof StickerFormatTypes; + +export type StickerResolvable = Sticker | Snowflake; + +export type StickerType = keyof typeof StickerTypes; + +export type SystemChannelFlagsString = + | 'SUPPRESS_JOIN_NOTIFICATIONS' + | 'SUPPRESS_PREMIUM_SUBSCRIPTIONS' + | 'SUPPRESS_GUILD_REMINDER_NOTIFICATIONS' + | 'SUPPRESS_JOIN_NOTIFICATION_REPLIES'; + +export type SystemChannelFlagsResolvable = BitFieldResolvable; + +export type SystemMessageType = Exclude< + MessageType, + 'DEFAULT' | 'REPLY' | 'APPLICATION_COMMAND' | 'CONTEXT_MENU_COMMAND' +>; + +export type StageChannelResolvable = StageChannel | Snowflake; + +export interface StageInstanceEditOptions { + topic?: string; + privacyLevel?: PrivacyLevel | number; +} + +export type SweeperKey = keyof SweeperDefinitions; + +export type CollectionSweepFilter = (value: V, key: K, collection: Collection) => boolean; + +export type SweepFilter = ( + collection: LimitedCollection, +) => ((value: V, key: K, collection: LimitedCollection) => boolean) | null; + +export interface SweepOptions { + interval: number; + filter: GlobalSweepFilter; +} + +export interface LifetimeSweepOptions { + interval: number; + lifetime: number; + filter?: never; +} + +export interface SweeperDefinitions { + applicationCommands: [Snowflake, ApplicationCommand]; + bans: [Snowflake, GuildBan]; + emojis: [Snowflake, GuildEmoji]; + invites: [string, Invite, true]; + guildMembers: [Snowflake, GuildMember]; + messages: [Snowflake, Message, true]; + presences: [Snowflake, Presence]; + reactions: [string | Snowflake, MessageReaction]; + stageInstances: [Snowflake, StageInstance]; + stickers: [Snowflake, Sticker]; + threadMembers: [Snowflake, ThreadMember]; + threads: [Snowflake, ThreadChannel, true]; + users: [Snowflake, User]; + voiceStates: [Snowflake, VoiceState]; +} + +export type SweeperOptions = { + [K in keyof SweeperDefinitions]?: SweeperDefinitions[K][2] extends true + ? SweepOptions | LifetimeSweepOptions + : SweepOptions; +}; + +export interface LimitedCollectionOptions { + maxSize?: number; + keepOverLimit?: (value: V, key: K, collection: LimitedCollection) => boolean; + /** @deprecated Use Global Sweepers instead */ + sweepFilter?: SweepFilter; + /** @deprecated Use Global Sweepers instead */ + sweepInterval?: number; +} + +export type AnyChannel = + | CategoryChannel + | DMChannel + | PartialDMChannel + | NewsChannel + | StageChannel + | StoreChannel + | TextChannel + | ThreadChannel + | VoiceChannel; + +export type TextBasedChannel = Extract; + +export type TextBasedChannelTypes = TextBasedChannel['type']; + +export type VoiceBasedChannel = Extract; + +export type GuildBasedChannel = Extract; + +export type NonThreadGuildBasedChannel = Exclude; + +export type GuildTextBasedChannel = Extract; + +export type TextChannelResolvable = Snowflake | TextChannel; + +export type ThreadAutoArchiveDuration = 60 | 1440 | 4320 | 10080 | 'MAX'; + +export type ThreadChannelResolvable = ThreadChannel | Snowflake; + +export type ThreadChannelTypes = 'GUILD_NEWS_THREAD' | 'GUILD_PUBLIC_THREAD' | 'GUILD_PRIVATE_THREAD'; + +export interface ThreadCreateOptions extends StartThreadOptions { + startMessage?: MessageResolvable; + type?: AllowedThreadType; + invitable?: AllowedThreadType extends 'GUILD_PRIVATE_THREAD' | 12 ? boolean : never; + rateLimitPerUser?: number; +} + +export interface ThreadEditData { + name?: string; + archived?: boolean; + autoArchiveDuration?: ThreadAutoArchiveDuration; + rateLimitPerUser?: number; + locked?: boolean; + invitable?: boolean; +} + +export type ThreadMemberFlagsString = ''; + +export type ThreadMemberResolvable = ThreadMember | UserResolvable; + +export type UserFlagsString = + | 'DISCORD_EMPLOYEE' + | 'PARTNERED_SERVER_OWNER' + | 'HYPESQUAD_EVENTS' + | 'BUGHUNTER_LEVEL_1' + | 'HOUSE_BRAVERY' + | 'HOUSE_BRILLIANCE' + | 'HOUSE_BALANCE' + | 'EARLY_SUPPORTER' + | 'TEAM_USER' + | 'BUGHUNTER_LEVEL_2' + | 'VERIFIED_BOT' + | 'EARLY_VERIFIED_BOT_DEVELOPER' + | 'DISCORD_CERTIFIED_MODERATOR' + | 'BOT_HTTP_INTERACTIONS'; + +export type UserMention = `<@${Snowflake}>`; + +export type UserResolvable = User | Snowflake | Message | GuildMember | ThreadMember; + +export interface Vanity { + code: string | null; + uses: number; +} + +export type VerificationLevel = keyof typeof VerificationLevels; + +export type VoiceBasedChannelTypes = VoiceBasedChannel['type']; + +export type VoiceChannelResolvable = Snowflake | VoiceChannel; + +export type WebhookClientData = WebhookClientDataIdWithToken | WebhookClientDataURL; + +export interface WebhookClientDataIdWithToken { + id: Snowflake; + token: string; +} + +export interface WebhookClientDataURL { + url: string; +} + +export type WebhookClientOptions = Pick< + ClientOptions, + 'allowedMentions' | 'restTimeOffset' | 'restRequestTimeout' | 'retryLimit' | 'http' +>; + +export interface WebhookEditData { + name?: string; + avatar?: BufferResolvable | null; + channel?: GuildTextChannelResolvable; +} + +export type WebhookEditMessageOptions = Pick< + WebhookMessageOptions, + 'content' | 'embeds' | 'files' | 'allowedMentions' | 'components' | 'attachments' | 'threadId' +>; + +export interface WebhookFetchMessageOptions { + cache?: boolean; + threadId?: Snowflake; +} + +export interface WebhookMessageOptions extends Omit { + username?: string; + avatarURL?: string; + threadId?: Snowflake; +} + +export type WebhookType = keyof typeof WebhookTypes; + +export interface WebSocketOptions { + large_threshold?: number; + compress?: boolean; + properties?: WebSocketProperties; +} + +export interface WebSocketProperties { + $os?: string; + $browser?: string; + $device?: string; +} + +export interface WidgetActivity { + name: string; +} + +export interface WidgetChannel { + id: Snowflake; + name: string; + position: number; +} + +export interface WelcomeChannelData { + description: string; + channel: TextChannel | NewsChannel | StoreChannel | Snowflake; + emoji?: EmojiIdentifierResolvable; +} + +export interface WelcomeScreenEditData { + enabled?: boolean; + description?: string; + welcomeChannels?: WelcomeChannelData[]; +} + +export type WSEventType = + | 'READY' + | 'RESUMED' + | 'APPLICATION_COMMAND_CREATE' + | 'APPLICATION_COMMAND_DELETE' + | 'APPLICATION_COMMAND_UPDATE' + | 'GUILD_CREATE' + | 'GUILD_DELETE' + | 'GUILD_UPDATE' + | 'INVITE_CREATE' + | 'INVITE_DELETE' + | 'GUILD_MEMBER_ADD' + | 'GUILD_MEMBER_REMOVE' + | 'GUILD_MEMBER_UPDATE' + | 'GUILD_MEMBERS_CHUNK' + | 'GUILD_ROLE_CREATE' + | 'GUILD_ROLE_DELETE' + | 'GUILD_ROLE_UPDATE' + | 'GUILD_BAN_ADD' + | 'GUILD_BAN_REMOVE' + | 'GUILD_EMOJIS_UPDATE' + | 'GUILD_INTEGRATIONS_UPDATE' + | 'CHANNEL_CREATE' + | 'CHANNEL_DELETE' + | 'CHANNEL_UPDATE' + | 'CHANNEL_PINS_UPDATE' + | 'MESSAGE_CREATE' + | 'MESSAGE_DELETE' + | 'MESSAGE_UPDATE' + | 'MESSAGE_DELETE_BULK' + | 'MESSAGE_REACTION_ADD' + | 'MESSAGE_REACTION_REMOVE' + | 'MESSAGE_REACTION_REMOVE_ALL' + | 'MESSAGE_REACTION_REMOVE_EMOJI' + | 'THREAD_CREATE' + | 'THREAD_UPDATE' + | 'THREAD_DELETE' + | 'THREAD_LIST_SYNC' + | 'THREAD_MEMBER_UPDATE' + | 'THREAD_MEMBERS_UPDATE' + | 'USER_UPDATE' + | 'PRESENCE_UPDATE' + | 'TYPING_START' + | 'VOICE_STATE_UPDATE' + | 'VOICE_SERVER_UPDATE' + | 'WEBHOOKS_UPDATE' + | 'INTERACTION_CREATE' + | 'STAGE_INSTANCE_CREATE' + | 'STAGE_INSTANCE_UPDATE' + | 'STAGE_INSTANCE_DELETE' + | 'GUILD_STICKERS_UPDATE'; + +export type Serialized = T extends symbol | bigint | (() => any) + ? never + : T extends number | string | boolean | undefined + ? T + : T extends { toJSON(): infer R } + ? R + : T extends ReadonlyArray + ? Serialized[] + : T extends ReadonlyMap | ReadonlySet + ? {} + : { [K in keyof T]: Serialized }; + +//#endregion + +//#region Voice + +/** + * @internal Use `DiscordGatewayAdapterLibraryMethods` from `@discordjs/voice` instead. + */ +export interface InternalDiscordGatewayAdapterLibraryMethods { + onVoiceServerUpdate(data: GatewayVoiceServerUpdateDispatchData): void; + onVoiceStateUpdate(data: GatewayVoiceStateUpdateDispatchData): void; + destroy(): void; +} + +/** + * @internal Use `DiscordGatewayAdapterImplementerMethods` from `@discordjs/voice` instead. + */ +export interface InternalDiscordGatewayAdapterImplementerMethods { + sendPayload(payload: unknown): boolean; + destroy(): void; +} + +/** + * @internal Use `DiscordGatewayAdapterCreator` from `@discordjs/voice` instead. + */ +export type InternalDiscordGatewayAdapterCreator = ( + methods: InternalDiscordGatewayAdapterLibraryMethods, +) => InternalDiscordGatewayAdapterImplementerMethods; + +//#endregion diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts new file mode 100644 index 00000000..ccfbc3a --- /dev/null +++ b/typings/index.test-d.ts @@ -0,0 +1,1252 @@ +import type { ChildProcess } from 'child_process'; +import type { + APIInteractionGuildMember, + APIMessage, + APIPartialChannel, + APIPartialGuild, + APIInteractionDataResolvedGuildMember, + APIInteractionDataResolvedChannel, + APIRole, + APIButtonComponent, + APISelectMenuComponent, +} from 'discord-api-types/v9'; +import { AuditLogEvent } from 'discord-api-types/v9'; +import { + ApplicationCommand, + ApplicationCommandData, + ApplicationCommandManager, + ApplicationCommandOptionData, + ApplicationCommandResolvable, + ApplicationCommandSubCommandData, + ApplicationCommandSubGroupData, + BaseCommandInteraction, + ButtonInteraction, + CacheType, + CategoryChannel, + Client, + ClientApplication, + ClientUser, + CloseEvent, + Collection, + CommandInteraction, + CommandInteractionOption, + CommandInteractionOptionResolver, + CommandOptionNonChoiceResolvableType, + Constants, + ContextMenuInteraction, + DMChannel, + Guild, + GuildApplicationCommandManager, + GuildChannelManager, + GuildEmoji, + GuildEmojiManager, + GuildMember, + GuildResolvable, + Intents, + Interaction, + InteractionCollector, + LimitedCollection, + Message, + MessageActionRow, + MessageAttachment, + MessageButton, + MessageCollector, + MessageComponentInteraction, + MessageEmbed, + MessageReaction, + NewsChannel, + Options, + PartialTextBasedChannelFields, + PartialUser, + Permissions, + ReactionCollector, + Role, + RoleManager, + SelectMenuInteraction, + Serialized, + ShardClientUtil, + ShardingManager, + Snowflake, + StageChannel, + StoreChannel, + TextBasedChannelFields, + TextBasedChannel, + TextBasedChannelTypes, + VoiceBasedChannel, + GuildBasedChannel, + NonThreadGuildBasedChannel, + GuildTextBasedChannel, + TextChannel, + ThreadChannel, + ThreadMember, + Typing, + User, + VoiceChannel, + Shard, + WebSocketShard, + Collector, + GuildAuditLogsEntry, + GuildAuditLogs, + StageInstance, + Sticker, + Emoji, + MessageActionRowComponent, + MessageSelectMenu, + PartialDMChannel, +} from '.'; +import type { ApplicationCommandOptionTypes } from './enums'; +import { expectAssignable, expectDeprecated, expectNotAssignable, expectNotType, expectType } from 'tsd'; + +// Test type transformation: +declare const serialize: (value: T) => Serialized; +declare const notPropertyOf: (value: T, property: P & Exclude) => void; + +const client: Client = new Client({ + intents: Intents.FLAGS.GUILDS, + makeCache: Options.cacheWithLimits({ + MessageManager: 200, + // @ts-expect-error + Message: 100, + ThreadManager: { + maxSize: 1000, + keepOverLimit: (x: ThreadChannel) => x.id === '123', + sweepInterval: 5000, + sweepFilter: LimitedCollection.filterByLifetime({ + getComparisonTimestamp: (x: ThreadChannel) => x.archiveTimestamp ?? 0, + excludeFromSweep: (x: ThreadChannel) => !x.archived, + }), + }, + }), +}); + +const testGuildId = '222078108977594368'; // DJS +const testUserId = '987654321098765432'; // example id +const globalCommandId = '123456789012345678'; // example id +const guildCommandId = '234567890123456789'; // example id + +client.on('ready', async () => { + console.log(`Client is logged in as ${client.user!.tag} and ready!`); + + // Test fetching all global commands and ones from one guild + expectType>>( + await client.application!.commands.fetch(), + ); + expectType>>( + await client.application!.commands.fetch({ guildId: testGuildId }), + ); + + // Test command manager methods + const globalCommand = await client.application?.commands.fetch(globalCommandId); + const guildCommandFromGlobal = await client.application?.commands.fetch(guildCommandId, { guildId: testGuildId }); + const guildCommandFromGuild = await client.guilds.cache.get(testGuildId)?.commands.fetch(guildCommandId); + + // @ts-expect-error + await client.guilds.cache.get(testGuildId)?.commands.fetch(guildCommandId, { guildId: testGuildId }); + + // Test command permissions + const globalPermissionsManager = client.application?.commands.permissions; + const guildPermissionsManager = client.guilds.cache.get(testGuildId)?.commands.permissions; + const originalPermissions = await client.application?.commands.permissions.fetch({ guild: testGuildId }); + + // Permissions from global manager + await globalPermissionsManager?.add({ + command: globalCommandId, + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + await globalPermissionsManager?.has({ command: globalCommandId, guild: testGuildId, permissionId: testGuildId }); + await globalPermissionsManager?.fetch({ guild: testGuildId }); + await globalPermissionsManager?.fetch({ command: globalCommandId, guild: testGuildId }); + await globalPermissionsManager?.remove({ command: globalCommandId, guild: testGuildId, roles: [testGuildId] }); + await globalPermissionsManager?.remove({ command: globalCommandId, guild: testGuildId, users: [testUserId] }); + await globalPermissionsManager?.remove({ + command: globalCommandId, + guild: testGuildId, + roles: [testGuildId], + users: [testUserId], + }); + await globalPermissionsManager?.set({ + command: globalCommandId, + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + await globalPermissionsManager?.set({ + guild: testGuildId, + fullPermissions: [{ id: globalCommandId, permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }], + }); + + // @ts-expect-error + await globalPermissionsManager?.add({ + command: globalCommandId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + // @ts-expect-error + await globalPermissionsManager?.has({ command: globalCommandId, permissionId: testGuildId }); + // @ts-expect-error + await globalPermissionsManager?.fetch(); + // @ts-expect-error + await globalPermissionsManager?.fetch({ command: globalCommandId }); + // @ts-expect-error + await globalPermissionsManager?.remove({ command: globalCommandId, roles: [testGuildId] }); + // @ts-expect-error + await globalPermissionsManager?.remove({ command: globalCommandId, users: [testUserId] }); + // @ts-expect-error + await globalPermissionsManager?.remove({ command: globalCommandId, roles: [testGuildId], users: [testUserId] }); + // @ts-expect-error + await globalPermissionsManager?.set({ + command: globalCommandId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + // @ts-expect-error + await globalPermissionsManager?.set({ + fullPermissions: [{ id: globalCommandId, permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }], + }); + // @ts-expect-error + await globalPermissionsManager?.set({ + command: globalCommandId, + guild: testGuildId, + fullPermissions: [{ id: globalCommandId, permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }], + }); + + // @ts-expect-error + await globalPermissionsManager?.add({ + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + // @ts-expect-error + await globalPermissionsManager?.has({ guild: testGuildId, permissionId: testGuildId }); + // @ts-expect-error + await globalPermissionsManager?.remove({ guild: testGuildId, roles: [testGuildId] }); + // @ts-expect-error + await globalPermissionsManager?.remove({ guild: testGuildId, users: [testUserId] }); + // @ts-expect-error + await globalPermissionsManager?.remove({ guild: testGuildId, roles: [testGuildId], users: [testUserId] }); + // @ts-expect-error + await globalPermissionsManager?.set({ + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + + // Permissions from guild manager + await guildPermissionsManager?.add({ + command: globalCommandId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + await guildPermissionsManager?.has({ command: globalCommandId, permissionId: testGuildId }); + await guildPermissionsManager?.fetch({}); + await guildPermissionsManager?.fetch({ command: globalCommandId }); + await guildPermissionsManager?.remove({ command: globalCommandId, roles: [testGuildId] }); + await guildPermissionsManager?.remove({ command: globalCommandId, users: [testUserId] }); + await guildPermissionsManager?.remove({ command: globalCommandId, roles: [testGuildId], users: [testUserId] }); + await guildPermissionsManager?.set({ + command: globalCommandId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + await guildPermissionsManager?.set({ + fullPermissions: [{ id: globalCommandId, permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }], + }); + + await guildPermissionsManager?.add({ + command: globalCommandId, + // @ts-expect-error + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + // @ts-expect-error + await guildPermissionsManager?.has({ command: globalCommandId, guild: testGuildId, permissionId: testGuildId }); + // @ts-expect-error + await guildPermissionsManager?.fetch({ guild: testGuildId }); + // @ts-expect-error + await guildPermissionsManager?.fetch({ command: globalCommandId, guild: testGuildId }); + // @ts-expect-error + await guildPermissionsManager?.remove({ command: globalCommandId, guild: testGuildId, roles: [testGuildId] }); + // @ts-expect-error + await guildPermissionsManager?.remove({ command: globalCommandId, guild: testGuildId, users: [testUserId] }); + await guildPermissionsManager?.remove({ + command: globalCommandId, + // @ts-expect-error + guild: testGuildId, + roles: [testGuildId], + users: [testUserId], + }); + // @ts-expect-error + await guildPermissionsManager?.set({ + command: globalCommandId, + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + await guildPermissionsManager?.set({ + // @ts-expect-error + guild: testGuildId, + fullPermissions: [{ id: globalCommandId, permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }], + }); + + // @ts-expect-error + await guildPermissionsManager?.add({ permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }); + // @ts-expect-error + await guildPermissionsManager?.has({ permissionId: testGuildId }); + // @ts-expect-error + await guildPermissionsManager?.remove({ roles: [testGuildId] }); + // @ts-expect-error + await guildPermissionsManager?.remove({ users: [testUserId] }); + // @ts-expect-error + await guildPermissionsManager?.remove({ roles: [testGuildId], users: [testUserId] }); + // @ts-expect-error + await guildPermissionsManager?.set({ permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }); + // @ts-expect-error + await guildPermissionsManager?.set({ + command: globalCommandId, + fullPermissions: [{ id: globalCommandId, permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }], + }); + + // Permissions from cached global ApplicationCommand + await globalCommand?.permissions.add({ + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + await globalCommand?.permissions.has({ guild: testGuildId, permissionId: testGuildId }); + await globalCommand?.permissions.fetch({ guild: testGuildId }); + await globalCommand?.permissions.remove({ guild: testGuildId, roles: [testGuildId] }); + await globalCommand?.permissions.remove({ guild: testGuildId, users: [testUserId] }); + await globalCommand?.permissions.remove({ guild: testGuildId, roles: [testGuildId], users: [testUserId] }); + await globalCommand?.permissions.set({ + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + + await globalCommand?.permissions.add({ + // @ts-expect-error + command: globalCommandId, + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + // @ts-expect-error + await globalCommand?.permissions.has({ command: globalCommandId, guild: testGuildId, permissionId: testGuildId }); + // @ts-expect-error + await globalCommand?.permissions.fetch({ command: globalCommandId, guild: testGuildId }); + // @ts-expect-error + await globalCommand?.permissions.remove({ command: globalCommandId, guild: testGuildId, roles: [testGuildId] }); + // @ts-expect-error + await globalCommand?.permissions.remove({ command: globalCommandId, guild: testGuildId, users: [testUserId] }); + await globalCommand?.permissions.remove({ + // @ts-expect-error + command: globalCommandId, + guild: testGuildId, + roles: [testGuildId], + users: [testUserId], + }); + await globalCommand?.permissions.set({ + // @ts-expect-error + command: globalCommandId, + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + + // @ts-expect-error + await globalCommand?.permissions.add({ permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }); + // @ts-expect-error + await globalCommand?.permissions.has({ permissionId: testGuildId }); + // @ts-expect-error + await globalCommand?.permissions.fetch({}); + // @ts-expect-error + await globalCommand?.permissions.remove({ roles: [testGuildId] }); + // @ts-expect-error + await globalCommand?.permissions.remove({ users: [testUserId] }); + // @ts-expect-error + await globalCommand?.permissions.remove({ roles: [testGuildId], users: [testUserId] }); + // @ts-expect-error + await globalCommand?.permissions.set({ permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }); + + // Permissions from cached guild ApplicationCommand + await guildCommandFromGlobal?.permissions.add({ permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }); + await guildCommandFromGlobal?.permissions.has({ permissionId: testGuildId }); + await guildCommandFromGlobal?.permissions.fetch({}); + await guildCommandFromGlobal?.permissions.remove({ roles: [testGuildId] }); + await guildCommandFromGlobal?.permissions.remove({ users: [testUserId] }); + await guildCommandFromGlobal?.permissions.remove({ roles: [testGuildId], users: [testUserId] }); + await guildCommandFromGlobal?.permissions.set({ permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }); + + await guildCommandFromGlobal?.permissions.add({ + // @ts-expect-error + command: globalCommandId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + // @ts-expect-error + await guildCommandFromGlobal?.permissions.has({ command: guildCommandId, permissionId: testGuildId }); + // @ts-expect-error + await guildCommandFromGlobal?.permissions.remove({ command: guildCommandId, roles: [testGuildId] }); + // @ts-expect-error + await guildCommandFromGlobal?.permissions.remove({ command: guildCommandId, users: [testUserId] }); + await guildCommandFromGlobal?.permissions.remove({ + // @ts-expect-error + command: guildCommandId, + roles: [testGuildId], + users: [testUserId], + }); + await guildCommandFromGlobal?.permissions.set({ + // @ts-expect-error + command: guildCommandId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + + await guildCommandFromGlobal?.permissions.add({ + // @ts-expect-error + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + // @ts-expect-error + await guildCommandFromGlobal?.permissions.has({ guild: testGuildId, permissionId: testGuildId }); + // @ts-expect-error + await guildCommandFromGlobal?.permissions.remove({ guild: testGuildId, roles: [testGuildId] }); + // @ts-expect-error + await guildCommandFromGlobal?.permissions.remove({ guild: testGuildId, users: [testUserId] }); + // @ts-expect-error + await guildCommandFromGlobal?.permissions.remove({ guild: testGuildId, roles: [testGuildId], users: [testUserId] }); + await guildCommandFromGlobal?.permissions.set({ + // @ts-expect-error + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + + await guildCommandFromGuild?.permissions.add({ permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }); + await guildCommandFromGuild?.permissions.has({ permissionId: testGuildId }); + await guildCommandFromGuild?.permissions.fetch({}); + await guildCommandFromGuild?.permissions.remove({ roles: [testGuildId] }); + await guildCommandFromGuild?.permissions.remove({ users: [testUserId] }); + await guildCommandFromGuild?.permissions.remove({ roles: [testGuildId], users: [testUserId] }); + await guildCommandFromGuild?.permissions.set({ permissions: [{ type: 'ROLE', id: testGuildId, permission: true }] }); + + await guildCommandFromGuild?.permissions.add({ + // @ts-expect-error + command: globalCommandId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + // @ts-expect-error + await guildCommandFromGuild?.permissions.has({ command: guildCommandId, permissionId: testGuildId }); + // @ts-expect-error + await guildCommandFromGuild?.permissions.remove({ command: guildCommandId, roles: [testGuildId] }); + // @ts-expect-error + await guildCommandFromGuild?.permissions.remove({ command: guildCommandId, users: [testUserId] }); + await guildCommandFromGuild?.permissions.remove({ + // @ts-expect-error + command: guildCommandId, + roles: [testGuildId], + users: [testUserId], + }); + await guildCommandFromGuild?.permissions.set({ + // @ts-expect-error + command: guildCommandId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + + await guildCommandFromGuild?.permissions.add({ + // @ts-expect-error + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + // @ts-expect-error + await guildCommandFromGuild?.permissions.has({ guild: testGuildId, permissionId: testGuildId }); + // @ts-expect-error + await guildCommandFromGuild?.permissions.remove({ guild: testGuildId, roles: [testGuildId] }); + // @ts-expect-error + await guildCommandFromGuild?.permissions.remove({ guild: testGuildId, users: [testUserId] }); + // @ts-expect-error + await guildCommandFromGuild?.permissions.remove({ guild: testGuildId, roles: [testGuildId], users: [testUserId] }); + await guildCommandFromGuild?.permissions.set({ + // @ts-expect-error + guild: testGuildId, + permissions: [{ type: 'ROLE', id: testGuildId, permission: true }], + }); + + client.application?.commands.permissions.set({ + guild: testGuildId, + fullPermissions: originalPermissions?.map((permissions, id) => ({ permissions, id })) ?? [], + }); +}); + +client.on('guildCreate', async g => { + const channel = g.channels.cache.random(); + if (!channel) return; + + if (channel.isThread()) { + const fetchedMember = await channel.members.fetch('12345678'); + expectType(fetchedMember); + const fetchedMemberCol = await channel.members.fetch(true); + expectDeprecated(await channel.members.fetch(true)); + expectType>(fetchedMemberCol); + } + + channel.setName('foo').then(updatedChannel => { + console.log(`New channel name: ${updatedChannel.name}`); + }); + + // @ts-expect-error no options + expectNotType>(g.members.add(testUserId)); + + // @ts-expect-error no access token + expectNotType>(g.members.add(testUserId, {})); + + expectNotType>( + // @ts-expect-error invalid role resolvable + g.members.add(testUserId, { accessToken: 'totallyRealAccessToken', roles: [g.roles.cache] }), + ); + + expectType>( + g.members.add(testUserId, { accessToken: 'totallyRealAccessToken', fetchWhenExisting: false }), + ); + + expectType>(g.members.add(testUserId, { accessToken: 'totallyRealAccessToken' })); + + expectType>( + g.members.add(testUserId, { + accessToken: 'totallyRealAccessToken', + mute: true, + deaf: false, + roles: [g.roles.cache.first()!], + force: true, + fetchWhenExisting: true, + }), + ); +}); + +client.on('messageReactionRemoveAll', async message => { + console.log(`messageReactionRemoveAll - id: ${message.id} (${message.id.length})`); + + if (message.partial) message = await message.fetch(); + + console.log(`messageReactionRemoveAll - content: ${message.content}`); +}); + +// This is to check that stuff is the right type +declare const assertIsMessage: (m: Promise) => void; + +client.on('messageCreate', async message => { + const { channel } = message; + assertIsMessage(channel.send('string')); + assertIsMessage(channel.send({})); + assertIsMessage(channel.send({ embeds: [] })); + + const attachment = new MessageAttachment('file.png'); + const embed = new MessageEmbed(); + assertIsMessage(channel.send({ files: [attachment] })); + assertIsMessage(channel.send({ embeds: [embed] })); + assertIsMessage(channel.send({ embeds: [embed], files: [attachment] })); + + if (message.inGuild()) { + expectAssignable>(message); + const component = await message.awaitMessageComponent({ componentType: 'BUTTON' }); + expectType>(component); + expectType>(await component.reply({ fetchReply: true })); + + const buttonCollector = message.createMessageComponentCollector({ componentType: 'BUTTON' }); + expectType>>(buttonCollector); + expectAssignable<(test: ButtonInteraction<'cached'>) => boolean | Promise>(buttonCollector.filter); + expectType(message.channel); + expectType(message.guild); + expectType(message.member); + } + + expectType(message.channel); + expectNotType(message.channel); + + // @ts-expect-error + channel.send(); + // @ts-expect-error + channel.send({ another: 'property' }); + + // Check collector creations. + + // Verify that buttons interactions are inferred. + const buttonCollector = message.createMessageComponentCollector({ componentType: 'BUTTON' }); + expectAssignable>(message.awaitMessageComponent({ componentType: 'BUTTON' })); + expectAssignable>(channel.awaitMessageComponent({ componentType: 'BUTTON' })); + expectAssignable>(buttonCollector); + + // Verify that select menus interaction are inferred. + const selectMenuCollector = message.createMessageComponentCollector({ componentType: 'SELECT_MENU' }); + expectAssignable>(message.awaitMessageComponent({ componentType: 'SELECT_MENU' })); + expectAssignable>(channel.awaitMessageComponent({ componentType: 'SELECT_MENU' })); + expectAssignable>(selectMenuCollector); + + // Verify that message component interactions are default collected types. + const defaultCollector = message.createMessageComponentCollector(); + expectAssignable>(message.awaitMessageComponent()); + expectAssignable>(channel.awaitMessageComponent()); + expectAssignable>(defaultCollector); + + // Verify that additional options don't affect default collector types. + const semiDefaultCollector = message.createMessageComponentCollector({ time: 10000 }); + expectType>(semiDefaultCollector); + const semiDefaultCollectorChannel = message.createMessageComponentCollector({ time: 10000 }); + expectType>(semiDefaultCollectorChannel); + + // Verify that interaction collector options can't be used. + + // @ts-expect-error + const interactionOptions = message.createMessageComponentCollector({ interactionType: 'APPLICATION_COMMAND' }); + + // Make sure filter parameters are properly inferred. + message.createMessageComponentCollector({ + filter: i => { + expectType(i); + return true; + }, + }); + + message.createMessageComponentCollector({ + componentType: 'BUTTON', + filter: i => { + expectType(i); + return true; + }, + }); + + message.createMessageComponentCollector({ + componentType: 'SELECT_MENU', + filter: i => { + expectType(i); + return true; + }, + }); + + message.awaitMessageComponent({ + filter: i => { + expectType(i); + return true; + }, + }); + + message.awaitMessageComponent({ + componentType: 'BUTTON', + filter: i => { + expectType(i); + return true; + }, + }); + + message.awaitMessageComponent({ + componentType: 'SELECT_MENU', + filter: i => { + expectType(i); + return true; + }, + }); + + const webhook = await message.fetchWebhook(); + + if (webhook.isChannelFollower()) { + expectAssignable(webhook.sourceGuild); + expectAssignable(webhook.sourceChannel); + } else if (webhook.isIncoming()) { + expectType(webhook.token); + } + + expectNotType(webhook.sourceGuild); + expectNotType(webhook.sourceChannel); + expectNotType(webhook.token); + + channel.awaitMessageComponent({ + filter: i => { + expectType>(i); + return true; + }, + }); + + channel.awaitMessageComponent({ + componentType: 'BUTTON', + filter: i => { + expectType>(i); + return true; + }, + }); + + channel.awaitMessageComponent({ + componentType: 'SELECT_MENU', + filter: i => { + expectType>(i); + return true; + }, + }); +}); + +client.on('interaction', async interaction => { + expectType(interaction.guildId); + expectType(interaction.channelId); + expectType(interaction.member); + + if (!interaction.isCommand()) return; + + void new MessageActionRow(); + + const button = new MessageButton(); + + const actionRow = new MessageActionRow({ components: [button] }); + + await interaction.reply({ content: 'Hi!', components: [actionRow] }); + + // @ts-expect-error + interaction.reply({ content: 'Hi!', components: [[button]] }); + + // @ts-expect-error + void new MessageActionRow({}); + + // @ts-expect-error + await interaction.reply({ content: 'Hi!', components: [button] }); + + if (interaction.isMessageComponent()) { + expectType(interaction.channelId); + } +}); + +client.login('absolutely-valid-token'); + +// Test client conditional types +client.on('ready', client => { + expectType>(client); +}); + +declare const loggedInClient: Client; +expectType(loggedInClient.application); +expectType(loggedInClient.readyAt); +expectType(loggedInClient.readyTimestamp); +expectType(loggedInClient.token); +expectType(loggedInClient.uptime); +expectType(loggedInClient.user); + +declare const loggedOutClient: Client; +expectType(loggedOutClient.application); +expectType(loggedOutClient.readyAt); +expectType(loggedOutClient.readyTimestamp); +expectType(loggedOutClient.token); +expectType(loggedOutClient.uptime); +expectType(loggedOutClient.user); + +expectType(serialize(undefined)); +expectType(serialize(null)); +expectType(serialize([1, 2, 3])); +expectType<{}>(serialize(new Set([1, 2, 3]))); +expectType<{}>( + serialize( + new Map([ + [1, '2'], + [2, '4'], + ]), + ), +); +expectType(serialize(new Permissions(Permissions.FLAGS.ATTACH_FILES))); +expectType(serialize(new Intents(Intents.FLAGS.GUILDS))); +expectAssignable( + serialize( + new Collection([ + [1, '2'], + [2, '4'], + ]), + ), +); +expectType(serialize(Symbol('a'))); +expectType(serialize(() => {})); +expectType(serialize(BigInt(42))); + +// Test type return of broadcastEval: +declare const shardClientUtil: ShardClientUtil; +declare const shardingManager: ShardingManager; + +expectType>(shardingManager.broadcastEval(() => 1)); +expectType>(shardClientUtil.broadcastEval(() => 1)); +expectType>(shardingManager.broadcastEval(async () => 1)); +expectType>(shardClientUtil.broadcastEval(async () => 1)); + +declare const dmChannel: DMChannel; +declare const threadChannel: ThreadChannel; +declare const newsChannel: NewsChannel; +declare const textChannel: TextChannel; +declare const storeChannel: StoreChannel; +declare const voiceChannel: VoiceChannel; +declare const guild: Guild; +declare const user: User; +declare const guildMember: GuildMember; + +// Test whether the structures implement send +expectType(dmChannel.send); +expectType(threadChannel); +expectType(newsChannel); +expectType(textChannel); +expectAssignable(user); +expectAssignable(guildMember); + +expectType(dmChannel.lastMessage); +expectType(threadChannel.lastMessage); +expectType(newsChannel.lastMessage); +expectType(textChannel.lastMessage); + +expectDeprecated(storeChannel.clone()); +expectDeprecated(categoryChannel.createChannel('Store', { type: 'GUILD_STORE' })); +expectDeprecated(guild.channels.create('Store', { type: 'GUILD_STORE' })); + +notPropertyOf(user, 'lastMessage'); +notPropertyOf(user, 'lastMessageId'); +notPropertyOf(guildMember, 'lastMessage'); +notPropertyOf(guildMember, 'lastMessageId'); + +// Test collector event parameters +declare const messageCollector: MessageCollector; +messageCollector.on('collect', (...args) => { + expectType<[Message]>(args); +}); + +declare const reactionCollector: ReactionCollector; +reactionCollector.on('dispose', (...args) => { + expectType<[MessageReaction, User]>(args); +}); + +// Make sure the properties are typed correctly, and that no backwards properties +// (K -> V and V -> K) exist: +expectType<'messageCreate'>(Constants.Events.MESSAGE_CREATE); +expectType<'close'>(Constants.ShardEvents.CLOSE); +expectType<1>(Constants.Status.CONNECTING); +expectType<0>(Constants.Opcodes.DISPATCH); +expectType<2>(Constants.ClientApplicationAssetTypes.BIG); + +declare const applicationCommandData: ApplicationCommandData; +declare const applicationCommandResolvable: ApplicationCommandResolvable; +declare const applicationCommandManager: ApplicationCommandManager; +{ + type ApplicationCommandScope = ApplicationCommand<{ guild: GuildResolvable }>; + + expectType>(applicationCommandManager.create(applicationCommandData)); + expectAssignable>(applicationCommandManager.create(applicationCommandData, '0')); + expectType>( + applicationCommandManager.edit(applicationCommandResolvable, applicationCommandData), + ); + expectType>( + applicationCommandManager.edit(applicationCommandResolvable, applicationCommandData, '0'), + ); + expectType>>( + applicationCommandManager.set([applicationCommandData]), + ); + expectType>>( + applicationCommandManager.set([applicationCommandData], '0'), + ); +} + +declare const applicationNonChoiceOptionData: ApplicationCommandOptionData & { + type: CommandOptionNonChoiceResolvableType; +}; +{ + // Options aren't allowed on this command type. + + // @ts-expect-error + applicationNonChoiceOptionData.choices; +} + +declare const applicationSubGroupCommandData: ApplicationCommandSubGroupData; +{ + expectType<'SUB_COMMAND_GROUP' | ApplicationCommandOptionTypes.SUB_COMMAND_GROUP>( + applicationSubGroupCommandData.type, + ); + expectType(applicationSubGroupCommandData.options); +} + +declare const guildApplicationCommandManager: GuildApplicationCommandManager; +expectType>>(guildApplicationCommandManager.fetch()); +expectType>>(guildApplicationCommandManager.fetch(undefined, {})); +expectType>(guildApplicationCommandManager.fetch('0')); + +declare const categoryChannel: CategoryChannel; +{ + expectType>(categoryChannel.createChannel('name', { type: 'GUILD_VOICE' })); + expectType>(categoryChannel.createChannel('name', { type: 'GUILD_TEXT' })); + expectType>(categoryChannel.createChannel('name', { type: 'GUILD_NEWS' })); + expectType>(categoryChannel.createChannel('name', { type: 'GUILD_STORE' })); + expectType>(categoryChannel.createChannel('name', { type: 'GUILD_STAGE_VOICE' })); + expectType>(categoryChannel.createChannel('name', {})); + expectType>(categoryChannel.createChannel('name')); +} + +declare const guildChannelManager: GuildChannelManager; +{ + type AnyChannel = TextChannel | VoiceChannel | CategoryChannel | NewsChannel | StoreChannel | StageChannel; + + expectType>(guildChannelManager.create('name', { type: 'GUILD_VOICE' })); + expectType>(guildChannelManager.create('name', { type: 'GUILD_CATEGORY' })); + expectType>(guildChannelManager.create('name', { type: 'GUILD_TEXT' })); + expectType>(guildChannelManager.create('name', { type: 'GUILD_NEWS' })); + expectType>(guildChannelManager.create('name', { type: 'GUILD_STORE' })); + expectType>(guildChannelManager.create('name', { type: 'GUILD_STAGE_VOICE' })); + + expectType>>(guildChannelManager.fetch()); + expectType>>(guildChannelManager.fetch(undefined, {})); + expectType>(guildChannelManager.fetch('0')); +} + +declare const roleManager: RoleManager; +expectType>>(roleManager.fetch()); +expectType>>(roleManager.fetch(undefined, {})); +expectType>(roleManager.fetch('0')); + +declare const guildEmojiManager: GuildEmojiManager; +expectType>>(guildEmojiManager.fetch()); +expectType>>(guildEmojiManager.fetch(undefined, {})); +expectType>(guildEmojiManager.fetch('0')); + +declare const typing: Typing; +expectType(typing.user); +if (typing.user.partial) expectType(typing.user.username); + +expectType(typing.channel); +if (typing.channel.partial) expectType(typing.channel.lastMessageId); + +expectType(typing.member); +expectType(typing.guild); + +if (typing.inGuild()) { + expectType(typing.channel.guild); + expectType(typing.guild); +} + +// Test partials structures +client.on('guildMemberRemove', member => { + if (member.partial) return expectType(member.joinedAt); + expectType(member.joinedAt); +}); + +client.on('messageReactionAdd', async reaction => { + if (reaction.partial) { + expectType(reaction.count); + reaction = await reaction.fetch(); + } + expectType(reaction.count); + if (reaction.message.partial) return expectType(reaction.message.content); + expectType(reaction.message.content); +}); + +// Test .deleted deprecations +declare const emoji: Emoji; +declare const message: Message; +declare const role: Role; +declare const stageInstance: StageInstance; +declare const sticker: Sticker; +expectDeprecated(dmChannel.deleted); +expectDeprecated(textChannel.deleted); +expectDeprecated(voiceChannel.deleted); +expectDeprecated(newsChannel.deleted); +expectDeprecated(threadChannel.deleted); +expectDeprecated(emoji.deleted); +expectDeprecated(guildMember.deleted); +expectDeprecated(guild.deleted); +expectDeprecated(message.deleted); +expectDeprecated(role.deleted); +expectDeprecated(stageInstance.deleted); +expectDeprecated(sticker.deleted); + +// Test interactions +declare const interaction: Interaction; +declare const booleanValue: boolean; +if (interaction.inGuild()) expectType(interaction.guildId); + +client.on('interactionCreate', async interaction => { + if (interaction.inCachedGuild()) { + expectAssignable(interaction.member); + expectNotType>(interaction); + expectAssignable(interaction); + } else if (interaction.inRawGuild()) { + expectAssignable(interaction.member); + expectNotAssignable>(interaction); + } else { + expectType(interaction.member); + expectNotAssignable>(interaction); + } + + if (interaction.isContextMenu()) { + expectType(interaction); + if (interaction.inCachedGuild()) { + expectAssignable(interaction); + expectAssignable(interaction.guild); + expectAssignable>(interaction); + } else if (interaction.inRawGuild()) { + expectAssignable(interaction); + expectType(interaction.guild); + } else if (interaction.inGuild()) { + expectAssignable(interaction); + expectType(interaction.guild); + } + } + + if (interaction.isMessageContextMenu()) { + expectType(interaction.targetMessage); + if (interaction.inCachedGuild()) { + expectType>(interaction.targetMessage); + } else if (interaction.inRawGuild()) { + expectType(interaction.targetMessage); + } else if (interaction.inGuild()) { + expectType(interaction.targetMessage); + } + } + + if (interaction.isButton()) { + expectType(interaction); + expectType(interaction.component); + expectType(interaction.message); + if (interaction.inCachedGuild()) { + expectAssignable(interaction); + expectType(interaction.component); + expectType>(interaction.message); + expectType(interaction.guild); + expectAssignable>(interaction.reply({ fetchReply: true })); + } else if (interaction.inRawGuild()) { + expectAssignable(interaction); + expectType(interaction.component); + expectType(interaction.message); + expectType(interaction.guild); + expectType>(interaction.reply({ fetchReply: true })); + } else if (interaction.inGuild()) { + expectAssignable(interaction); + expectType(interaction.component); + expectType(interaction.message); + expectAssignable(interaction.guild); + expectType>(interaction.reply({ fetchReply: true })); + } + } + + if (interaction.isMessageComponent()) { + expectType(interaction); + expectType(interaction.component); + expectType(interaction.message); + if (interaction.inCachedGuild()) { + expectAssignable(interaction); + expectType(interaction.component); + expectType>(interaction.message); + expectType(interaction.guild); + expectAssignable>(interaction.reply({ fetchReply: true })); + } else if (interaction.inRawGuild()) { + expectAssignable(interaction); + expectType(interaction.component); + expectType(interaction.message); + expectType(interaction.guild); + expectType>(interaction.reply({ fetchReply: true })); + } else if (interaction.inGuild()) { + expectAssignable(interaction); + expectType(interaction.component); + expectType(interaction.message); + expectType(interaction.guild); + expectType>(interaction.reply({ fetchReply: true })); + } + } + + if (interaction.isSelectMenu()) { + expectType(interaction); + expectType(interaction.component); + expectType(interaction.message); + if (interaction.inCachedGuild()) { + expectAssignable(interaction); + expectType(interaction.component); + expectType>(interaction.message); + expectType(interaction.guild); + expectType>>(interaction.reply({ fetchReply: true })); + } else if (interaction.inRawGuild()) { + expectAssignable(interaction); + expectType(interaction.component); + expectType(interaction.message); + expectType(interaction.guild); + expectType>(interaction.reply({ fetchReply: true })); + } else if (interaction.inGuild()) { + expectAssignable(interaction); + expectType(interaction.component); + expectType(interaction.message); + expectType(interaction.guild); + expectType>(interaction.reply({ fetchReply: true })); + } + } + + if (interaction.isCommand()) { + if (interaction.inRawGuild()) { + expectNotAssignable>(interaction); + expectAssignable(interaction); + expectType>(interaction.reply({ fetchReply: true })); + expectType(interaction.options.getMember('test')); + expectType(interaction.options.getMember('test', true)); + + expectType(interaction.options.getChannel('test', true)); + expectType(interaction.options.getRole('test', true)); + } else if (interaction.inCachedGuild()) { + const msg = await interaction.reply({ fetchReply: true }); + const btn = await msg.awaitMessageComponent({ componentType: 'BUTTON' }); + + expectType>(msg); + expectType>(btn); + + expectType(interaction.options.getMember('test')); + expectAssignable(interaction); + expectType>>(interaction.reply({ fetchReply: true })); + + expectType(interaction.options.getChannel('test', true)); + expectType(interaction.options.getRole('test', true)); + } else { + // @ts-expect-error + consumeCachedCommand(interaction); + expectType(interaction); + expectType>(interaction.reply({ fetchReply: true })); + expectType(interaction.options.getMember('test')); + expectType(interaction.options.getMember('test', true)); + + expectType(interaction.options.getChannel('test', true)); + expectType(interaction.options.getRole('test', true)); + } + + expectType(interaction); + expectType, 'getFocused' | 'getMessage'>>(interaction.options); + expectType(interaction.options.data); + + const optionalOption = interaction.options.get('name'); + const requiredOption = interaction.options.get('name', true); + expectType(optionalOption); + expectType(requiredOption); + expectType(requiredOption.options); + + expectType(interaction.options.getString('name', booleanValue)); + expectType(interaction.options.getString('name', false)); + expectType(interaction.options.getString('name', true)); + + expectType(interaction.options.getSubcommand()); + expectType(interaction.options.getSubcommand(true)); + expectType(interaction.options.getSubcommand(booleanValue)); + expectType(interaction.options.getSubcommand(false)); + + expectType(interaction.options.getSubcommandGroup()); + expectType(interaction.options.getSubcommandGroup(true)); + expectType(interaction.options.getSubcommandGroup(booleanValue)); + expectType(interaction.options.getSubcommandGroup(false)); + } +}); + +declare const shard: Shard; + +shard.on('death', process => { + expectType(process); +}); + +declare const webSocketShard: WebSocketShard; + +webSocketShard.on('close', event => { + expectType(event); +}); + +declare const collector: Collector; + +collector.on('collect', (collected, ...other) => { + expectType(collected); + expectType(other); +}); + +collector.on('dispose', (vals, ...other) => { + expectType(vals); + expectType(other); +}); + +collector.on('end', (collection, reason) => { + expectType>(collection); + expectType(reason); +}); + +expectType>(shard.eval(c => c.readyTimestamp)); + +// Test audit logs +expectType>>(guild.fetchAuditLogs({ type: 'MEMBER_KICK' })); +expectAssignable>>( + guild.fetchAuditLogs({ type: GuildAuditLogs.Actions.MEMBER_KICK }), +); +expectType>>(guild.fetchAuditLogs({ type: AuditLogEvent.MemberKick })); + +expectType>>(guild.fetchAuditLogs({ type: 'CHANNEL_CREATE' })); +expectAssignable>>( + guild.fetchAuditLogs({ type: GuildAuditLogs.Actions.CHANNEL_CREATE }), +); +expectType>>( + guild.fetchAuditLogs({ type: AuditLogEvent.ChannelCreate }), +); + +expectType>>(guild.fetchAuditLogs({ type: 'INTEGRATION_UPDATE' })); +expectAssignable>>( + guild.fetchAuditLogs({ type: GuildAuditLogs.Actions.INTEGRATION_UPDATE }), +); +expectType>>( + guild.fetchAuditLogs({ type: AuditLogEvent.IntegrationUpdate }), +); + +expectType>>(guild.fetchAuditLogs({ type: 'ALL' })); +expectType>>(guild.fetchAuditLogs({ type: GuildAuditLogs.Actions.ALL })); +expectType>>(guild.fetchAuditLogs()); + +expectType | undefined>>( + guild.fetchAuditLogs({ type: 'MEMBER_KICK' }).then(al => al.entries.first()), +); +expectType | undefined>>( + guild.fetchAuditLogs({ type: GuildAuditLogs.Actions.MEMBER_KICK }).then(al => al.entries.first()), +); +expectAssignable | undefined>>( + guild.fetchAuditLogs({ type: AuditLogEvent.MemberKick }).then(al => al.entries.first()), +); + +expectType | undefined>>( + guild.fetchAuditLogs({ type: 'ALL' }).then(al => al.entries.first()), +); +expectType | undefined>>( + guild.fetchAuditLogs({ type: GuildAuditLogs.Actions.ALL }).then(al => al.entries.first()), +); +expectType | undefined>>( + guild.fetchAuditLogs({ type: null }).then(al => al.entries.first()), +); +expectType | undefined>>( + guild.fetchAuditLogs().then(al => al.entries.first()), +); + +expectType>( + guild.fetchAuditLogs({ type: 'MEMBER_KICK' }).then(al => al.entries.first()?.extra), +); +expectType>( + guild.fetchAuditLogs({ type: AuditLogEvent.MemberKick }).then(al => al.entries.first()?.extra), +); +expectType>( + guild.fetchAuditLogs({ type: 'STAGE_INSTANCE_CREATE' }).then(al => al.entries.first()?.extra), +); +expectType>( + guild.fetchAuditLogs({ type: 'MESSAGE_DELETE' }).then(al => al.entries.first()?.extra), +); + +expectType>( + guild.fetchAuditLogs({ type: 'MEMBER_KICK' }).then(al => al.entries.first()?.target), +); +expectType>( + guild.fetchAuditLogs({ type: AuditLogEvent.MemberKick }).then(al => al.entries.first()?.target), +); +expectType>( + guild.fetchAuditLogs({ type: 'STAGE_INSTANCE_CREATE' }).then(al => al.entries.first()?.target), +); +expectType>( + guild.fetchAuditLogs({ type: 'MESSAGE_DELETE' }).then(al => al.entries.first()?.target), +); + +expectType>( + // @ts-expect-error Invalid audit log ID + guild.fetchAuditLogs({ type: 2000 }).then(al => al.entries.first()?.target), +); + +declare const TextBasedChannel: TextBasedChannel; +declare const TextBasedChannelTypes: TextBasedChannelTypes; +declare const VoiceBasedChannel: VoiceBasedChannel; +declare const GuildBasedChannel: GuildBasedChannel; +declare const NonThreadGuildBasedChannel: NonThreadGuildBasedChannel; +declare const GuildTextBasedChannel: GuildTextBasedChannel; + +expectType(TextBasedChannel); +expectType<'DM' | 'GUILD_NEWS' | 'GUILD_TEXT' | 'GUILD_PUBLIC_THREAD' | 'GUILD_PRIVATE_THREAD' | 'GUILD_NEWS_THREAD'>( + TextBasedChannelTypes, +); +expectType(VoiceBasedChannel); +expectType( + GuildBasedChannel, +); +expectType( + NonThreadGuildBasedChannel, +); +expectType(GuildTextBasedChannel); diff --git a/typings/rawDataTypes.d.ts b/typings/rawDataTypes.d.ts new file mode 100644 index 00000000..d7a9ec7 --- /dev/null +++ b/typings/rawDataTypes.d.ts @@ -0,0 +1,210 @@ +// These are aggregate types that are used in the typings file but do not exist as actual exported values. +// To prevent them from showing up in an editor, they are imported from here instead of exporting them there directly. + +import { + APIApplication, + APIApplicationCommand, + APIApplicationCommandInteraction, + APIAttachment, + APIAuditLog, + APIAuditLogEntry, + APIBan, + APIChannel, + APIEmoji, + APIExtendedInvite, + APIGuild, + APIGuildIntegration, + APIGuildIntegrationApplication, + APIGuildMember, + APIGuildPreview, + APIGuildWelcomeScreen, + APIGuildWelcomeScreenChannel, + APIGuildWidget, + APIGuildWidgetMember, + APIInteractionDataResolvedChannel, + APIInteractionDataResolvedGuildMember, + APIInteractionGuildMember, + APIInvite, + APIInviteStageInstance, + APIMessage, + APIMessageButtonInteractionData, + APIMessageComponentInteraction, + APIMessageSelectMenuInteractionData, + APIOverwrite, + APIPartialChannel, + APIPartialEmoji, + APIPartialGuild, + APIReaction, + APIRole, + APIStageInstance, + APISticker, + APIStickerItem, + APIStickerPack, + APITeam, + APITeamMember, + APITemplate, + APIThreadMember, + APIUnavailableGuild, + APIUser, + APIVoiceRegion, + APIWebhook, + GatewayActivity, + GatewayActivityAssets, + GatewayActivityEmoji, + GatewayGuildBanAddDispatchData, + GatewayGuildMemberAddDispatchData, + GatewayGuildMemberUpdateDispatchData, + GatewayInteractionCreateDispatchData, + GatewayInviteCreateDispatchData, + GatewayInviteDeleteDispatchData, + GatewayMessageReactionAddDispatchData, + GatewayMessageUpdateDispatchData, + GatewayPresenceUpdate, + GatewayReadyDispatchData, + GatewayTypingStartDispatchData, + GatewayVoiceState, + RESTAPIPartialCurrentUserGuild, + RESTGetAPIWebhookWithTokenResult, + RESTPatchAPIChannelMessageJSONBody, + RESTPatchAPICurrentGuildMemberNicknameJSONBody, + RESTPatchAPIInteractionFollowupJSONBody, + RESTPatchAPIInteractionOriginalResponseJSONBody, + RESTPatchAPIWebhookWithTokenJSONBody, + RESTPostAPIChannelMessageJSONBody, + RESTPostAPIInteractionCallbackFormDataBody, + RESTPostAPIInteractionFollowupJSONBody, + RESTPostAPIWebhookWithTokenJSONBody, + Snowflake, + APIGuildScheduledEvent +} from 'discord-api-types/v9'; +import { GuildChannel, Guild, PermissionOverwrites } from '.'; + +export type RawActivityData = GatewayActivity; + +export type RawApplicationData = RawClientApplicationData | RawIntegrationApplicationData; +export type RawClientApplicationData = GatewayReadyDispatchData['application'] | APIMessage['application']; +export type RawIntegrationApplicationData = APIGuildIntegrationApplication | Partial; + +export type RawApplicationCommandData = APIApplicationCommand; + +export type RawChannelData = + | RawGuildChannelData + | RawThreadChannelData + | RawDMChannelData + | RawPartialGroupDMChannelData; +export type RawDMChannelData = APIChannel | APIInteractionDataResolvedChannel; +export type RawGuildChannelData = APIChannel | APIInteractionDataResolvedChannel | Required; +export type RawPartialGroupDMChannelData = APIChannel | Required; +export type RawThreadChannelData = APIChannel | APIInteractionDataResolvedChannel; + +export type RawEmojiData = + | RawGuildEmojiData + | RawReactionEmojiData + | GatewayActivityEmoji + | Omit, 'animated'>; +export type RawGuildEmojiData = APIEmoji; +export type RawReactionEmojiData = APIEmoji | APIPartialEmoji; + +export type RawGuildAuditLogData = APIAuditLog; + +export type RawGuildAuditLogEntryData = APIAuditLogEntry; + +export type RawGuildBanData = GatewayGuildBanAddDispatchData | APIBan; + +export type RawGuildData = APIGuild | APIUnavailableGuild; +export type RawAnonymousGuildData = RawGuildData | RawInviteGuildData; +export type RawBaseGuildData = RawAnonymousGuildData | RawOAuth2GuildData; +export type RawInviteGuildData = APIPartialGuild; +export type RawOAuth2GuildData = RESTAPIPartialCurrentUserGuild; + +export type RawGuildMemberData = + | APIGuildMember + | APIInteractionGuildMember + | APIInteractionDataResolvedGuildMember + | GatewayGuildMemberAddDispatchData + | GatewayGuildMemberUpdateDispatchData + | Required + | { user: { id: Snowflake } }; +export type RawThreadMemberData = APIThreadMember; + +export type RawGuildPreviewData = APIGuildPreview; + +export type RawGuildScheduledEventData = APIGuildScheduledEvent; + +export type RawGuildTemplateData = APITemplate; + +export type RawIntegrationData = APIGuildIntegration; + +export type RawInteractionData = GatewayInteractionCreateDispatchData; +export type RawCommandInteractionData = APIApplicationCommandInteraction; +export type RawMessageComponentInteractionData = APIMessageComponentInteraction; +export type RawMessageButtonInteractionData = APIMessageButtonInteractionData; +export type RawMessageSelectMenuInteractionData = APIMessageSelectMenuInteractionData; + +export type RawInviteData = + | APIExtendedInvite + | APIInvite + | (GatewayInviteCreateDispatchData & { channel: GuildChannel; guild: Guild }) + | (GatewayInviteDeleteDispatchData & { channel: GuildChannel; guild: Guild }); + +export type RawInviteStageInstance = APIInviteStageInstance; + +export type RawMessageData = APIMessage; +export type RawPartialMessageData = GatewayMessageUpdateDispatchData; + +export type RawMessageAttachmentData = APIAttachment; + +export type RawMessagePayloadData = + | RESTPostAPIChannelMessageJSONBody + | RESTPatchAPIChannelMessageJSONBody + | RESTPostAPIWebhookWithTokenJSONBody + | RESTPatchAPIWebhookWithTokenJSONBody + | RESTPostAPIInteractionCallbackFormDataBody + | RESTPatchAPIInteractionOriginalResponseJSONBody + | RESTPostAPIInteractionFollowupJSONBody + | RESTPatchAPIInteractionFollowupJSONBody; + +export type RawMessageReactionData = APIReaction | GatewayMessageReactionAddDispatchData; + +export type RawPermissionOverwriteData = APIOverwrite | PermissionOverwrites; + +export type RawPresenceData = GatewayPresenceUpdate; + +export type RawRoleData = APIRole; + +export type RawRichPresenceAssets = GatewayActivityAssets; + +export type RawStageInstanceData = + | APIStageInstance + | (Partial & Pick); + +export type RawStickerData = APISticker | APIStickerItem; + +export type RawStickerPackData = APIStickerPack; + +export type RawTeamData = APITeam; + +export type RawTeamMemberData = APITeamMember; + +export type RawTypingData = GatewayTypingStartDispatchData; + +export type RawUserData = + | (APIUser & { member?: Omit }) + | (GatewayPresenceUpdate['user'] & Pick); + +export type RawVoiceRegionData = APIVoiceRegion; + +export type RawVoiceStateData = GatewayVoiceState | Omit; + +export type RawWebhookData = + | APIWebhook + | RESTGetAPIWebhookWithTokenResult + | (Partial & Required>); + +export type RawWelcomeChannelData = APIGuildWelcomeScreenChannel; + +export type RawWelcomeScreenData = APIGuildWelcomeScreen; + +export type RawWidgetData = APIGuildWidget; + +export type RawWidgetMemberData = APIGuildWidgetMember;