diff options
author | Keuin <[email protected]> | 2021-12-17 23:55:44 +0800 |
---|---|---|
committer | Keuin <[email protected]> | 2021-12-17 23:55:44 +0800 |
commit | 12ddbba66e6f2585e59d05d1782c0e8ce9fe6146 (patch) | |
tree | 0d98ee95c01c8509160658080523e351357d4a9b | |
parent | 7fc64f506ea7ebc68fcb0a9e98351deed7c1d212 (diff) |
Use the latest Velocity API.
Implement API server for online players and server status.
Implement core message routing abstraction and concrete BungeeCross, Velocity, Telegram endpoint impl.
Load config from config file "crosslink/config.json".
Test core components. Proxy API related stuff are not tested.
Add README in English and Chinese.
TODO: Add config hot reloading. More configurable system. PSMB endpoint impl.
104 files changed, 5092 insertions, 52 deletions
@@ -115,4 +115,7 @@ Temporary Items .apdisk # IntelliJ -out/
\ No newline at end of file +out/ + + +VersionInfo.java
\ No newline at end of file @@ -0,0 +1,31 @@ +# CrossLink API documentation + +API Version: `1.0.0` + +Compatible CrossLink version: `1.0-SNAPSHOT` + +## `/online_players` + +- Method: `GET` +- GET Parameters: + - `grouped` (**int**, **optional**) If set to `1`, returns online players grouped by the servers they currently on. + Otherwise, returns all online players in one list. +- Returns: A non-null JSON dictionary, containing a non-null value `players`. + If `grouped=1`, `players` is a non-null dictionary. Otherwise, `players` is a non-null list. +- Example: + - `GET /online_players`: `{"players":[]}` + - `GET /online_players`: `{"players":["fakeKeuin","trueKeuin"]}` + - `GET /online_players?grouped=1`: `{"players":[]}` + - `GET /online_players?grouped=1`: `{"players":{"server2":["fakeKeuin"],"server1":["trueKeuin"]}}` + - `GET /online_players?grouped=1`: `{"players":{"server2":["fakeKeuin", "trueKeuin"]}}` + + +## `/server_status` + +- Method: `GET` +- GET Parameters: none +- Returns: A non-null JSON dictionary, containing `ServerName`(`string`) -> `ServerStatus`(`dictionary`) mapping. + A `ServerStatus` object contains `status` key, which is an **string** enum `Status`. +- Range of enum `Status`: `UP`, `DOWN`, `TIMED OUT` +- Example: + - `GET /server_status`: `{"servers":{"server1":{"status":"UP"},"server2":{"status":"UP"}}}`
\ No newline at end of file @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + 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. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + 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 <https://www.gnu.org/licenses/>. + +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: + + <program> Copyright (C) <year> <name of author> + 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 +<https://www.gnu.org/licenses/>. + + 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 +<https://www.gnu.org/licenses/why-not-lgpl.html>.
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a94875 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# CrossLink: rule-based intercommunicating framework for BungeeCord and Velocity
\ No newline at end of file diff --git a/README_zh_CN.md b/README_zh_CN.md new file mode 100644 index 0000000..dc756c4 --- /dev/null +++ b/README_zh_CN.md @@ -0,0 +1,155 @@ +# CrossLink: 基于规则路由的 BungeeCord/Velocity 消息互联框架 + +CrossLink 的目的是连接Minecraft群组服务器和即时通讯软件。 + +CrossLink 将每个子服务器的聊天窗口视为独立的端点,将每个即时通讯软件的群聊也视为独立的端点。 +服务器管理员编写配置文件,描述消息将如何在这些端点之间互相转发,从而让玩家无论在哪个子服务器 +或者哪个即时通讯软件里,都可以在一起聊天。 + +路由策略可根据需求灵活配置。由于 CrossLink 的代码完全不关心消息该从哪出现、又要被发送到哪里, +因此转发操作可被用户完全控制。用户将`按来源过滤`、`按内容过滤`、`字符串替换`、`丢弃`、`转发` +这些**基本操作(action)** 组合成为**路由规则(rule)**,有限个路由规则的有序排列形成**路由表**。 +CrossLink 按照路由表转发消息,基本操作的组合可实现自由而强大的消息转发,从而 +提供高度定制化的 Minecraft 群组服务器消息互联方案。 + + +# 运行环境 + +- Minecraft 反向代理服务器: + - BungeeCord (未测试,理论上完全兼容) + - Waterfall (在 `waterfall-1.18-470` 版本测试通过) + - Velocity (在 `velocity-3.1.1-98` 版本测试通过) + +- Java 运行时环境: + - OpenJDK 17 (在 `OpenJDK 64-Bit Server VM (build 17.0.1+12-LTS, mixed mode, sharing)` 版本测试通过) + + +# 配置说明 + +1. 在插件目录 `plugins` 里放置插件 `.jar` 文件。 +2. 在此目录下新建子目录 `crosslink`。 +3. 在子目录 `crosslink` 下新建 `messaging.json` 文本文件和 `api.json` 文本文件。 +4. 按照以下样例编写配置文件。 + +## `messaging.json` 示例 + +```json5 +{ + "remotes": [ + { + "type": "telegram", + "id": "Telegram", // this endpoint is identified with "remote:Telegram" + "enabled": true, // default: true, if set to false, this remote will be ignored + "token": "======SECRET======", // Telegram Bot token + "chat_id": 123456789, // repeat to and from this chat + "proxy": "socks://127.0.0.1:10809", // connect to Telegram API using this proxy + "api": "https://my-telegram-api.com" // url to custom Telegram API + }, + { + "type": "psmb", // psmb is a special case, since it is not an endpoint + "id": "mypsmb", // this stub endpoint is identified with "remote:mypsmb" + "enabled": true, // but it creates zero or one or more than one sub "virtual" endpoints + "host": "1.onesmp.org", + "port": 3456, + "subscribe_to": "chat_.+", // psmb subscription pattern + // for example: if you have topic chat_1 and chat_2 + // then they are identified with "remote:mypsmb:chat_1" and "remote:mypsmb:chat_2" + // dispatching messages from "remote:mypsmb" to virtual endpoints + // such as "remote:mypsmb:chat_1", is done by psmb stub endpoint + "topics": [ // all topics this endpoint can actually "see" + "chat_qq", // regexp in "subscribe_to" and action route "to" + "chat_wechat" // will only match endpoints declared in this list + ] + }, + { + "type": "json-rpc", + "id": "rpc", + "enabled": true, + "listen": ["127.0.0.1", 8008], + "methods": { + "get": "getMessage", + "put": "sendMessage" + } + } + ], + "routing": [ + // all rules are processed sequentially + // a message may match multiple rules and thus may be duplicate in your case + // if the message is dropped in an action in one rule, + // (the action type is just "drop" and it does not have any argument) + // all subsequent rules will NOT see this message + { + // inbound chat messages (remote -> all servers) + "object": "chat_message", // match chat messages + "from": "remote:.*", // regexp matching source, + // only messages with matched source will be + // processed by this rule, otherwise this rule is skipped + "actions": [{ + "type": "format", + "color": "green" + }, { // actions run sequentially + "type": "route", // route this message to matched destinations + "to": "server:.*" // regexp matching destination + }] + }, + { + // outbound messages (starting with '#', server -> all remotes) + "object": "chat_message", + "from": "server:.*", + "actions": [{ + "type": "filter", // filter the message using given regexp + // if the message does not match given pattern, + // it won't be passed into subsequent actions + "pattern": "#.+" // match all messages starts with char '#' + }, { + "type": "replace", // replace the message, removing heading '#' + "from": "^#(.*)", // capture all chars after the heading '#' + "to": "$1" // and make them as the output + }, { + "type": "route", // send the message to all remotes + "to": "remote:.*" + }] + }, + { + // cross-server messages (server -> all other servers) + "object": "chat_message", + "from": "server:.*", + "actions": [{ + "type": "route", + "to": "server:.*", + "backflow": false // do not repeat to sender, true by default + // since the destination pattern will match the source, + // we have to set backflow to false to prevent + // players from seeing duplicate messages + }] + } + ] +} +``` + +## `api.json` 示例 + +```json5 +{ + "host": "127.0.0.1", // which host to listen on + "port": 8008, // which port to listen on + "token": "===secret===" // if defined, only requests with GET parameter `token` + // which equals to this value will be processed + // invalid requests will only get a 403 error +} +``` + + +# 开源声明 + +CrossLink 至今的所有版本均在 GPLv3 协议下开源,您可以在 [CrossLink 项目主页](https://github.com/keuin/crosslink) 上找到他的源代码。 +CrossLink 基于许多开源组件,这些开源组件的许可协议和声明可以在他们的项目主页找到: + +- [java-annotations](https://github.com/JetBrains/java-annotations) +- [junit-5](https://github.com/junit-team/junit5) +- [Time4J](https://github.com/MenoData/Time4J) +- [Guice](https://github.com/google/guice) +- [adventure](https://github.com/KyoriPowered/adventure) +- [jackson-core](https://github.com/FasterXML/jackson-core) +- [gson](https://github.com/google/gson) +- [java-telegram-bot-api](https://github.com/pengrad/java-telegram-bot-api)
\ No newline at end of file diff --git a/build.gradle b/build.gradle index e04aa4a..b6032ec 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,6 @@ plugins { + id "com.peterabeles.gversion" version "1.10" + id 'com.github.johnrengelman.shadow' version '7.1.0' id 'java' } @@ -9,7 +11,7 @@ repositories { mavenCentral() maven { name 'velocity' - url 'https://repo.velocitypowered.com/snapshots/' + url 'https://repo.velocitypowered.com/snapshots' } maven { name 'bungeecord-repo' @@ -17,15 +19,67 @@ repositories { } } +gversion { + srcDir = "src/main/java/" // path is relative to the sub-project by default + // Gradle variables can also be used + // E.g. "${project.rootDir}/module/src/main/java" + classPackage = "com.keuin.crosslink.util.version" + className = "VersionInfo" // optional. If not specified GVersion is used + dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" // optional. This is the default + timeZone = "UTC+8" // optional. UTC is default + debug = false // optional. print out extra debug information + language = "java" // optional. Can be Java, Kotlin, YAML, or Properties. Case insensitive. + explicitType = false // optional. Force types to be explicitly printed + indent = " " // optional. Change how code is indented. 1 tab is default. + annotate = false // optional. Java only. Adds @Generated annotation +} + +configurations { + provided + implementation.extendsFrom provided + testImplementation.extendsFrom provided + testImplementation.extendsFrom shadow + project.compileJava.dependsOn(createVersionFile) // auto generate class VersionInfo when compiling +} + dependencies { + // provided: the host program (BungeeCord or Velocity) have loaded this library, no need to shade + // shadow: our custom dependency, should be packed into .jar + // TODO: reduce .jar file size + + // IDEA annotations + implementation 'org.jetbrains:annotations:22.0.0' // JUnit testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' // Velocity - implementation 'com.velocitypowered:velocity-api:1.0.0-SNAPSHOT' - annotationProcessor 'com.velocitypowered:velocity-api:1.0.0-SNAPSHOT' + provided 'com.velocitypowered:velocity-api:3.0.0-SNAPSHOT' + annotationProcessor 'com.velocitypowered:velocity-api:3.0.0-SNAPSHOT' // BungeeCord - implementation 'net.md-5:bungeecord-api:1.16-R0.5-SNAPSHOT' + provided 'net.md-5:bungeecord-api:1.16-R0.5-SNAPSHOT' + // https://mvnrepository.com/artifact/net.time4j/time4j-base + shadow 'net.time4j:time4j-base:5.8' + // https://mvnrepository.com/artifact/com.google.inject/guice + shadow 'com.google.inject:guice:5.0.1' + // https://mvnrepository.com/artifact/net.kyori/adventure-api + shadow 'net.kyori:adventure-api:4.9.3' // bungeecord does not have this, so shadow + // https://mvnrepository.com/artifact/net.kyori/adventure-text-serializer-plain + shadow 'net.kyori:adventure-text-serializer-plain:4.9.3' + // https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core + shadow 'com.fasterxml.jackson.core:jackson-core:2.13.0' + // https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind + shadow 'com.fasterxml.jackson.core:jackson-databind:2.13.0' + // https://mvnrepository.com/artifact/com.google.code.gson/gson + shadow 'com.google.code.gson:gson:2.8.9' + // https://mvnrepository.com/artifact/com.github.pengrad/java-telegram-bot-api + shadow 'com.github.pengrad:java-telegram-bot-api:5.5.0' +} + +shadowJar { + configurations = [project.configurations.shadow] + + // mitigate log4j security problem + exclude 'org/apache/logging/log4j/core/lookup/JndiLookup.class' } test { diff --git a/example/messaging.jsonc b/example/messaging.jsonc new file mode 100644 index 0000000..649d7ee --- /dev/null +++ b/example/messaging.jsonc @@ -0,0 +1,89 @@ +{ + "remotes": [ + { + "type": "telegram", + "id": "Telegram", // this endpoint is identified with "remote:Telegram" + "enabled": true, // default: true, if set to false, this remote will be ignored + "token": "================TOKEN================", + "chat_id": 12345678 // repeat to and from this chat + }, + { + "type": "psmb", // psmb is a special case, since it is not an endpoint + "id": "mypsmb", // this stub endpoint is identified with "remote:mypsmb" + "enabled": true, // but it creates zero or one or more than one sub "virtual" endpoints + "host": "1.onesmp.org", + "port": 3456, + "subscribe_to": "chat_.+", // psmb subscription pattern + // for example: if you have topic chat_1 and chat_2 + // then they are identified with "remote:mypsmb:chat_1" and "remote:mypsmb:chat_2" + // dispatching messages from "remote:mypsmb" to virtual endpoints + // such as "remote:mypsmb:chat_1", is done by psmb stub endpoint + "topics": [ // all topics this endpoint can actually "see" + "chat_qq", // regexp in "subscribe_to" and action route "to" + "chat_wechat" // will only match endpoints declared in this list + ] + }, + { + "type": "json-rpc", + "id": "rpc", + "enabled": true, + "listen": ["127.0.0.1", 8008], + "methods": { + "get": "getMessage", + "put": "sendMessage" + } + } + ], + "routing": [ + // all rules are processed sequentially + // a message may match multiple rules and thus may be duplicate in your case + // if the message is dropped in an action in one rule, + // (the action type is just "drop" and it does not have any argument) + // all subsequent rules will NOT see this message + { + // inbound chat messages (remote -> all servers) + "object": "chat_message", // match chat messages + "from": "remote:.*", // regexp matching source, + // only messages with matched source will be + // processed by this rule, otherwise this rule is skipped + "actions": [{ + "type": "format", + "color": "green" + }, { // actions run sequentially + "type": "route", // route this message to matched destinations + "to": "server:.*" // regexp matching destination + }] + }, + { + // outbound messages (starting with '#', server -> all remotes) + "object": "chat_message", + "from": "server:.*", + "actions": [{ + "type": "filter", // filter the message using given regexp + // if the message does not match given pattern, + // it won't be passed into subsequent actions + "pattern": "#.+" // match all messages starts with char '#' + }, { + "type": "replace", // replace the message, removing heading '#' + "from": "^#(.*)", // capture all chars after the heading '#' + "to": "$1" // and make them as the output + }, { + "type": "route", // send the message to all remotes + "to": "remote:.*" + }] + }, + { + // cross-server messages (server -> all other servers) + "object": "chat_message", + "from": "server:.*", + "actions": [{ + "type": "route", + "to": "server:.*", + "backflow": false // do not repeat to sender, true by default + // since the destination pattern will match the source, + // we have to set backflow to false to prevent + // players from seeing duplicate messages + }] + } + ] +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/BungeeMain.java b/src/main/java/com/keuin/crosslink/BungeeMain.java deleted file mode 100644 index 01486be..0000000 --- a/src/main/java/com/keuin/crosslink/BungeeMain.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.keuin.crosslink; - -import net.md_5.bungee.api.plugin.Plugin; - -import java.util.logging.Logger; - -public class BungeeMain extends Plugin { - - private final Logger logger = getLogger(); - - @Override - public void onLoad() { - logger.info("CrossLink is loading in BungeeCord mode."); - } - - @Override - public void onEnable() { - } - - @Override - public void onDisable() { - } - -}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/VelocityMain.java b/src/main/java/com/keuin/crosslink/VelocityMain.java deleted file mode 100644 index 7123712..0000000 --- a/src/main/java/com/keuin/crosslink/VelocityMain.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.keuin.crosslink; - -import com.google.inject.Inject; -import com.velocitypowered.api.plugin.Plugin; -import com.velocitypowered.api.proxy.ProxyServer; -import org.slf4j.Logger; - -@Plugin(id = "crosslink", name = "CrossLink", version = "1.0-SNAPSHOT", - description = "Link your grouped servers with external world.", authors = {"Keuin"}) -public class VelocityMain { - private final ProxyServer server; - private final Logger logger; - - @Inject - public VelocityMain(ProxyServer server, Logger logger) { - this.server = server; - this.logger = logger; - - logger.info("CrossLink is loading in Velocity mode."); - } -}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/api/ApiServer.java b/src/main/java/com/keuin/crosslink/api/ApiServer.java new file mode 100644 index 0000000..d7599a5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/ApiServer.java @@ -0,0 +1,124 @@ +package com.keuin.crosslink.api; + +import com.google.common.collect.ImmutableMap; +import com.google.inject.Inject; +import com.keuin.crosslink.api.error.ApiStartupException; +import com.keuin.crosslink.api.request.JsonHttpExchange; +import com.keuin.crosslink.api.request.JsonReqHandler; +import com.keuin.crosslink.data.PlayerInfo; +import com.keuin.crosslink.data.ServerInfo; +import com.keuin.crosslink.plugin.common.ICoreAccessor; +import com.keuin.crosslink.util.LoggerNaming; +import com.sun.net.httpserver.HttpServer; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +/** + * CrossLink API server. + * Serves API on a specific port. Fetch data from given accessor. + */ +public class ApiServer implements IApiServer { + private final Logger logger = LoggerFactory.getLogger(LoggerNaming.name().of("api").of("server").toString()); + private HttpServer server = null; + private final ICoreAccessor coreAccessor; + + @Inject + public ApiServer(@NotNull ICoreAccessor coreAccessor) { + Objects.requireNonNull(coreAccessor); + this.coreAccessor = coreAccessor; + } + + @Override + public void startup(InetSocketAddress listen) throws ApiStartupException { + try { + this.server = HttpServer.create(listen, 0); + ImmutableMap.<String, JsonReqHandler>builder() + .put("/", new JsonReqHandler("GET") { + @Override + protected void handle(JsonHttpExchange exc) { + logger.debug("Root handler is called."); + } + }) + .put("/online_players", new JsonReqHandler("GET") { + @Override + protected void handle(JsonHttpExchange exchange) { + // FIXME "true" or other non-zero values does not work + if (exchange.queryMap().getOrDefault("grouped", "0").equals("1")) { + // grouped by server name + var map = exchange.getResponseBody().putObject("players"); + coreAccessor.getOnlinePlayers().stream() + .collect(Collectors.groupingBy(PlayerInfo::serverName)) + .forEach((serverName, players) -> { + var arr = map.putArray(serverName); + players.stream().map(PlayerInfo::name).forEach(arr::add); + }); + } else { + var players = exchange.getResponseBody().putArray("players"); + coreAccessor.getOnlinePlayers().stream().map(PlayerInfo::name).forEach(players::add); + } + } + }) + .put("/server_status", new JsonReqHandler("GET") { + @Override + protected void handle(JsonHttpExchange exchange) throws IOException { + // FIXME make this http server async, prevent serialize non-blocking operation + var ev = new Object(); + var isDone = new AtomicBoolean(false); + logger.debug("Start reading server info."); + coreAccessor.getServerInfo((infoList) -> { + logger.debug("Async reading status."); + var response = exchange.getResponseBody().putObject("servers"); + for (ServerInfo info : infoList) { + var obj = response.putObject(info.name()); + obj.put("status", info.status().getValue()); + } + // unlock the main routine + logger.debug("Async notifying main routine."); + isDone.set(true); + synchronized (ev) { + ev.notifyAll(); + } + }); + // block until the async routine returns + logger.debug("Wait for reading status."); + while (!isDone.get()) { + try { + logger.debug("Spin waiting for pinging to finish."); + synchronized (ev) { + ev.wait(1000); + } + } catch (InterruptedException ignored) { + } + } + logger.debug("Finished reading server status. Sending response to HTTP client."); + } + }) + .build().forEach((p, h) -> server.createContext(p).setHandler(h)); + server.start(); + logger.info("API server is listening on {}:{}.", + listen.getAddress().toString().replaceFirst("/(.+)", "$1"), + listen.getPort()); + } catch (IOException ex) { + throw new ApiStartupException(ex); + } + } + + @Override + public void shutdown() { + var s = server; + if (s != null) { + logger.info("Waiting for incoming connections to close."); + s.stop(1); + logger.info("API server is stopped."); + } + } + + // TODO http server, get data from coreAccessor +} diff --git a/src/main/java/com/keuin/crosslink/api/IApiServer.java b/src/main/java/com/keuin/crosslink/api/IApiServer.java new file mode 100644 index 0000000..678d8ac --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/IApiServer.java @@ -0,0 +1,19 @@ +package com.keuin.crosslink.api; + +import com.keuin.crosslink.api.error.ApiStartupException; + +import java.net.InetSocketAddress; + +public interface IApiServer { + /** + * Start serving the API in other threads. + * @throws ApiStartupException if failed to startup. + */ + void startup(InetSocketAddress listen) throws ApiStartupException; + + /** + * Shutdown the API server. + * If the API server is not started, this method does nothing. + */ + void shutdown(); +} diff --git a/src/main/java/com/keuin/crosslink/api/error/ApiBindException.java b/src/main/java/com/keuin/crosslink/api/error/ApiBindException.java new file mode 100644 index 0000000..5e5b23a --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/error/ApiBindException.java @@ -0,0 +1,7 @@ +package com.keuin.crosslink.api.error; + +public class ApiBindException extends ApiStartupException { + public ApiBindException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/api/error/ApiStartupException.java b/src/main/java/com/keuin/crosslink/api/error/ApiStartupException.java new file mode 100644 index 0000000..4024101 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/error/ApiStartupException.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.api.error; + +public class ApiStartupException extends Exception { + public ApiStartupException() { + } + + public ApiStartupException(String message) { + super(message); + } + + public ApiStartupException(String message, Throwable cause) { + super(message, cause); + } + + public ApiStartupException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/api/request/JsonHttpExchange.java b/src/main/java/com/keuin/crosslink/api/request/JsonHttpExchange.java new file mode 100644 index 0000000..d1c010d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/request/JsonHttpExchange.java @@ -0,0 +1,186 @@ +package com.keuin.crosslink.api.request; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.keuin.crosslink.util.HttpQuery; +import com.keuin.crosslink.util.LazyEvaluated; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Map; + +public class JsonHttpExchange implements AutoCloseable { + + private static final ObjectMapper mapper = new ObjectMapper(); + + private final HttpExchange exchange; + private int rCode = -1; // 200 by default (<= 0) + private final JsonNode requestBody; + private final JsonNode responseBody = mapper.readTree("{}"); + private final LazyEvaluated<Map<String, String>> lazyQueryMap; + private byte[] responseBytes = new byte[0]; + + JsonHttpExchange(HttpExchange exchange) throws IOException { + this.exchange = exchange; + this.requestBody = mapper.readTree(exchange.getRequestBody()); + this.lazyQueryMap = new LazyEvaluated<>(() -> HttpQuery.getParamMap(exchange.getRequestURI().getQuery())); + this.getResponseHeaders().set("Content-Type", "application/json"); + } + + public Map<String, String> queryMap() { + // FIXME string parameter parsing + return lazyQueryMap.get(); + } + + /** + * Returns an immutable {@link Map} containing the HTTP headers that were + * included with this request. The keys in this {@code Map} will be the header + * names, while the values will be a {@link java.util.List} of + * {@linkplain java.lang.String Strings} containing each value that was + * included (either for a header that was listed several times, or one that + * accepts a comma-delimited list of values on a single line). In either of + * these cases, the values for the header name will be presented in the + * order that they were included in the request. + * + * <p> The keys in {@code Map} are case-insensitive. + * + * @return a read-only {@code Map} which can be used to access request headers + */ + public Headers getRequestHeaders() { + return exchange.getRequestHeaders(); + } + + /** + * Returns a mutable {@link Map} into which the HTTP response headers can be + * stored and which will be transmitted as part of this response. The keys in + * the {@code Map} will be the header names, while the values must be a + * {@link java.util.List} of {@linkplain java.lang.String Strings} containing + * each value that should be included multiple times (in the order that they + * should be included). + * + * <p> The keys in {@code Map} are case-insensitive. + * + * @return a writable {@code Map} which can be used to set response headers. + */ + public Headers getResponseHeaders() { + return exchange.getResponseHeaders(); + } + + /** + * Get the request {@link URI}. + * + * @return the request {@code URI} + */ + public URI getRequestURI() { + return exchange.getRequestURI(); + } + + /** + * Get the request method. + * + * @return the request method + */ + public String getRequestMethod() { + return exchange.getRequestMethod(); + } + + /** + * Get the {@link HttpContext} for this exchange. + * + * @return the {@code HttpContext} + */ + public HttpContext getHttpContext() { + return exchange.getHttpContext(); + } + + /** + * Ends this exchange by doing the following in sequence: + * <ol> + * <li> close the request {@link InputStream}, if not already closed. + * <li> close the response {@link OutputStream}, if not already closed. + * </ol> + */ + public void close() throws IOException { + this.getResponseHeaders().set("Content-Type", "application/json"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + mapper.writeValue(baos, responseBody); + responseBytes = baos.toByteArray(); + exchange.sendResponseHeaders(getResponseCode(), responseBytes.length); + exchange.getResponseBody().write(responseBytes); + exchange.close(); + } + + /** + * Returns a stream from which the request body can be read. + * Multiple calls to this method will return the same stream. + * It is recommended that applications should consume (read) all of the data + * from this stream before closing it. If a stream is closed before all data + * has been read, then the {@link InputStream#close()} call will read + * and discard remaining data (up to an implementation specific number of + * bytes). + * + * @return the stream from which the request body can be read + */ + public JsonNode getRequestBody() { + return requestBody; + } + + /** + * Returns the response JSON body. + */ + public ObjectNode getResponseBody() { + return (ObjectNode) responseBody; + } + + + public void setResponseCode(int code) { + rCode = code; + } + + /** + * Returns the address of the remote entity invoking this request. + * + * @return the {@link InetSocketAddress} of the caller + */ + public InetSocketAddress getRemoteAddress() { + return exchange.getRemoteAddress(); + } + + /** + * Returns the response code, if it has already been set. + * + * @return the response code, if available. {@code -1} if not available yet. + */ + public int getResponseCode() { + return (rCode <= 0) ? 200 : rCode; + } + + /** + * Returns the local address on which the request was received. + * + * @return the {@link InetSocketAddress} of the local interface + */ + public InetSocketAddress getLocalAddress() { + return exchange.getLocalAddress(); + } + + /** + * Returns the protocol string from the request in the form + * <i>protocol/majorVersion.minorVersion</i>. For example, + * "{@code HTTP/1.1}". + * + * @return the protocol string from the request + */ + public String getProtocol() { + return exchange.getProtocol(); + } + +} diff --git a/src/main/java/com/keuin/crosslink/api/request/JsonReqHandler.java b/src/main/java/com/keuin/crosslink/api/request/JsonReqHandler.java new file mode 100644 index 0000000..302ee65 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/request/JsonReqHandler.java @@ -0,0 +1,44 @@ +package com.keuin.crosslink.api.request; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.Objects; +import java.util.function.Function; + +public abstract class JsonReqHandler implements HttpHandler { + private final Function<JsonHttpExchange, Integer> precondition; + + public JsonReqHandler() { + precondition = (exc) -> -1; // always true + } + + protected JsonReqHandler(Function<JsonHttpExchange, Integer> precondition) { + this.precondition = precondition; + } + + protected JsonReqHandler(@NotNull String method) { + Objects.requireNonNull(method); + this.precondition = (exc) -> method.equals(exc.getRequestMethod()) ? -1 : 400; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try (var exc = new JsonHttpExchange(exchange)) { + var v = precondition.apply(exc); + if (v > 0) { + // precondition failed with a http response code + // user defined handler is not called + exc.setResponseCode(v); + return; + } + handle(exc); + } + } + + protected void handle(JsonHttpExchange exchange) throws IOException { + + } +} diff --git a/src/main/java/com/keuin/crosslink/config/ConfigLoadException.java b/src/main/java/com/keuin/crosslink/config/ConfigLoadException.java new file mode 100644 index 0000000..f41fdd5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/config/ConfigLoadException.java @@ -0,0 +1,4 @@ +package com.keuin.crosslink.config; + +public class ConfigLoadException extends Exception { +} diff --git a/src/main/java/com/keuin/crosslink/config/GlobalConfigManager.java b/src/main/java/com/keuin/crosslink/config/GlobalConfigManager.java new file mode 100644 index 0000000..26996c2 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/config/GlobalConfigManager.java @@ -0,0 +1,50 @@ +package com.keuin.crosslink.config; + +import org.jetbrains.annotations.NotNull; + +import java.io.File; + +public class GlobalConfigManager { + /** + * Load config from disk. + * If loaded successfully, the global 'loaded' status will be set to true. + * @throws ConfigLoadException failed to load. The 'loaded' status will be set to false. + */ + public static void initializeGlobalManager(File configFile) throws ConfigLoadException { + // TODO read config from disk, create the singleton object +// throw new RuntimeException(); + } + + public static @NotNull GlobalConfigManager getInstance() { + // TODO get the singleton object +// throw new RuntimeException(); + throw new RuntimeException("GlobalConfigManager is not initialized"); + } + + /** + * Get an immutable view of the global config. + * A view is a consistent, but not up-to-date snapshot. + * + * @return the config view. + */ + public IConfigView getConfig() { + // TODO + throw new RuntimeException("Global config is not loaded"); + } + + public boolean isLoaded() { + throw new RuntimeException(); + } + + /** + * Reload the config file from disk. + * If loaded successfully, the global 'loaded' status will be set to true. + * @throws ConfigLoadException failed to reload. + * If previously loaded, the global config won't be modified. + * Otherwise, the 'loaded' status will be set to false. + */ + public void reload() throws ConfigLoadException { + // TODO +// throw new RuntimeException(); + } +} diff --git a/src/main/java/com/keuin/crosslink/config/IConfigView.java b/src/main/java/com/keuin/crosslink/config/IConfigView.java new file mode 100644 index 0000000..a3cab1b --- /dev/null +++ b/src/main/java/com/keuin/crosslink/config/IConfigView.java @@ -0,0 +1,5 @@ +package com.keuin.crosslink.config; + +public interface IConfigView { + int pingTimeoutMillis(); +} diff --git a/src/main/java/com/keuin/crosslink/config/section/ConfigMessaging.java b/src/main/java/com/keuin/crosslink/config/section/ConfigMessaging.java new file mode 100644 index 0000000..75a16ec --- /dev/null +++ b/src/main/java/com/keuin/crosslink/config/section/ConfigMessaging.java @@ -0,0 +1,4 @@ +package com.keuin.crosslink.config.section; + +public class ConfigMessaging { +} diff --git a/src/main/java/com/keuin/crosslink/data/PlayerInfo.java b/src/main/java/com/keuin/crosslink/data/PlayerInfo.java new file mode 100644 index 0000000..6be3f6f --- /dev/null +++ b/src/main/java/com/keuin/crosslink/data/PlayerInfo.java @@ -0,0 +1,24 @@ +package com.keuin.crosslink.data; + +import com.velocitypowered.api.proxy.Player; +import net.md_5.bungee.api.connection.ProxiedPlayer; + +import java.util.UUID; + +public record PlayerInfo(String name, UUID uuid, String serverName) { + public static PlayerInfo fromBungeePlayer(ProxiedPlayer bungeePlayer) { + return new PlayerInfo( + bungeePlayer.getName(), + bungeePlayer.getUniqueId(), + bungeePlayer.getServer().getInfo().getName() + ); + } + + public static PlayerInfo fromVelocityPlayer(Player player) { + return new PlayerInfo( + player.getUsername(), + player.getUniqueId(), + player.getCurrentServer().map((conn) -> conn.getServerInfo().getName()).orElse("null") + ); + } +} diff --git a/src/main/java/com/keuin/crosslink/data/ServerInfo.java b/src/main/java/com/keuin/crosslink/data/ServerInfo.java new file mode 100644 index 0000000..8bdc126 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/data/ServerInfo.java @@ -0,0 +1,4 @@ +package com.keuin.crosslink.data; + +public record ServerInfo(String name, ServerStatus status) { +} diff --git a/src/main/java/com/keuin/crosslink/data/ServerStatus.java b/src/main/java/com/keuin/crosslink/data/ServerStatus.java new file mode 100644 index 0000000..cc9c147 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/data/ServerStatus.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.data; + +/** + * Server running status. + * Copied from legacy BungeeCross code. + */ +public enum ServerStatus { + ONLINE("UP"), OFFLINE("DOWN"), TIMED_OUT("TIMED OUT"); + private final String value; + + ServerStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/messaging/action/BaseFilterAction.java b/src/main/java/com/keuin/crosslink/messaging/action/BaseFilterAction.java new file mode 100644 index 0000000..427a428 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/BaseFilterAction.java @@ -0,0 +1,33 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +import java.util.function.Predicate; + +public class BaseFilterAction implements IAction { + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("actions").of("filter").toString()); + private final Predicate<IMessage> filter; + + public BaseFilterAction(Predicate<IMessage> filter) { + this.filter = filter; + } + + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + Objects.requireNonNull(message); + if (filter.test(message)) { + logger.debug("Message " + message + " passed filter."); + return IActionResult.normal(message); + } else { + logger.debug("Message " + message + " is filtered out."); + return IActionResult.filtered(); + } + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/BaseReplaceAction.java b/src/main/java/com/keuin/crosslink/messaging/action/BaseReplaceAction.java new file mode 100644 index 0000000..4932fac --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/BaseReplaceAction.java @@ -0,0 +1,32 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +import java.util.function.UnaryOperator; + +public class BaseReplaceAction implements IAction { + + private final UnaryOperator<IMessage> replacer; + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("actions").of("replace").toString()); + + /** + * Create a replacement action based on given replacer. + * Note that the replacer should always return a non-null value. + */ + public BaseReplaceAction(UnaryOperator<IMessage> replacer) { + this.replacer = replacer; + } + + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + logger.debug("Replace message " + message); + return IActionResult.normal(Objects.requireNonNull(replacer.apply(message))); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/DropAction.java b/src/main/java/com/keuin/crosslink/messaging/action/DropAction.java new file mode 100644 index 0000000..d4d7815 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/DropAction.java @@ -0,0 +1,24 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DropAction implements IAction { + private final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("actions").of("drop").toString()); + + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + logger.debug("Drop message " + message); + return IActionResult.dropped(); + } + + @Override + public String toString() { + return "DropAction{}"; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/FormatAction.java b/src/main/java/com/keuin/crosslink/messaging/action/FormatAction.java new file mode 100644 index 0000000..cc8b554 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/FormatAction.java @@ -0,0 +1,38 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.util.Messaging; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; + +public class FormatAction extends BaseReplaceAction { + public FormatAction(TextColor color) { + super((message) -> { + // FIXME non-text-based message may lose information? + var formatted = Messaging.duplicate(message.kyoriMessage()); + return IMessage.create(message.source(), message.sender(), formatted.color(color)); + }); + } + public FormatAction(TextDecoration decor) { + super((message) -> { + // FIXME non-text-based message may lose information? + // FIXME clear other decorations + var formatted = Messaging.duplicate(message.kyoriMessage()); + return IMessage.create(message.source(), message.sender(), formatted.decorate(decor)); + }); + } + public FormatAction(Style style) { + super((message) -> { + // FIXME non-text-based message may lose information? + // FIXME clear other styles + var formatted = Messaging.duplicate(message.kyoriMessage()); + return IMessage.create(message.source(), message.sender(), formatted.style(style)); + }); + } + + @Override + public String toString() { + return "FormatAction{}"; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/IAction.java b/src/main/java/com/keuin/crosslink/messaging/action/IAction.java new file mode 100644 index 0000000..b232aaf --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/IAction.java @@ -0,0 +1,40 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.message.IMessage; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * An immutable, constant behaving optional mutator on {@link IMessage} instances. + * Specific action will be taken on given message and the result will be returned. + */ +public interface IAction { + @NotNull IActionResult process(@NotNull IMessage message); + + static @NotNull IAction compounded(@NotNull IAction... actions) { + return new IAction() { + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + Objects.requireNonNull(message); + var msg = IActionResult.normal(message); + for (IAction action : actions) { + if (msg.isDropped() || msg.isFiltered()) { + return msg; + } + // not dropped and not filtered + // move on + // not null guaranteed by post condition of method "getResult" + msg = action.process(Objects.requireNonNull(msg.getResult())); + } + return msg; + } + + @Override + public String toString() { + return String.format("CompoundedAction{actions=%s}", (Object) actions); + } + }; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/Re2placeAction.java b/src/main/java/com/keuin/crosslink/messaging/action/Re2placeAction.java new file mode 100644 index 0000000..f3811fb --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/Re2placeAction.java @@ -0,0 +1,21 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.message.IMessage; + +import java.util.regex.Pattern; + +// regexp replace +public class Re2placeAction extends BaseReplaceAction { + public Re2placeAction(Pattern from, String to) { + super((message) -> { + // FIXME keep color information + var content = from.matcher(message.pureString()).replaceAll(to); + return IMessage.create(message.source(), message.sender(), content); + }); + } + + @Override + public String toString() { + return "Re2placeAction{}"; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/ReFilterAction.java b/src/main/java/com/keuin/crosslink/messaging/action/ReFilterAction.java new file mode 100644 index 0000000..3a06e01 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/ReFilterAction.java @@ -0,0 +1,14 @@ +package com.keuin.crosslink.messaging.action; + +import java.util.regex.Pattern; + +public class ReFilterAction extends BaseFilterAction { + public ReFilterAction(Pattern pattern) { + super((message) -> pattern.matcher(message.pureString()).matches()); + } + + @Override + public String toString() { + return "ReFilterAction{}"; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/RouteAction.java b/src/main/java/com/keuin/crosslink/messaging/action/RouteAction.java new file mode 100644 index 0000000..0015638 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/RouteAction.java @@ -0,0 +1,47 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; + +public class RouteAction implements IAction { + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("actions").of("route").toString()); + private final Supplier<Set<IEndpoint>> destinations; + private final boolean allowBackFlow; + + public RouteAction(Supplier<Set<IEndpoint>> destinations, boolean allowBackFlow) { + this.allowBackFlow = allowBackFlow; + this.destinations = destinations; // late evaluated destinations, ensuring the result to be up-to-date + } + + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + // FIXME implement equals() and hashCode() for all IEndpoint subclasses + var dest = destinations.get().stream(); + if (!allowBackFlow) { + dest = dest.filter((ep) -> !ep.equals(message.source())); + } + dest.forEach((ep) -> { + logger.debug("Route message " + message + " to endpoint " + ep + ", backflow=" + allowBackFlow); + ep.sendMessage(message); + }); + return IActionResult.normal(Objects.requireNonNull(message)); + } + + @Override + public String toString() { + return "RouteAction{" + + "destinations=" + destinations + + ", allowBackFlow=" + allowBackFlow + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/result/IActionResult.java b/src/main/java/com/keuin/crosslink/messaging/action/result/IActionResult.java new file mode 100644 index 0000000..3820f35 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/result/IActionResult.java @@ -0,0 +1,95 @@ +package com.keuin.crosslink.messaging.action.result; + +import com.keuin.crosslink.messaging.message.IMessage; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** + * Process result of an action. The intermediate state in the middle of a chain. + */ +public interface IActionResult { + boolean isDropped(); + + /** + * If the message is filtered out by a filter action or by the rule itself (such as the "from" filter in rule) + * + * @return if the message is filtered out. + */ + boolean isFiltered(); + + /** + * Null if and only if isDropped or isFiltered returns true. + */ + @Nullable IMessage getResult(); // TODO is it better to make this always not null? + + /** + * Returns if the message has not been dropped or filtered out. + */ + default boolean isValid() { + var valid = !isDropped() && !isFiltered(); + if (valid) { + Objects.requireNonNull(getResult()); + } + return valid; + } + + static IActionResult dropped() { + return new IActionResult() { + @Override + public boolean isDropped() { + return true; + } + + @Override + public boolean isFiltered() { + return false; + } + + @Override + public IMessage getResult() { + return null; + } + }; + } + + static IActionResult filtered() { + return new IActionResult() { + @Override + public boolean isDropped() { + return false; + } + + @Override + public boolean isFiltered() { + return true; + } + + @Override + public IMessage getResult() { + return null; + } + }; + } + + static IActionResult normal(@NotNull IMessage message) { + Objects.requireNonNull(message); + return new IActionResult() { + @Override + public boolean isDropped() { + return false; + } + + @Override + public boolean isFiltered() { + return false; + } + + @Override + public IMessage getResult() { + return message; + } + }; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/ConfigSyntaxError.java b/src/main/java/com/keuin/crosslink/messaging/config/ConfigSyntaxError.java new file mode 100644 index 0000000..6191484 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/ConfigSyntaxError.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.messaging.config; + +public class ConfigSyntaxError extends Exception { + public ConfigSyntaxError() { + } + + public ConfigSyntaxError(String message) { + super(message); + } + + public ConfigSyntaxError(String message, Throwable cause) { + super(message, cause); + } + + public ConfigSyntaxError(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/IRemoteEndpointFactory.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/IRemoteEndpointFactory.java new file mode 100644 index 0000000..9310dbd --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/IRemoteEndpointFactory.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.messaging.config.remote; + +import com.fasterxml.jackson.databind.JsonNode; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +// Creates a specific type of remote endpoint, based on given configuration. +public interface IRemoteEndpointFactory { + @NotNull String type(); + @Nullable IEndpoint create(@NotNull JsonNode config) throws InvalidEndpointConfigurationException; +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidEndpointConfigurationException.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidEndpointConfigurationException.java new file mode 100644 index 0000000..376547e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidEndpointConfigurationException.java @@ -0,0 +1,22 @@ +package com.keuin.crosslink.messaging.config.remote; + +/** + * The given JSON config node is invalid to this factory. + */ +public class InvalidEndpointConfigurationException extends Exception { + public InvalidEndpointConfigurationException() { + super(); + } + + public InvalidEndpointConfigurationException(String message) { + super(message); + } + + public InvalidEndpointConfigurationException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidEndpointConfigurationException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidTypeOfEndpointException.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidTypeOfEndpointException.java new file mode 100644 index 0000000..5b27247 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidTypeOfEndpointException.java @@ -0,0 +1,19 @@ +package com.keuin.crosslink.messaging.config.remote; + +public class InvalidTypeOfEndpointException extends InvalidEndpointConfigurationException { + public InvalidTypeOfEndpointException() { + super(); + } + + public InvalidTypeOfEndpointException(String message) { + super(message); + } + + public InvalidTypeOfEndpointException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidTypeOfEndpointException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/RemoteEndpointFactory.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/RemoteEndpointFactory.java new file mode 100644 index 0000000..b73fba5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/RemoteEndpointFactory.java @@ -0,0 +1,47 @@ +package com.keuin.crosslink.messaging.config.remote; + +import com.fasterxml.jackson.databind.JsonNode; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; + +/** + * User should use this factory to create endpoints from a general JSON node. + * This factory can create all supported configurable types of remote endpoints. + */ +public class RemoteEndpointFactory { + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("config").of("remotes").toString()); + private static final IRemoteEndpointFactory[] factories = new IRemoteEndpointFactory[]{new TelegramEndpointFactory()}; + + /** + * Create an {@link IEndpoint} instance based on given JSON config node. + * If success, return the created instance. + * If the given config node does not specify a valid type of endpoint, + * an exception {@link InvalidTypeOfEndpointException} will be thrown. + * If the config node specifies a valid type of endpoint but is invalid, + * an exception {@link InvalidEndpointConfigurationException} will be thrown. + * Note that if the endpoint is not configured to be enabled, null will be returned. + * + * @param config the config to create an endpoint from. + * @return the created endpoint. + */ + public static @Nullable IEndpoint create(@NotNull JsonNode config) throws InvalidEndpointConfigurationException { + var type = Optional.ofNullable(config.get("type")).map(JsonNode::textValue).orElse(null); + for (IRemoteEndpointFactory f : factories) { + if (f.type().equals(type)) { + var ep = f.create(config); + if (ep != null) { + logger.debug("Create remote endpoint " + ep); + } + return ep; + } + } + throw new InvalidTypeOfEndpointException("Unknown type: " + type); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/TelegramEndpointFactory.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/TelegramEndpointFactory.java new file mode 100644 index 0000000..f6e2818 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/TelegramEndpointFactory.java @@ -0,0 +1,44 @@ +package com.keuin.crosslink.messaging.config.remote; + +import com.fasterxml.jackson.databind.JsonNode; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.endpoint.remote.telegram.TelegramGroupEndpoint; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +public class TelegramEndpointFactory implements IRemoteEndpointFactory { + @Override + public @NotNull String type() { + return "telegram"; + } + + @Override + public @Nullable IEndpoint create(@NotNull JsonNode config) throws InvalidEndpointConfigurationException { + // read id, not optional + var id = Optional.ofNullable(config.get("id")).map(JsonNode::textValue).orElse(null); + if (id == null || id.isEmpty()) throw new InvalidEndpointConfigurationException("Invalid \"id\""); + + // read enabled, optional (true by default) + if (!config.get("enabled").isBoolean()) throw new InvalidEndpointConfigurationException("Invalid \"enabled\""); + var enabled = Optional.ofNullable(config.get("enabled")).map(JsonNode::booleanValue).orElse(true); + + // read token, not optional + var token = Optional.ofNullable(config.get("token")).map(JsonNode::textValue).orElse(null); + if (token == null || token.isEmpty()) throw new InvalidEndpointConfigurationException("Invalid \"token\""); + + // read chat id + var nodeChatId = config.get("chat_id"); + var chatId = Optional.ofNullable(nodeChatId).map(JsonNode::longValue).orElse(null); + if (chatId == null) throw new InvalidEndpointConfigurationException("Invalid \"chat_id\""); + if (!nodeChatId.isIntegralNumber() || nodeChatId.isBoolean() + || nodeChatId.isFloatingPointNumber() || nodeChatId.isTextual()) { + throw new InvalidEndpointConfigurationException("Invalid \"chat_id\""); + } + + if (!enabled) return null; // not enabled, give a null value + + return new TelegramGroupEndpoint(token, id, chatId); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/router/IRouterConfigurer.java b/src/main/java/com/keuin/crosslink/messaging/config/router/IRouterConfigurer.java new file mode 100644 index 0000000..359ede4 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/router/IRouterConfigurer.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.messaging.config.router; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.keuin.crosslink.messaging.config.ConfigSyntaxError; +import com.keuin.crosslink.messaging.router.IRouterConfigurable; + +public interface IRouterConfigurer { + /** + * Parse and configure the router with internal configuration string. + * @throws JsonProcessingException cannot parse JSON string. + * @throws ConfigSyntaxError config content is invalid. + */ + void configure(IRouterConfigurable router) throws JsonProcessingException, ConfigSyntaxError; + +// @NotNull IRouterConfigurable getRouter(); + + +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/router/RouterConfigurer.java b/src/main/java/com/keuin/crosslink/messaging/config/router/RouterConfigurer.java new file mode 100644 index 0000000..578fcd5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/router/RouterConfigurer.java @@ -0,0 +1,250 @@ +package com.keuin.crosslink.messaging.config.router; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.keuin.crosslink.messaging.action.*; +import com.keuin.crosslink.messaging.config.ConfigSyntaxError; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.filter.IFilter; +import com.keuin.crosslink.messaging.filter.ReIdFilter; +import com.keuin.crosslink.messaging.router.IRouterConfigurable; +import com.keuin.crosslink.messaging.rule.IRule; +import com.keuin.crosslink.messaging.rule.ImmutableRule; +import com.keuin.crosslink.messaging.rule.ObjectType; +import com.keuin.crosslink.util.LoggerNaming; +import net.kyori.adventure.text.format.NamedTextColor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnmodifiableView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Configure {@link IRouterConfigurable} with supplied JSON config node. + * The config is typically read from config file. + * I know this class is compact and harder to maintain... But it works. + */ +public class RouterConfigurer implements IRouterConfigurer { + private final JsonNode config; + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("config").of("router").toString()); + + public RouterConfigurer(@NotNull JsonNode config) { + Objects.requireNonNull(config); + this.config = config; + } + + private static class ActionConstructionException extends Exception { + public ActionConstructionException() { + } + + public ActionConstructionException(String message) { + super(message); + } + + public ActionConstructionException(String message, Throwable cause) { + super(message, cause); + } + } + + private interface ActionConstructor { + IAction construct(IRouterConfigurable router, JsonNode jsonNode) throws ActionConstructionException; + } + + private static final Map<String, ActionConstructor> actionConstructors = new HashMap<>(); + + static { + // register action decoders + actionConstructors.put("drop", (r, j) -> { + var iter = j.fieldNames(); + while (iter.hasNext()) { + var field = iter.next(); + if (!Objects.equals(field, "type")) { + throw new ActionConstructionException(String.format("Unnecessary field \"%s\"", field)); + } + } + return new DropAction(); + }); + actionConstructors.put("format", (r, j) -> { + var iter = j.fields(); + var actions = new ArrayList<FormatAction>(); + while (iter.hasNext()) { + var field = iter.next(); + switch (field.getKey()) { + case "type": + break; + case "color": + var text = field.getValue().textValue(); + var color = NamedTextColor.NAMES.value(text); + if (color == null) { + throw new ActionConstructionException(String.format("Invalid color \"%s\"", text)); + } + actions.add(new FormatAction(color)); + break; + default: + throw new ActionConstructionException(String.format("Invalid field \"%s\"", field.getKey())); + } + } + return IAction.compounded(actions.toArray(new IAction[0])); + }); + actionConstructors.put("replace", (r, j) -> { + try { + String from = null, to = null; + var iter = j.fields(); + while (iter.hasNext()) { + var field = iter.next(); + switch (field.getKey()) { + case "type": + break; + case "from": + from = field.getValue().textValue(); + break; + case "to": + to = field.getValue().textValue(); + break; + default: + throw new ActionConstructionException(String.format("Invalid field \"%s\"", field.getKey())); + } + } + if (from == null) throw new ActionConstructionException("Missing field \"from\""); + if (to == null) throw new ActionConstructionException("Missing field \"to\""); + var fromPattern = Pattern.compile(from); + return new Re2placeAction(fromPattern, to); + } catch (PatternSyntaxException ex) { + throw new ActionConstructionException("Invalid regexp in field \"from\"", ex); + } + }); + actionConstructors.put("filter", (r, j) -> { + try { + String pattern = null; + var iter = j.fields(); + while (iter.hasNext()) { + var field = iter.next(); + switch (field.getKey()) { + case "type": + break; + case "pattern": + pattern = field.getValue().textValue(); + break; + default: + throw new ActionConstructionException(String.format("Invalid field \"%s\"", field.getKey())); + } + } + if (pattern == null) throw new ActionConstructionException("Missing field \"pattern\""); + var p = Pattern.compile(pattern); + return new ReFilterAction(p); + } catch (PatternSyntaxException ex) { + throw new ActionConstructionException("Invalid regexp in field \"from\"", ex); + } + }); + actionConstructors.put("route", (r, j) -> { + try { + String to = null; + var backFlow = true; // true by default + var iter = j.fields(); + while (iter.hasNext()) { + var field = iter.next(); + switch (field.getKey()) { + case "type": + break; + case "to": + to = field.getValue().textValue(); + break; + case "backflow": + if (field.getValue().isBoolean()) + backFlow = field.getValue().booleanValue(); + else + throw new ActionConstructionException("Field \"backflow\" expects a boolean value"); + break; + default: + throw new ActionConstructionException(String.format("Invalid field \"%s\"", field.getKey())); + } + } + logger.debug("Read entry: route to \"" + to + "\", backflow=" + backFlow); + if (to == null) throw new ActionConstructionException("Missing field \"to\""); + var split = to.split(":"); + if (split.length != 2) throw new ActionConstructionException("Invalid field \"to\": wrong format"); + return new RouteAction(new Supplier<>() { + private final String namespace = split[0]; + private final Pattern idPattern = Pattern.compile(split[1]); + + @Override + public Set<IEndpoint> get() { + return r.resolveEndpoints(namespace, idPattern); + } + + @Override + public String toString() { + return "DestinationResolver{namespace=" + namespace + ", idPattern=" + idPattern + "}"; + } + }, backFlow); + } catch (PatternSyntaxException ex) { + throw new ActionConstructionException("Invalid regexp in field \"to\"", ex); + } + }); + } + + private @NotNull @UnmodifiableView List<IRule> loadRuleChain(@NotNull IRouterConfigurable router, + @NotNull JsonNode config) + throws ConfigSyntaxError { + Objects.requireNonNull(config); + if (!config.isArray()) { + throw new ConfigSyntaxError("Routing rules should be a JSON array"); + } + var ruleCounter = 1; + try { + var ruleList = new ArrayList<IRule>(); + for (var jRule : config) { + Object object = jRule.get("object"); + Object from = jRule.get("from"); + var jActions = jRule.get("actions"); + if (object == null) throw new ConfigSyntaxError("Missing field \"object\""); + if (from == null) throw new ConfigSyntaxError("Missing field \"from\""); + if (jActions == null) throw new ConfigSyntaxError("Missing field \"actions\""); + object = ((JsonNode) object).textValue(); + from = ((JsonNode) from).textValue(); + if (object == null) throw new ConfigSyntaxError("Invalid field \"object\""); + if (from == null) throw new ConfigSyntaxError("Invalid field \"from\""); + object = ObjectType.of((String) object); + if (object == null) + throw new ConfigSyntaxError("Invalid field \"object\", unknown enum"); + var fromFilter = IFilter.fromPatternString((String) from); + var actionCounter = 1; + var actions = new ArrayList<IAction>(); + try { + for (var jAction : jActions) { + if (jAction == null) throw new ConfigSyntaxError("Invalid action %d"); + var aType = jAction.get("type").textValue(); // action type + var constructor = actionConstructors.get(aType); + if (constructor == null) + throw new ConfigSyntaxError(String.format("Invalid action type: %s", aType)); + var iAction = constructor.construct(router, jAction); + actions.add(iAction); + ++actionCounter; + } + } catch (ConfigSyntaxError | ActionConstructionException ex) { + throw new ConfigSyntaxError(ex.getMessage() + ", in action " + actionCounter, ex); + } + ruleList.add(new ImmutableRule((ObjectType) object, fromFilter, actions)); + logger.debug("Load rule: OBJECT: {} | FROM: {} | ACTION: {}", object, from, jActions); + ++ruleCounter; + } + return Collections.unmodifiableList(ruleList); + } catch (ReIdFilter.InvalidPatternStringException ex) { + throw new ConfigSyntaxError(ex); + } catch (ConfigSyntaxError ex) { + throw new ConfigSyntaxError(ex.getMessage() + ", in rule " + ruleCounter, ex); + } + } + + @Override + public void configure(IRouterConfigurable router) throws JsonProcessingException, ConfigSyntaxError { + router.updateRuleChain(loadRuleChain(router, config)); + } + + +} diff --git a/src/main/java/com/keuin/crosslink/messaging/consts/Consts.java b/src/main/java/com/keuin/crosslink/messaging/consts/Consts.java new file mode 100644 index 0000000..b4cdb16 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/consts/Consts.java @@ -0,0 +1,6 @@ +package com.keuin.crosslink.messaging.consts; + +public class Consts { + public static final String SERVER_DOMAIN = "server"; + public static final String REMOTE_DOMAIN = "remote"; +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/EndpointNamespace.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/EndpointNamespace.java new file mode 100644 index 0000000..fb3eb12 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/EndpointNamespace.java @@ -0,0 +1,28 @@ +package com.keuin.crosslink.messaging.endpoint; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public enum EndpointNamespace { + SERVER("server"), REMOTE("remote"); + private final String namespace; + + EndpointNamespace(String namespace) { + this.namespace = namespace; + } + + @Override + public String toString() { + return namespace; + } + + public static @Nullable EndpointNamespace of(@NotNull String s) { + Objects.requireNonNull(s); + for (EndpointNamespace v : EndpointNamespace.values()) { + if (v.toString().equals(s)) return v; + } + return null; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/IEndpoint.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/IEndpoint.java new file mode 100644 index 0000000..fc4e8ab --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/IEndpoint.java @@ -0,0 +1,27 @@ +package com.keuin.crosslink.messaging.endpoint; + +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import org.jetbrains.annotations.NotNull; + +public interface IEndpoint { + void sendMessage(IMessage message); + void setRouter(IRouter router); + void close(); + + /** + * Get the identifier of this endpoint. + * @return the identifier. + */ + @NotNull String id(); + + @NotNull EndpointNamespace namespace(); + + default @NotNull String friendlyName() { + return id(); + } + + default String namespacedId() { + return String.format("%s:%s", namespace(), id()); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/local/BungeeServerChatEndpoint.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/local/BungeeServerChatEndpoint.java new file mode 100644 index 0000000..a221f02 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/local/BungeeServerChatEndpoint.java @@ -0,0 +1,104 @@ +package com.keuin.crosslink.messaging.endpoint.local; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.plugin.bungee.BungeeMainWrapper; +import com.keuin.crosslink.util.LoggerNaming; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.ChatEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; + +public class BungeeServerChatEndpoint implements IEndpoint { + private final Logger logger; + private final ServerInfo server; + private final ProxyServer proxy; + private final Listener chatListener; + private final String id; + private IRouter router = null; + + // We have to make this public, otherwise the BungeeCord event dispatcher + // cannot access this and an exception will be thrown + public class ChatListener implements Listener { + @EventHandler + public void onChat(ChatEvent event) { + Objects.requireNonNull(event); + var player = (ProxiedPlayer) event.getSender(); + if (!server.equals(player.getServer().getInfo())) return; // the message does not come from this endpoint + var textMessage = event.getMessage(); + if (textMessage.startsWith("/")) return; // do not repeat commands + var sender = ISender.create(player.getName(), player.getUniqueId()); + var message = IMessage.create(BungeeServerChatEndpoint.this, sender, textMessage); + logger.info("Received chat message from game: " + message); + onMessage(message); + } + } + + public BungeeServerChatEndpoint(@NotNull ServerInfo server, + @NotNull ProxyServer proxy, + @NotNull BungeeMainWrapper plugin) { + this.logger = + LoggerFactory.getLogger(LoggerNaming.name().of("endpoint").of("bungee").of(server.getName()).toString()); + this.server = Objects.requireNonNull(server); + this.proxy = Objects.requireNonNull(proxy); + this.id = Objects.requireNonNull(server.getName()); + this.chatListener = new ChatListener(); + proxy.getPluginManager().registerListener(plugin, chatListener); + } + + @Override + public void sendMessage(IMessage message) { + proxy.getPlayers().forEach((player) -> { + var info = player.getServer().getInfo(); + if (Objects.equals(info.getName(), server.getName()) && + Objects.equals(info.getSocketAddress(), server.getSocketAddress())) { + player.sendMessage(message.bungeeDisplay()); + } + }); + } + + @Override + public void setRouter(IRouter router) { + this.router = router; + } + + @Override + public void close() { + proxy.getPluginManager().unregisterListener(chatListener); + } + + @Override + public @NotNull String id() { + return id; + } + + @Override + public @NotNull EndpointNamespace namespace() { + return EndpointNamespace.SERVER; + } + + private void onMessage(@NotNull IMessage message) { + var rt = router; + if (rt == null) { + throw new IllegalStateException("Current endpoint hasn't bound to any router"); + } + rt.sendMessage(message); + } + + @Override + public String toString() { + return "BungeeServerChatEndpoint{" + + "id='" + id + '\'' + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/local/VelocityServerChatEndpoint.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/local/VelocityServerChatEndpoint.java new file mode 100644 index 0000000..b490121 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/local/VelocityServerChatEndpoint.java @@ -0,0 +1,91 @@ +package com.keuin.crosslink.messaging.endpoint.local; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.plugin.velocity.VelocityMainWrapper; +import com.velocitypowered.api.event.EventHandler; +import com.velocitypowered.api.event.player.PlayerChatEvent; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public class VelocityServerChatEndpoint implements IEndpoint { + private final RegisteredServer server; + private final ProxyServer proxy; + private final VelocityMainWrapper plugin; + private IRouter router = null; + private final String id; + + // adapter registered on velocity event dispatcher + private final EventHandler<PlayerChatEvent> eventHandler; + + public VelocityServerChatEndpoint(RegisteredServer server, ProxyServer proxy, VelocityMainWrapper plugin) { + this.server = server; + this.proxy = proxy; + this.plugin = plugin; + this.id = server.getServerInfo().getName(); + this.eventHandler = (event) -> { + Objects.requireNonNull(event); + if (!event.getPlayer().getCurrentServer() + .map((ps) -> ps.getServerInfo().equals(server.getServerInfo())) + .orElse(false)) return; // this message does not come from the server this endpoint bound to, skip + var textMessage = event.getMessage(); + if (textMessage.startsWith("/")) return; // do not repeat commands + var sender = ISender.create(event.getPlayer().getUsername(), event.getPlayer().getUniqueId()); + var message = IMessage.create(VelocityServerChatEndpoint.this, sender, textMessage); + onMessage(message); + }; + proxy.getEventManager().register(plugin, PlayerChatEvent.class, eventHandler); + } + + @Override + public void sendMessage(IMessage message) { + proxy.getAllPlayers().forEach((player) -> { + var info = player.getCurrentServer().map(ServerConnection::getServerInfo).orElse(null); + if (Objects.equals(info, server.getServerInfo())) { + player.sendMessage(message.velocityDisplay()); + } + }); + } + + @Override + public void setRouter(IRouter router) { + this.router = router; + } + + @Override + public void close() { + proxy.getEventManager().unregister(plugin, eventHandler); + } + + @Override + public @NotNull String id() { + return id; + } + + @Override + public @NotNull EndpointNamespace namespace() { + return EndpointNamespace.SERVER; + } + + private void onMessage(@NotNull IMessage message) { + var rt = router; + if (rt == null) { + throw new IllegalStateException("No route to endpoint"); + } + rt.sendMessage(message); + } + + @Override + public String toString() { + return "VelocityServerChatEndpoint{" + + "id='" + id + '\'' + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/remote/telegram/TelegramGroupEndpoint.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/remote/telegram/TelegramGroupEndpoint.java new file mode 100644 index 0000000..9e6af55 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/remote/telegram/TelegramGroupEndpoint.java @@ -0,0 +1,117 @@ +package com.keuin.crosslink.messaging.endpoint.remote.telegram; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.util.LoggerNaming; +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.UpdatesListener; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import okhttp3.OkHttpClient; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public class TelegramGroupEndpoint implements IEndpoint { + // TODO make this compatible with both chat and group + private final Logger logger; + private final TelegramBot bot; + private final String endpointId; + private final long chatId; + private IRouter router = null; + + public TelegramGroupEndpoint(@NotNull String token, @NotNull String endpointId, long chatId) { + this(token, endpointId, chatId, null); + } + + public TelegramGroupEndpoint(@NotNull String token, + @NotNull String endpointId, + long chatId, + @Nullable String proxyUrl) { + Objects.requireNonNull(token); + Objects.requireNonNull(endpointId); + this.bot = new TelegramBot + .Builder(token) + .okHttpClient(new OkHttpClient.Builder() + .proxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress("localhost", 10808))) + .build()) + .build(); + this.endpointId = endpointId; + this.chatId = chatId; + this.logger = LoggerFactory.getLogger(LoggerNaming.name().of("endpoint").of("telegram").of(endpointId).toString()); + bot.setUpdatesListener(this::onUpdate); + } + + private int onUpdate(List<Update> updates) { + var lastId = UpdatesListener.CONFIRMED_UPDATES_NONE; + for (Update u : updates) { + lastId = u.updateId(); + if (u.message().chat().id() != chatId) continue; + if (u.message() == null) continue; + var message = u.editedMessage(); + if (message == null) message = u.message(); + if (message == null) continue; + var msgText = message.text(); + if (msgText == null) continue; + if (router == null) { + logger.error("No router associated with this endpoint. Message is dropped."); + continue; + } + // TODO support other types of messages (currently only plaintext is supported) + var buf = ByteBuffer.allocate(Long.BYTES); + buf.putLong(u.message().from().id()); + var sender = ISender.create(u.message().from().username(), UUID.nameUUIDFromBytes(buf.array())); + var msgObj = IMessage.create(this, sender, msgText); + logger.info("Received plain text message from telegram: " + msgObj); + router.sendMessage(msgObj); +// logger.debug(u.toString()); + } + // return id of last processed update or confirm them all + return lastId; + } + + @Override + public void sendMessage(IMessage message) { + bot.execute(new SendMessage(chatId, message.plainTextDisplay())); + } + + + @Override + public void setRouter(IRouter router) { + this.router = router; + } + + public void close() { + bot.removeGetUpdatesListener(); + bot.shutdown(); + } + + @Override + public @NotNull String id() { + return endpointId; + } + + @Override + public @NotNull EndpointNamespace namespace() { + return EndpointNamespace.REMOTE; + } + + @Override + public String toString() { + return "TelegramGroupEndpoint{" + + "id='" + endpointId + '\'' + + ", chatId=" + chatId + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/filter/IFilter.java b/src/main/java/com/keuin/crosslink/messaging/filter/IFilter.java new file mode 100644 index 0000000..1f4fd43 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/filter/IFilter.java @@ -0,0 +1,32 @@ +package com.keuin.crosslink.messaging.filter; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.util.Messaging; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Select endpoints. + */ +public interface IFilter { + boolean filter(@NotNull IEndpoint id); + + static @NotNull IFilter fromPatternString(@NotNull String pattern) throws ReIdFilter.InvalidPatternStringException { + try { + var parts = Messaging.splitIdSelector(pattern); + if (parts == null) throw new ReIdFilter.InvalidPatternStringException("Invalid pattern"); + var ns = EndpointNamespace.of(parts[0]); + if (ns == null) { + throw new ReIdFilter.InvalidPatternStringException(String.format("Invalid namespace %s", parts[0])); + } + var p = Pattern.compile(parts[1]); + return new ReIdFilter(ns, p); + } catch (PatternSyntaxException ex) { + throw new ReIdFilter.InvalidPatternStringException("Invalid pattern regular expression", ex); + } + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/filter/ReIdFilter.java b/src/main/java/com/keuin/crosslink/messaging/filter/ReIdFilter.java new file mode 100644 index 0000000..59be0e0 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/filter/ReIdFilter.java @@ -0,0 +1,48 @@ +package com.keuin.crosslink.messaging.filter; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.regex.Pattern; + +public class ReIdFilter implements IFilter { + public static class InvalidPatternStringException extends Exception { + public InvalidPatternStringException(String message) { + super(message); + } + + public InvalidPatternStringException(String message, Throwable cause) { + super(message, cause); + } + } + + private final EndpointNamespace namespace; + private final Pattern idPattern; + + public ReIdFilter(@NotNull EndpointNamespace namespace, @NotNull Pattern idPattern) { + Objects.requireNonNull(namespace); + Objects.requireNonNull(idPattern); + this.namespace = namespace; + this.idPattern = idPattern; + } + + public @NotNull String getIdPattern() { + return idPattern.pattern(); + } + + @Override + public boolean filter(@NotNull IEndpoint id) { + if (!namespace.equals(id.namespace())) return false; + return idPattern.matcher(id.id()).matches(); + } + + @Override + public String toString() { + return "ReIdFilter{" + + "namespace=" + namespace + + ", idPattern=" + idPattern + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/message/ComponentBackedMessage.java b/src/main/java/com/keuin/crosslink/messaging/message/ComponentBackedMessage.java new file mode 100644 index 0000000..4353c84 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/message/ComponentBackedMessage.java @@ -0,0 +1,50 @@ +package com.keuin.crosslink.messaging.message; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.messaging.util.Messaging; +import com.keuin.crosslink.util.LazyEvaluated; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public class ComponentBackedMessage implements IMessage { + private final IEndpoint source; + private final ISender sender; + private final Component component; + private final LazyEvaluated<String> lazyString; + + public ComponentBackedMessage(@NotNull IEndpoint source, @NotNull ISender sender, @NotNull Component component) { + Objects.requireNonNull(source); + Objects.requireNonNull(sender); + Objects.requireNonNull(component); + this.source = source; + this.sender = sender; + this.component = Messaging.duplicate(component); + this.lazyString = new LazyEvaluated<>(() -> PlainTextComponentSerializer.plainText().serialize(component)); + } + + @Override + public @NotNull ISender sender() { + return sender; + } + + @Override + public @NotNull IEndpoint source() { + return source; + } + + @Override + public @NotNull String pureString() { + return lazyString.get(); + } + + @Override + public Component kyoriMessage() { + return Messaging.duplicate(component); + } + + // FIXME implement bungeeMessage +} diff --git a/src/main/java/com/keuin/crosslink/messaging/message/IMessage.java b/src/main/java/com/keuin/crosslink/messaging/message/IMessage.java new file mode 100644 index 0000000..d2340b8 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/message/IMessage.java @@ -0,0 +1,95 @@ +package com.keuin.crosslink.messaging.message; + + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.sender.ISender; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ComponentBuilder; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * Immutable. + */ +public interface IMessage { + @NotNull ISender sender(); + + @NotNull IEndpoint source(); + + @NotNull String pureString(); + + default Component kyoriMessage() { + // FIXME keep color data + return Component.text().content(pureString()).build(); + } + + static IMessage create(@NotNull IMessage message) { + Objects.requireNonNull(message); + return IMessage.create(message.source(), message.sender(), message.kyoriMessage()); + } + + /** + * Create a text message with given pure text content. + */ + static IMessage create(@NotNull IEndpoint source, @NotNull ISender sender, @NotNull String content) { + Objects.requireNonNull(source); + Objects.requireNonNull(sender); + Objects.requireNonNull(content); + return new TextBackedMessage(source, sender, content); + } + + static IMessage create(IEndpoint source, ISender sender, Component component) { + Objects.requireNonNull(component); + Objects.requireNonNull(sender); + return new ComponentBackedMessage(source, sender, component); + } + + default BaseComponent[] bungeeMessage() { + // FIXME keep color data + return new ComponentBuilder().append(pureString()).create(); + } + + default Component velocityMessage() { + return kyoriMessage(); + } + + /** + * Get the component with sender id and message content. + * Suitable for displaying in BungeeCord sub-servers directly. + */ + default BaseComponent[] bungeeDisplay() { + return new ComponentBuilder() + .append(new ComponentBuilder(String.format("<%s@%s>", sender().plainTextId(), source().friendlyName())) + .italic(true).create()) + .append(new ComponentBuilder(" ").italic(false).create()) + .append(bungeeMessage()) + .create(); + } + + /** + * Get the component with sender id and message content. + * Suitable for displaying in Velocity sub-servers directly. + */ + default Component velocityDisplay() { + var cfg = JoinConfiguration.builder().separator(Component.text(" ")).build(); + return Component.join(cfg, + Component.text() + .content(String.format("<%s@%s>", sender().plainTextId(), source().friendlyName())) + .style(Style.style(TextDecoration.ITALIC)).build(), velocityMessage() + ); + } + + /** + * Get the plain text form of this message, containing sender and source information. + * This can be used to display this message in a plain text environment, such as command line, or text file. + * @return the plain text form of this message. + */ + default String plainTextDisplay() { + return String.format("<%s@%s> %s", sender().plainTextId(), source().friendlyName(), pureString()); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/message/TextBackedMessage.java b/src/main/java/com/keuin/crosslink/messaging/message/TextBackedMessage.java new file mode 100644 index 0000000..576bb91 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/message/TextBackedMessage.java @@ -0,0 +1,53 @@ +package com.keuin.crosslink.messaging.message; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.sender.ISender; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +class TextBackedMessage implements IMessage { + + private final IEndpoint source; + private final ISender sender; + private final String content; + + TextBackedMessage(@NotNull IEndpoint source, @NotNull ISender sender, @NotNull String content) { + Objects.requireNonNull(source); + Objects.requireNonNull(sender); + Objects.requireNonNull(content); + this.source = source; + this.sender = sender; + this.content = content; + } + + @Override + public @NotNull ISender sender() { + return sender; + } + + @Override + public @NotNull IEndpoint source() { + return source; + } + + @Override + public @NotNull String pureString() { + return content; + } + + @Override + public Component kyoriMessage() { + return Component.text(content); + } + + @Override + public String toString() { + return "TextBackedMessage{" + + "source=" + source + + ", sender=" + sender + + ", content='" + content + '\'' + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/router/ConcreteRouter.java b/src/main/java/com/keuin/crosslink/messaging/router/ConcreteRouter.java new file mode 100644 index 0000000..6433f8e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/router/ConcreteRouter.java @@ -0,0 +1,78 @@ +package com.keuin.crosslink.messaging.router; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.rule.IRule; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class ConcreteRouter implements IRouter { + private final Map<String, Map<String, IEndpoint>> endpoints = new HashMap<>(); // namespace / id / endpoint + private final AtomicBoolean isOpened = new AtomicBoolean(true); + private volatile List<IRule> ruleChain = Collections.emptyList(); + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("router").of("impl").toString()); + + @Override + public synchronized boolean addEndpoint(@NotNull IEndpoint endpoint) { + var ns = endpoint.namespace().toString(); + if (!endpoints.containsKey(ns)) { + endpoints.put(ns, new HashMap<>()); + } + var map = endpoints.get(ns); + if (map.containsKey(endpoint.id())) { + logger.error("Endpoint {} is already added into router.", endpoint.namespacedId()); + return false; // already exists + } + endpoint.setRouter(this); + logger.debug("Added endpoint \"" + endpoint.namespacedId() + "\"."); + map.put(endpoint.id(), endpoint); + return true; + } + + @Override + public @NotNull Set<IEndpoint> resolveEndpoints(@NotNull String namespace, @NotNull Pattern idPattern) { + Objects.requireNonNull(namespace); + Objects.requireNonNull(idPattern); + return Optional.ofNullable(endpoints.get(namespace)) + .map(m -> m.entrySet().stream() + .filter((ent) -> idPattern.matcher(ent.getKey()).matches()) + .map(Map.Entry::getValue) + .collect(Collectors.toUnmodifiableSet()) + ).orElse(Collections.emptySet()); + } + + @Override + public void updateRuleChain(@NotNull List<IRule> newChain) { + this.ruleChain = List.copyOf(newChain); + } + + @Override + public void sendMessage(IMessage message) { + logger.debug("Routing message " + message); + if (!isOpened.get()) { + throw new IllegalStateException("Router is closed"); + } + for (IRule rule : ruleChain) { + logger.debug("Applying rule " + rule + " on message " + message); + var result = rule.process(message); + if (result.isDropped()) { + // the message is dropped when processed by this rule + // stop processing and do not pass to next rules + break; + } + } + } + + @Override + public void close() throws Exception { + isOpened.set(false); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/router/IRouter.java b/src/main/java/com/keuin/crosslink/messaging/router/IRouter.java new file mode 100644 index 0000000..3e5dbd6 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/router/IRouter.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.messaging.router; + +import com.keuin.crosslink.messaging.message.IMessage; + +public interface IRouter extends AutoCloseable, IRouterConfigurable { + /** + * Put a message into router. Called by implementation of endpoints. + * Router will scan the rule chain and pass the message to the rules one by one. + * Rules can filter, manipulate, and route messages. + * One message may have zero, one, or multiple final destinations. + * + * @param message the message to be routed. + */ + void sendMessage(IMessage message); + + @Override + void close() throws Exception; +} diff --git a/src/main/java/com/keuin/crosslink/messaging/router/IRouterConfigurable.java b/src/main/java/com/keuin/crosslink/messaging/router/IRouterConfigurable.java new file mode 100644 index 0000000..d1871b9 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/router/IRouterConfigurable.java @@ -0,0 +1,44 @@ +package com.keuin.crosslink.messaging.router; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.rule.IRule; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Contains methods that are essential to router configuring, while not used by general routing routines. + */ +public interface IRouterConfigurable { + class ConfigLoadException extends Exception { + public ConfigLoadException() { + } + + public ConfigLoadException(String message) { + super(message); + } + + public ConfigLoadException(String message, Throwable cause) { + super(message, cause); + } + + public ConfigLoadException(Throwable cause) { + super(cause); + } + } + + boolean addEndpoint(@NotNull IEndpoint endpoint); + + /** + * Get endpoints satisfying given conditions on namespace and id. + * + * @param namespace namespace of endpoints. Only endpoints with this namespace will be returned. + * @param idPattern regexp pattern to match id. Only endpoints with id matching this pattern will be returned. + * @return all matched endpoints. + */ + @NotNull Set<IEndpoint> resolveEndpoints(@NotNull String namespace, @NotNull Pattern idPattern); + + void updateRuleChain(@NotNull List<IRule> newChain); +} diff --git a/src/main/java/com/keuin/crosslink/messaging/rule/IRule.java b/src/main/java/com/keuin/crosslink/messaging/rule/IRule.java new file mode 100644 index 0000000..3a67bf8 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/rule/IRule.java @@ -0,0 +1,26 @@ +package com.keuin.crosslink.messaging.rule; + +import com.keuin.crosslink.messaging.action.IAction; +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.filter.IFilter; +import com.keuin.crosslink.messaging.message.IMessage; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public interface IRule { + /** + * Process a message and decide whether to send to the next rule. + * + * @param message the message. + * @return true if send to next rule, false if discarded. + */ + IActionResult process(@NotNull IMessage message); + + @NotNull ObjectType object(); + + @NotNull IFilter from(); + + @NotNull List<IAction> actions(); + +} diff --git a/src/main/java/com/keuin/crosslink/messaging/rule/ImmutableRule.java b/src/main/java/com/keuin/crosslink/messaging/rule/ImmutableRule.java new file mode 100644 index 0000000..d57d1a5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/rule/ImmutableRule.java @@ -0,0 +1,61 @@ +package com.keuin.crosslink.messaging.rule; + +import com.keuin.crosslink.messaging.action.IAction; +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.filter.IFilter; +import com.keuin.crosslink.messaging.message.IMessage; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; + +public class ImmutableRule implements IRule { + private final ObjectType object; + private final IFilter from; + private final List<IAction> actions; + + public ImmutableRule(@NotNull ObjectType object, @NotNull IFilter from, @NotNull List<IAction> actions) { + Objects.requireNonNull(object); + Objects.requireNonNull(from); + Objects.requireNonNull(actions); + this.object = object; + this.from = from; + this.actions = List.copyOf(actions); + } + + @Override + public IActionResult process(@NotNull IMessage message) { + if (!from.filter(message.source())) return IActionResult.filtered(); // "form" does not match, pass through + var result = IActionResult.normal(Objects.requireNonNull(message)); + for (IAction action : actions) { + if (result.isFiltered() || result.isDropped()) break; + result = action.process(Objects.requireNonNull(result.getResult())); + Objects.requireNonNull(result); + } + return result; + } + + @Override + public @NotNull ObjectType object() { + return object; + } + + @Override + public @NotNull IFilter from() { + return from; + } + + @Override + public @NotNull List<IAction> actions() { + return actions; + } + + @Override + public String toString() { + return "ImmutableRule{" + + "object=" + object + + ", from=" + from + + ", actions=" + actions + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/rule/ObjectType.java b/src/main/java/com/keuin/crosslink/messaging/rule/ObjectType.java new file mode 100644 index 0000000..e224fc6 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/rule/ObjectType.java @@ -0,0 +1,26 @@ +package com.keuin.crosslink.messaging.rule; + +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public enum ObjectType { + CHAT_MESSAGE("chat_message"); + private final String name; + + ObjectType(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public static @Nullable ObjectType of(String s) { + Objects.requireNonNull(s); + for (ObjectType v : ObjectType.values()) { + if (v.name.equals(s)) return v; + } + return null; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/sender/GamePlayerSender.java b/src/main/java/com/keuin/crosslink/messaging/sender/GamePlayerSender.java new file mode 100644 index 0000000..be9b91e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/sender/GamePlayerSender.java @@ -0,0 +1,37 @@ +package com.keuin.crosslink.messaging.sender; + +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.UUID; + +class GamePlayerSender implements UniquelyIdentifiedSender { + + private final UUID uuid; + private final String id; + + GamePlayerSender(@NotNull UUID uuid, @NotNull String id) { + Objects.requireNonNull(uuid); + Objects.requireNonNull(id); + this.uuid = uuid; + this.id = id; + } + + @Override + public @NotNull String plainTextId() { + return id; + } + + @Override + public @NotNull UUID uuid() { + return uuid; + } + + @Override + public String toString() { + return "GamePlayerSender{" + + "uuid=" + uuid + + ", id='" + id + '\'' + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/sender/ISender.java b/src/main/java/com/keuin/crosslink/messaging/sender/ISender.java new file mode 100644 index 0000000..3dd17d2 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/sender/ISender.java @@ -0,0 +1,13 @@ +package com.keuin.crosslink.messaging.sender; + +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public interface ISender { + @NotNull String plainTextId(); + + static ISender create(String id, UUID uuid) { + return new GamePlayerSender(uuid, id); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/sender/UniquelyIdentifiedSender.java b/src/main/java/com/keuin/crosslink/messaging/sender/UniquelyIdentifiedSender.java new file mode 100644 index 0000000..69b4fda --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/sender/UniquelyIdentifiedSender.java @@ -0,0 +1,9 @@ +package com.keuin.crosslink.messaging.sender; + +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public interface UniquelyIdentifiedSender extends ISender { + @NotNull UUID uuid(); +} diff --git a/src/main/java/com/keuin/crosslink/messaging/util/Messaging.java b/src/main/java/com/keuin/crosslink/messaging/util/Messaging.java new file mode 100644 index 0000000..ff7a600 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/util/Messaging.java @@ -0,0 +1,30 @@ +package com.keuin.crosslink.messaging.util; + +import com.keuin.crosslink.messaging.filter.ReIdFilter; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; +import java.util.regex.Pattern; + +public class Messaging { + public static @NotNull Component duplicate(@NotNull Component source) { + // FIXME non-text-based message may lose information? + Objects.requireNonNull(source); + return Component.text().append(source).build(); + } + + public static @Nullable String[] splitIdSelector(@NotNull String pattern) { + Objects.requireNonNull(pattern); + if (Pattern.compile("\\s").matcher(pattern).find()) { + return null; + } + var parts = pattern.split(":"); + if (parts.length != 2 || pattern.startsWith(":") || pattern.endsWith(":")) { + // the heading or trailing ':' does not count, so here we got >= 3 ':' in the string + return null; + } + return parts; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeAccessor.java b/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeAccessor.java new file mode 100644 index 0000000..957927e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeAccessor.java @@ -0,0 +1,57 @@ +package com.keuin.crosslink.plugin.bungee; + +import com.google.inject.Inject; +import com.keuin.crosslink.config.GlobalConfigManager; +import com.keuin.crosslink.data.PlayerInfo; +import com.keuin.crosslink.data.ServerInfo; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.endpoint.local.BungeeServerChatEndpoint; +import com.keuin.crosslink.plugin.bungee.checker.BungeeServerStatusChecker; +import com.keuin.crosslink.plugin.common.ICoreAccessor; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class BungeeAccessor implements ICoreAccessor { + + private final BungeeMainWrapper plugin; + + @Inject + public BungeeAccessor(BungeeMainWrapper plugin) { + this.plugin = plugin; + } + + @Override + public List<PlayerInfo> getOnlinePlayers() { + var players = plugin.getProxy().getPlayers(); + return players.stream().map(PlayerInfo::fromBungeePlayer).toList(); + } + + @Override + public List<PlayerInfo> getOnlinePlayers(String serverName) { + Objects.requireNonNull(serverName); + return getOnlinePlayers().stream().filter((player) -> serverName.equals(player.serverName())).toList(); + } + + @Override + public void getServerInfo(Consumer<List<ServerInfo>> callback) { + var checker = new BungeeServerStatusChecker( + plugin.getProxy().getServers().values(), + plugin, + GlobalConfigManager.getInstance().getConfig().pingTimeoutMillis() + ); + checker.ping((infoMap) -> callback.accept(infoMap.entrySet().stream().map( + (ent) -> new ServerInfo(ent.getKey().getName(), ent.getValue()) + ).toList())); + } + + @Override + public Set<IEndpoint> getServerEndpoints() { + return plugin.getProxy().getServers().values().stream() + .map((si) -> new BungeeServerChatEndpoint(si, plugin.getProxy(), plugin)) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeMainWrapper.java b/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeMainWrapper.java new file mode 100644 index 0000000..e3f9824 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeMainWrapper.java @@ -0,0 +1,46 @@ +package com.keuin.crosslink.plugin.bungee; + +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.keuin.crosslink.plugin.bungee.module.BungeeAccessorModule; +import com.keuin.crosslink.plugin.bungee.module.BungeeApiServerModule; +import com.keuin.crosslink.plugin.common.PluginMain; +import com.keuin.crosslink.plugin.common.ProxyType; +import com.keuin.crosslink.plugin.common.environ.PluginEnvironment; +import com.keuin.crosslink.plugin.common.module.CommonApiServerProvider; +import com.keuin.crosslink.plugin.common.module.CommonIRouterModule; +import com.keuin.crosslink.plugin.common.module.CommonPluginEnvironProvider; +import com.keuin.crosslink.util.LoggerNaming; +import net.md_5.bungee.api.plugin.Plugin; +import org.slf4j.LoggerFactory; + +public final class BungeeMainWrapper extends Plugin { + + private final Injector injector = Guice.createInjector( + new BungeeAccessorModule(this), + new BungeeApiServerModule(), + new CommonIRouterModule(), + new CommonPluginEnvironProvider(new PluginEnvironment( + ProxyType.BUNGEECORD, + LoggerFactory.getLogger(LoggerNaming.name().toString()), + getDataFolder().toPath())), + new CommonApiServerProvider() + ); + private final PluginMain plugin = injector.getInstance(PluginMain.class); + + @Override + public void onLoad() { + // print startup message + // do nothing here + } + + @Override + public void onEnable() { + plugin.enable(); + } + + @Override + public void onDisable() { + plugin.disable(); + } +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/checker/BungeeServerStatusChecker.java b/src/main/java/com/keuin/crosslink/plugin/bungee/checker/BungeeServerStatusChecker.java new file mode 100644 index 0000000..7849f88 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/checker/BungeeServerStatusChecker.java @@ -0,0 +1,43 @@ +package com.keuin.crosslink.plugin.bungee.checker; + + +import com.keuin.crosslink.data.ServerStatus; +import com.keuin.crosslink.plugin.common.BaseServerChecker; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.function.Consumer; + +public class BungeeServerStatusChecker extends BaseServerChecker<ServerInfo> { + + private final Plugin plugin; + + public BungeeServerStatusChecker(@NotNull Collection<ServerInfo> servers, @NotNull Plugin plugin, int timeoutMillis) { + super(servers, timeoutMillis); + this.plugin = plugin; + } + + @Override + protected void scheduleTask(Runnable task) { + plugin.getProxy().getScheduler().runAsync(plugin, task); + } + + @Override + protected void pingServer(ServerInfo server, Consumer<ServerStatus> callback) { + server.ping((result, error) -> { + var isUp = result != null && error == null; + var builder = new StringBuilder(); + builder.append(String.format("Server %s is %s.", server.getName(), isUp ? "up" : "down")).append(". "); + if (result != null) { + builder.append("MOTD: ").append(result.getDescriptionComponent().toPlainText()).append(" "); + } + if (error != null) { + builder.append("Error: ").append(error.getClass().getName()).append(" ").append(error.getMessage()); + } + logger.debug(builder.toString()); + callback.accept(isUp ? ServerStatus.ONLINE : ServerStatus.OFFLINE); + }); + } +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeAccessorModule.java b/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeAccessorModule.java new file mode 100644 index 0000000..3cd120d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeAccessorModule.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.plugin.bungee.module; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.keuin.crosslink.plugin.bungee.BungeeMainWrapper; + +public class BungeeAccessorModule extends AbstractModule { + private final BungeeMainWrapper plugin; + + public BungeeAccessorModule(BungeeMainWrapper plugin) { + this.plugin = plugin; + } + + @Provides + BungeeMainWrapper getPlugin() { + return this.plugin; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeApiServerModule.java b/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeApiServerModule.java new file mode 100644 index 0000000..c8f3b4d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeApiServerModule.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.plugin.bungee.module; + +import com.google.inject.AbstractModule; +import com.keuin.crosslink.plugin.bungee.BungeeAccessor; +import com.keuin.crosslink.plugin.common.ICoreAccessor; + +public class BungeeApiServerModule extends AbstractModule { + @Override + protected void configure() { + bind(ICoreAccessor.class).to(BungeeAccessor.class); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/BaseServerChecker.java b/src/main/java/com/keuin/crosslink/plugin/common/BaseServerChecker.java new file mode 100644 index 0000000..2031a2a --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/BaseServerChecker.java @@ -0,0 +1,106 @@ +package com.keuin.crosslink.plugin.common; + +import com.keuin.crosslink.data.ServerStatus; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** + * Get online status of given sub servers. + * Copied from legacy BungeeCross code and generalized. + */ +public abstract class BaseServerChecker<S> { + + protected static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("common").of("server_checker").toString()); + private final Set<S> servers; + + private final AtomicBoolean isRunning = new AtomicBoolean(false); + private final Map<S, ServerStatus> pingResult = new ConcurrentHashMap<>(); + private final AtomicInteger pingCountdown; + private final int timeoutMillis; + + protected BaseServerChecker(@NotNull Collection<S> servers, + int timeoutMillis) { + this.servers = new HashSet<>(servers); + this.timeoutMillis = timeoutMillis; + this.pingCountdown = new AtomicInteger(this.servers.size()); + this.servers.forEach(s -> pingResult.put(s, ServerStatus.TIMED_OUT)); + for (S server : this.servers) { + Objects.requireNonNull(server); + // initialize with TIMED OUT status, + // which is default when no response received in waiting time range + pingResult.put(server, ServerStatus.TIMED_OUT); + } + } + + protected abstract void scheduleTask(Runnable task); + + // The implementation should fire an asynchronous routine to ping, then call the callback when finishes. + // In one word, this routine not block. + protected abstract void pingServer(S server, Consumer<ServerStatus> callback); + + /** + * Perform ping asynchronously. + * + * @param callback the callback. Will be invoked asynchronously. + */ + public final void ping(Consumer<Map<S, ServerStatus>> callback) { + if (isRunning.getAndSet(true)) { + // already running, do not start new threads + throw new IllegalStateException("The ServerStatusChecker is already started"); + } + logger.debug("Start pinging."); + + final Object finishEvent = new Object(); +// final Consumer<Runnable> scheduler = (Runnable consumer) -> +// plugin.getProxy().getScheduler().runAsync(plugin, consumer); + + // async ping + scheduleTask(() -> servers.forEach(server -> { + logger.debug("Async ping server " + server); + pingServer(server, (status) -> { + logger.debug("Result: server " + server + ", status " + status); + Objects.requireNonNull(status); + this.pingResult.put(server, status); + var remaining = pingCountdown.decrementAndGet(); + logger.debug("Not responded servers: {}.", remaining); + if (remaining == 0) { + synchronized (finishEvent) { + finishEvent.notifyAll(); + } + } + }); + })); + + // async wait and invoke callback + scheduleTask(() -> { + // wait until all server respond + // or timed out + var startTime = System.currentTimeMillis(); + int remaining; + while ((remaining = pingCountdown.get()) != 0 + && (System.currentTimeMillis() - startTime <= timeoutMillis)) { + logger.debug("Waiting for finish. remaining={}.", remaining); + try { + synchronized (finishEvent) { + finishEvent.wait(1000, 0); + } + } catch (InterruptedException ignored) { + } + } + + // all server have responded, or at least one server has timed out + // copy to ignore further result + callback.accept(new HashMap<>(pingResult)); + }); + } + +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/ICoreAccessor.java b/src/main/java/com/keuin/crosslink/plugin/common/ICoreAccessor.java new file mode 100644 index 0000000..4f0f0e0 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/ICoreAccessor.java @@ -0,0 +1,19 @@ +package com.keuin.crosslink.plugin.common; + +import com.keuin.crosslink.data.PlayerInfo; +import com.keuin.crosslink.data.ServerInfo; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; + +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +public interface ICoreAccessor { + List<PlayerInfo> getOnlinePlayers(); + + List<PlayerInfo> getOnlinePlayers(String serverName); + + void getServerInfo(Consumer<List<ServerInfo>> callback); + + Set<IEndpoint> getServerEndpoints(); +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/PluginMain.java b/src/main/java/com/keuin/crosslink/plugin/common/PluginMain.java new file mode 100644 index 0000000..288af95 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/PluginMain.java @@ -0,0 +1,156 @@ +package com.keuin.crosslink.plugin.common; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.keuin.crosslink.api.IApiServer; +import com.keuin.crosslink.api.error.ApiStartupException; +import com.keuin.crosslink.messaging.config.ConfigSyntaxError; +import com.keuin.crosslink.messaging.config.remote.InvalidEndpointConfigurationException; +import com.keuin.crosslink.messaging.config.remote.RemoteEndpointFactory; +import com.keuin.crosslink.messaging.config.router.RouterConfigurer; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.router.IRouter; +import com.keuin.crosslink.messaging.router.IRouterConfigurable; +import com.keuin.crosslink.plugin.common.environ.PluginEnvironment; +import com.keuin.crosslink.util.LoggerNaming; +import com.keuin.crosslink.util.StartupMessagePrinter; +import com.keuin.crosslink.util.version.NewVersionChecker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.HashSet; +import java.util.Optional; + +public final class PluginMain { + private final PluginEnvironment environment; + private final IApiServer apiServer; + private final ICoreAccessor coreAccessor; + private final IRouter messageRouter; + private final Logger logger; + + @Inject + public PluginMain(ICoreAccessor coreAccessor, + IRouter messageRouter, + IApiServer apiServer, + PluginEnvironment pluginEnvironment) { + this.coreAccessor = coreAccessor; + this.messageRouter = messageRouter; + this.apiServer = apiServer; + this.environment = pluginEnvironment; + this.logger = environment.logger(); + // print startup message + StartupMessagePrinter.print(logger::info, pluginEnvironment.proxyType().getName()); + logger.debug("Debug logging is enabled. You may see logs more than usual."); + NewVersionChecker.checkNewVersionAsync( + LoggerFactory.getLogger(LoggerNaming.name().of("update").toString())::info, true); + // the plugin is not constructed here, so don't register event listener here + } + + public void enable() { + final ObjectMapper mapper = new ObjectMapper(); + mapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true); + // TODO refactor setup and teardown routine, split into hooks + // load config + // TODO global config (such as API config) + logger.info("Loading message routing configuration."); + JsonNode messagingConfig = null, routingConfig = null, remoteConfig = null; + try (var fis = new FileInputStream(new File(environment.pluginDataPath().toFile(), "messaging.json"))) { + messagingConfig = Optional.ofNullable(mapper.readTree(fis)).orElse(mapper.readTree("{}")); + routingConfig = Optional.ofNullable(messagingConfig.get("routing")).orElse(mapper.readTree("[]")); + remoteConfig = Optional.ofNullable(messagingConfig.get("remotes")).orElse(mapper.readTree("[]")); + } catch (IOException ex) { + logger.error("Failed to load message routing configuration", ex); + throw new RuntimeException(ex); + } + + // initialize message routing + logger.info("Initializing message routing."); + var endpoints = new HashSet<IEndpoint>(); + try { + try { + logger.debug("Loading rule chain."); + var rc = new RouterConfigurer(routingConfig); + rc.configure(messageRouter); + logger.debug("Message router is configured successfully."); + } catch (JsonProcessingException | ConfigSyntaxError ex) { + throw new IRouterConfigurable.ConfigLoadException(ex); + } + //noinspection CollectionAddAllCanBeReplacedWithConstructor + endpoints.addAll(coreAccessor.getServerEndpoints()); + } catch (IRouter.ConfigLoadException ex) { + logger.error("Failed to read routing config", ex); + throw new RuntimeException(ex); + } + + try { + logger.debug("Loading remote endpoints."); + if (!remoteConfig.isArray()) { + logger.error("Failed to load remote endpoints: remotes should be a JSON array."); + throw new RuntimeException("Invalid remotes type"); + } + for (JsonNode remote : remoteConfig) { + var ep = RemoteEndpointFactory.create(remote); + if (ep != null) { + logger.debug("Add remote endpoint: " + ep); + endpoints.add(ep); + } + } + } catch (InvalidEndpointConfigurationException ex) { + logger.error("Invalid remote endpoint", ex); + throw new RuntimeException(ex); + } + + for (IEndpoint ep : endpoints) { + if (!messageRouter.addEndpoint(ep)) { + logger.error("Cannot add endpoint " + ep); + throw new RuntimeException("Cannot add endpoint " + ep); + } + } + logger.info(String.format("Added %d sub-server(s) to message router.", endpoints.size())); + + logger.info("Starting API server."); + try (var fis = new FileInputStream(new File(environment.pluginDataPath().toFile(), "api.json"))) { + var apiConfig = Optional.ofNullable(mapper.readTree(fis)).orElse(mapper.readTree("{}")); + var host = Optional.ofNullable(apiConfig.get("host")).map(JsonNode::textValue).orElse(null); + if (host == null + || host.isEmpty() + || (Character.digit(host.charAt(0), 16) == -1 && (host.charAt(0) != ':'))) { + throw new ApiStartupException("Invalid inet host to listen on"); + } + int port = Optional.ofNullable(apiConfig.get("port")).map(JsonNode::intValue).orElse(-1); + if (port <= 0) throw new ApiStartupException("Invalid port to listen on"); + // now the host is guaranteed to be an IP address string, so no DNS lookup will be performed + apiServer.startup(new InetSocketAddress(InetAddress.getByName(host), port)); + } catch (IOException ex) { + logger.error("Failed to load message routing configuration", ex); + throw new RuntimeException(ex); + } catch (ApiStartupException ex) { + logger.error("Failed to start API server", ex); + return; + } + logger.info("CrossLink is enabled."); + } + + public void disable() { + logger.info("Stopping API server."); + apiServer.shutdown(); + logger.info("CrossLink is disabled."); + } + + // may throw unchecked exception + public void reload() { + // TODO make api server and router reloadable + } + + private String capital(String s) { + return s.substring(0, 1).toUpperCase() + s.substring(1); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/ProxyType.java b/src/main/java/com/keuin/crosslink/plugin/common/ProxyType.java new file mode 100644 index 0000000..0270da1 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/ProxyType.java @@ -0,0 +1,14 @@ +package com.keuin.crosslink.plugin.common; + +public enum ProxyType { + BUNGEECORD("BungeeCord"), VELOCITY("Velocity"); + private final String name; + + ProxyType(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/environ/PluginEnvironment.java b/src/main/java/com/keuin/crosslink/plugin/common/environ/PluginEnvironment.java new file mode 100644 index 0000000..0864102 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/environ/PluginEnvironment.java @@ -0,0 +1,9 @@ +package com.keuin.crosslink.plugin.common.environ; + +import com.keuin.crosslink.plugin.common.ProxyType; +import org.slf4j.Logger; + +import java.nio.file.Path; + +public record PluginEnvironment(ProxyType proxyType, Logger logger, Path pluginDataPath) { +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/plugin/common/module/CommonApiServerProvider.java b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonApiServerProvider.java new file mode 100644 index 0000000..6498d7f --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonApiServerProvider.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.plugin.common.module; + +import com.google.inject.AbstractModule; +import com.keuin.crosslink.api.ApiServer; +import com.keuin.crosslink.api.IApiServer; + +public class CommonApiServerProvider extends AbstractModule { + @Override + protected void configure() { + bind(IApiServer.class).to(ApiServer.class); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/module/CommonIRouterModule.java b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonIRouterModule.java new file mode 100644 index 0000000..f5de45d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonIRouterModule.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.plugin.common.module; + +import com.google.inject.AbstractModule; +import com.keuin.crosslink.messaging.router.ConcreteRouter; +import com.keuin.crosslink.messaging.router.IRouter; + +public class CommonIRouterModule extends AbstractModule { + @Override + protected void configure() { + bind(IRouter.class).to(ConcreteRouter.class); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/module/CommonPluginEnvironProvider.java b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonPluginEnvironProvider.java new file mode 100644 index 0000000..361e6a4 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonPluginEnvironProvider.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.plugin.common.module; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.keuin.crosslink.plugin.common.environ.PluginEnvironment; + +public class CommonPluginEnvironProvider extends AbstractModule { + private final PluginEnvironment pluginEnvironment; + + public CommonPluginEnvironProvider(PluginEnvironment pluginEnvironment) { + this.pluginEnvironment = pluginEnvironment; + } + + @Provides + PluginEnvironment getPluginEnvironment() { + return this.pluginEnvironment; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityAccessor.java b/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityAccessor.java new file mode 100644 index 0000000..f541139 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityAccessor.java @@ -0,0 +1,54 @@ +package com.keuin.crosslink.plugin.velocity; + +import com.google.inject.Inject; +import com.keuin.crosslink.data.PlayerInfo; +import com.keuin.crosslink.data.ServerInfo; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.endpoint.local.VelocityServerChatEndpoint; +import com.keuin.crosslink.plugin.common.ICoreAccessor; +import com.keuin.crosslink.plugin.velocity.checker.VelocityServerStatusChecker; + +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class VelocityAccessor implements ICoreAccessor { + private final VelocityMainWrapper plugin; + + @Inject + public VelocityAccessor(VelocityMainWrapper plugin) { + this.plugin = plugin; + // TODO handle shutdown event + } + + @Override + public List<PlayerInfo> getOnlinePlayers() { + return plugin.getProxy().getAllPlayers().stream().map(PlayerInfo::fromVelocityPlayer).toList(); + } + + @Override + public List<PlayerInfo> getOnlinePlayers(String serverName) { + return getOnlinePlayers().stream().filter((player) -> serverName.equals(player.serverName())).toList(); + } + + @Override + public void getServerInfo(Consumer<List<ServerInfo>> callback) { + var checker = new VelocityServerStatusChecker( + plugin.getProxy().getAllServers(), + plugin, + //GlobalConfigManager.getInstance().getConfig().pingTimeoutMillis() + 2000 + ); + checker.ping((infoMap) -> callback.accept(infoMap.entrySet().stream().map( + (ent) -> new ServerInfo(ent.getKey().getServerInfo().getName(), ent.getValue()) + ).toList())); + } + + @Override + public Set<IEndpoint> getServerEndpoints() { + return plugin.getProxy().getAllServers().stream() + .map((si) -> new VelocityServerChatEndpoint(si, plugin.getProxy(), plugin)) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityMainWrapper.java b/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityMainWrapper.java new file mode 100644 index 0000000..f6a7cc5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityMainWrapper.java @@ -0,0 +1,62 @@ +package com.keuin.crosslink.plugin.velocity; + +import com.google.inject.Guice; +import com.google.inject.Inject; +import com.keuin.crosslink.plugin.common.PluginMain; +import com.keuin.crosslink.plugin.common.ProxyType; +import com.keuin.crosslink.plugin.common.environ.PluginEnvironment; +import com.keuin.crosslink.plugin.common.module.CommonApiServerProvider; +import com.keuin.crosslink.plugin.common.module.CommonIRouterModule; +import com.keuin.crosslink.plugin.common.module.CommonPluginEnvironProvider; +import com.keuin.crosslink.plugin.velocity.module.VelocityAccessorModule; +import com.keuin.crosslink.plugin.velocity.module.VelocityApiServerModule; +import com.keuin.crosslink.util.LoggerNaming; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyReloadEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; + +// Velocity plugin main class +// Initializes the core accessor and manages its life cycle (such as disabling the accessor when server is down) +@Plugin(id = "crosslink", name = "CrossLink", version = "1.0-SNAPSHOT", + description = "Link your grouped servers with external world.", authors = {"Keuin"}) +public final class VelocityMainWrapper { + private final ProxyServer proxy; + private final PluginMain plugin; + + @Subscribe + public void onProxyInitialization(ProxyInitializeEvent event) { + // reload event + proxy.getEventManager().register( + this, ProxyReloadEvent.class, (ev) -> plugin.reload()); + // shutdown event + proxy.getEventManager().register( + this, ProxyShutdownEvent.class, (ev) -> plugin.disable()); + plugin.enable(); + } + + @Inject + public VelocityMainWrapper(ProxyServer proxy, Logger logger, @DataDirectory Path pluginDataPath) { + this.proxy = proxy; + var injector = Guice.createInjector( + new VelocityAccessorModule(this), + new VelocityApiServerModule(), + new CommonIRouterModule(), + new CommonPluginEnvironProvider(new PluginEnvironment( + ProxyType.VELOCITY, LoggerFactory.getLogger(LoggerNaming.name().toString()), pluginDataPath)), + new CommonApiServerProvider() + ); + this.plugin = injector.getInstance(PluginMain.class); + } + + public ProxyServer getProxy() { + return proxy; + } +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/checker/VelocityServerStatusChecker.java b/src/main/java/com/keuin/crosslink/plugin/velocity/checker/VelocityServerStatusChecker.java new file mode 100644 index 0000000..0619c69 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/checker/VelocityServerStatusChecker.java @@ -0,0 +1,46 @@ +package com.keuin.crosslink.plugin.velocity.checker; + +import com.keuin.crosslink.data.ServerStatus; +import com.keuin.crosslink.plugin.common.BaseServerChecker; +import com.keuin.crosslink.plugin.velocity.VelocityMainWrapper; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Ping some given velocity sub servers. + * Based on the shared asynchronous task firing code from legacy BungeeCross codebase. + */ +public class VelocityServerStatusChecker extends BaseServerChecker<RegisteredServer> { + + private final VelocityMainWrapper plugin; + + public VelocityServerStatusChecker(@NotNull Collection<RegisteredServer> servers, @NotNull VelocityMainWrapper plugin, int timeoutMillis) { + super(servers, timeoutMillis); + this.plugin = plugin; + } + + @Override + protected void scheduleTask(Runnable task) { + plugin.getProxy().getScheduler().buildTask(plugin, task).schedule(); + } + + @Override + protected void pingServer(RegisteredServer server, Consumer<ServerStatus> callback) { + server.ping().whenComplete((ping, ex) -> { + var status = ServerStatus.ONLINE; + if (ex != null) { + logger.warn(String.format("An exception occurred while pinging server %s", + server.getServerInfo().getName()), ex); + status = ServerStatus.OFFLINE; + } else { + // the implementation assures that ping is not null if no exception was thrown + Objects.requireNonNull(ping); + } + callback.accept(status); + }); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityAccessorModule.java b/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityAccessorModule.java new file mode 100644 index 0000000..6baaacf --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityAccessorModule.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.plugin.velocity.module; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.keuin.crosslink.plugin.velocity.VelocityMainWrapper; + +public class VelocityAccessorModule extends AbstractModule { + private final VelocityMainWrapper plugin; + + public VelocityAccessorModule(VelocityMainWrapper plugin) { + this.plugin = plugin; + } + + @Provides + VelocityMainWrapper getPlugin() { + return this.plugin; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityApiServerModule.java b/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityApiServerModule.java new file mode 100644 index 0000000..c50e145 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityApiServerModule.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.plugin.velocity.module; + +import com.google.inject.AbstractModule; +import com.keuin.crosslink.plugin.common.ICoreAccessor; +import com.keuin.crosslink.plugin.velocity.VelocityAccessor; + +public class VelocityApiServerModule extends AbstractModule { + @Override + protected void configure() { + bind(ICoreAccessor.class).to(VelocityAccessor.class); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/AsciiArtPrinter.java b/src/main/java/com/keuin/crosslink/util/AsciiArtPrinter.java new file mode 100644 index 0000000..f7bad05 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/AsciiArtPrinter.java @@ -0,0 +1,33 @@ +package com.keuin.crosslink.util; + +import java.util.List; +import java.util.Random; +import java.util.function.Consumer; + +final class AsciiArtPrinter { + private static final Random random = new Random(); + private static final List<List<String>> ASCII_ARTS = List.of( + List.of( + " _____ _ _ _ ", + "/ __ \\ | | (_) | | ", + "| / \\/_ __ ___ ___ ___ | | _ _ __ | | __", + "| | | '__/ _ \\/ __/ __|| | | | '_ \\| |/ /", + "| \\__/\\ | | (_) \\__ \\__ \\| |___| | | | | < ", + " \\____/_| \\___/|___/___/\\_____/_|_| |_|_|\\_\\" + ), + List.of( + " ___ __ _ _ ", + " / __\\ __ ___ ___ ___ / /(_)_ __ | | __", + " / / | '__/ _ \\/ __/ __| / / | | '_ \\| |/ /", + "/ /__| | | (_) \\__ \\__ \\/ /__| | | | | < ", + "\\____/_| \\___/|___/___/\\____/_|_| |_|_|\\_\\" + ) + ); + + + public static void print(Consumer<String> linePrinter) { + linePrinter.accept(""); + ASCII_ARTS.get(random.nextInt(ASCII_ARTS.size())).forEach(linePrinter); + linePrinter.accept(""); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/EggFactory.java b/src/main/java/com/keuin/crosslink/util/EggFactory.java new file mode 100644 index 0000000..4dfa779 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/EggFactory.java @@ -0,0 +1,17 @@ +package com.keuin.crosslink.util; + +import net.time4j.PlainDate; +import net.time4j.calendar.ChineseCalendar; +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; + +final class EggFactory { + public static Optional<String> getEgg(@NotNull PlainDate today) { + var lunarDate = today.transform(ChineseCalendar.class); + if (lunarDate.getDayOfYear() == 1) { + return Optional.of("Today is Chinese New Year. 新年快乐!"); + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/HttpQuery.java b/src/main/java/com/keuin/crosslink/util/HttpQuery.java new file mode 100644 index 0000000..af06e4d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/HttpQuery.java @@ -0,0 +1,22 @@ +package com.keuin.crosslink.util; + +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class HttpQuery { + // Copied from https://stackoverflow.com/a/63976481 (modified) + public static Map<String, String> getParamMap(String query) { + // query is null if not provided (e.g. localhost/path ) + // query is empty if '?' is supplied (e.g. localhost/path? ) + if (query == null || query.isEmpty()) return Collections.emptyMap(); + + return Stream.of(query.split("&")) + .filter(s -> !s.isEmpty()) + .map(kv -> kv.split("=", 2)) + .collect(Collectors.toMap( + x -> x[0], + x -> ((x.length == 2) ? x[1] : ""))); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/LazyEvaluated.java b/src/main/java/com/keuin/crosslink/util/LazyEvaluated.java new file mode 100644 index 0000000..587a918 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/LazyEvaluated.java @@ -0,0 +1,25 @@ +package com.keuin.crosslink.util; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +public class LazyEvaluated<T> implements Supplier<T> { + + private final Supplier<T> supplier; + private T value = null; + private final AtomicBoolean evaluated = new AtomicBoolean(false); + + public LazyEvaluated(Supplier<T> supplier) { + this.supplier = supplier; + } + + @Override + public T get() { + if (!evaluated.get()) { + value = supplier.get(); + evaluated.set(true); + } + return value; + } + +} diff --git a/src/main/java/com/keuin/crosslink/util/LoggerNaming.java b/src/main/java/com/keuin/crosslink/util/LoggerNaming.java new file mode 100644 index 0000000..4f460a9 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/LoggerNaming.java @@ -0,0 +1,31 @@ +package com.keuin.crosslink.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class LoggerNaming { + public static class NamedNode { + private final List<String> names; + + public NamedNode(List<String> previousNames) { + Objects.requireNonNull(previousNames); + this.names = List.copyOf(previousNames); + } + + public NamedNode of(String sectionName) { + var list = new ArrayList<>(names); + list.add(sectionName); + return new NamedNode(list); + } + + @Override + public String toString() { + return String.join(".", names); + } + } + + public static NamedNode name() { + return new NamedNode(List.of("crosslink")); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/StartupMessagePrinter.java b/src/main/java/com/keuin/crosslink/util/StartupMessagePrinter.java new file mode 100644 index 0000000..b359df9 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/StartupMessagePrinter.java @@ -0,0 +1,13 @@ +package com.keuin.crosslink.util; + +import net.time4j.SystemClock; + +import java.util.function.Consumer; + +public final class StartupMessagePrinter { + public static void print(Consumer<String> linePrinter, String mode) { + AsciiArtPrinter.print(linePrinter); + linePrinter.accept(String.format("CrossLink is loading in %s mode.", mode)); + EggFactory.getEgg(SystemClock.inLocalView().today()).ifPresent(linePrinter); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/version/NewVersionChecker.java b/src/main/java/com/keuin/crosslink/util/version/NewVersionChecker.java new file mode 100644 index 0000000..fb1008e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/version/NewVersionChecker.java @@ -0,0 +1,95 @@ +package com.keuin.crosslink.util.version; + +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +public class NewVersionChecker { + + public static void checkNewVersionAsync(Consumer<String> linePrinter) { + checkNewVersionAsync(linePrinter, true); + } + + public static void checkNewVersionAsync(Consumer<String> linePrinter, boolean skipPreRelease) { + // do not block the main routine, so check in another thread + new Thread(() -> { + try { + if (VersionInfo.DIRTY != 0) { + // dirty build is never released, so is not comparable + linePrinter.accept("You are running a test build of CrossLink, which is unstable. " + + "Update checking is disabled. Please switch to official release if possible."); + } + final var req = new Request.Builder() + .header("Accept", "application/vnd.github.v3+json") + .url("https://api.github.com/repos/keuin/crosslink/releases") + .build(); + final var client = new OkHttpClient(); + final var mainResp = client.newCall(req).execute(); + final var mapper = new ObjectMapper(); + final var json = mapper.readTree(Objects.requireNonNull(mainResp.body()).byteStream()); + for (var ver : json) { + if (ver == null) continue; + final var commit = ver.get("target_commitish").textValue(); + if (VersionInfo.GIT_SHA.equalsIgnoreCase(commit)) { + linePrinter.accept("Current version is the latest version."); + return; // current version is the latest version + } + if (ver.get("draft").booleanValue()) continue; + if (ver.get("prerelease").booleanValue() && skipPreRelease) continue; + + // this is a new version, notify the user + final var pageUrl = ver.get("html_url").textValue(); + final var tag = ver.get("tag_name").textValue(); + final var name = ver.get("name").textValue(); + final var publishDate = Instant + .parse(ver.get("published_at").textValue()) + .atZone(ZoneId.of("UTC")).toLocalDateTime(); + final var currentVersionDate = Instant + .parse(VersionInfo.BUILD_DATE).atZone(ZoneId.of("UTC")).toLocalDateTime(); + final var fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm.ss"); + + // get detail message here + var detailMessage = ""; + try { + var detailResp = client.newCall(new Request.Builder() + .header("Accept", "application/vnd.github.v3+json") + .url(ver.get("url").textValue()).build()).execute(); + var detail = mapper.readTree(Objects.requireNonNull(detailResp.body()).byteStream()); + detailMessage = Optional.ofNullable(detail.get("body").textValue()).orElse(""); + } catch (Exception ignored) { + } + + linePrinter.accept("=".repeat(32)); + linePrinter.accept("New version of CrossLink is available!"); + linePrinter.accept(""); + linePrinter.accept(String.format("Current Version: %s, Build Time: %s", + VersionInfo.VERSION, currentVersionDate.format(fmt))); + linePrinter.accept(String.format("New Version: %s, Description: %s", tag, name)); + linePrinter.accept(String.format("Release Date: %s", publishDate.format(fmt))); + linePrinter.accept(String.format("Git Commit: %s", commit)); + linePrinter.accept(String.format("URL: %s", pageUrl)); + if (!detailMessage.isEmpty()) { + linePrinter.accept("Updates:"); + for (String s : detailMessage.split("\n")) { + linePrinter.accept(s); + } + } + linePrinter.accept("If you want to disable update checker, " + + "edit \"general.json\" and set \"check_update\" to false."); + linePrinter.accept("=".repeat(32)); + return; + } + } catch (Exception ex) { + linePrinter.accept("Cannot check new version from GitHub."); +// throw new RuntimeException(ex); + } + }).start(); + } +} diff --git a/src/main/resources/bungee.yml b/src/main/resources/bungee.yml index 24811bf..7ccd099 100644 --- a/src/main/resources/bungee.yml +++ b/src/main/resources/bungee.yml @@ -1,5 +1,5 @@ -name: CrossLink -main: com.keuin.crosslink.BungeeMain +name: crosslink +main: com.keuin.crosslink.plugin.bungee.BungeeMainWrapper version: 1.0-SNAPSHOT author: Keuin description: Link your grouped servers with external world.
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/action/BaseFilterActionTest.java b/src/test/java/com/keuin/crosslink/messaging/action/BaseFilterActionTest.java new file mode 100644 index 0000000..5b3e1e5 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/action/BaseFilterActionTest.java @@ -0,0 +1,36 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.testable.FakeEndpoint; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class BaseFilterActionTest { + @Test + void processAlwaysTrue() { + var action = new BaseFilterAction(m -> true); + var sender = ISender.create("123", UUID.randomUUID()); + var source = new FakeEndpoint(); + var message = IMessage.create(source, sender, "message"); + assertTrue(action.process(message).isValid()); + assertFalse(action.process(message).isDropped()); + assertFalse(action.process(message).isFiltered()); + assertNotNull(action.process(message).getResult()); + } + + @Test + void processAlwaysFalse() { + var action = new BaseFilterAction(m -> false); + var sender = ISender.create("123", UUID.randomUUID()); + var source = new FakeEndpoint(); + var message = IMessage.create(source, sender, "message"); + var result = action.process(message); + assertFalse(result.isValid()); + assertFalse(result.isDropped()); + assertTrue(result.isFiltered()); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/action/BaseReplaceActionTest.java b/src/test/java/com/keuin/crosslink/messaging/action/BaseReplaceActionTest.java new file mode 100644 index 0000000..78d4e6e --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/action/BaseReplaceActionTest.java @@ -0,0 +1,23 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.testable.FakeEndpoint; +import org.junit.jupiter.api.Test; + +import java.util.Objects; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BaseReplaceActionTest { + @Test + void processSimpleReplace() { + var action = new BaseReplaceAction((msg) -> IMessage.create(msg.source(), msg.sender(), "replaced")); + var sender = ISender.create("123", UUID.randomUUID()); + var source = new FakeEndpoint(); + var message = IMessage.create(source, sender, "message"); + assertEquals("message", message.pureString()); + assertEquals("replaced", Objects.requireNonNull(action.process(message).getResult()).pureString()); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/action/DropActionTest.java b/src/test/java/com/keuin/crosslink/messaging/action/DropActionTest.java new file mode 100644 index 0000000..4e0f441 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/action/DropActionTest.java @@ -0,0 +1,25 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.testable.FakeEndpoint; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DropActionTest { + + @Test + void testDrop() { + var action = new DropAction(); + var sender = ISender.create("123", UUID.randomUUID()); + var source = new FakeEndpoint(); + var message = IMessage.create(source, sender, "message"); + var result = action.process(message); + assertTrue(result.isDropped()); + assertFalse(result.isFiltered()); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/action/Re2placeActionTest.java b/src/test/java/com/keuin/crosslink/messaging/action/Re2placeActionTest.java new file mode 100644 index 0000000..79431f1 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/action/Re2placeActionTest.java @@ -0,0 +1,30 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.testable.FakeEndpoint; +import org.junit.jupiter.api.Test; + +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class Re2placeActionTest { + @Test + public void testRemoveHeadingSharp() { + var action = new Re2placeAction(Pattern.compile("^#(.*)"), "$1"); + var sender = ISender.create("123", UUID.randomUUID()); + var source = new FakeEndpoint(); + var message = IMessage.create(source, sender, "#message"); + assertEquals("#message", message.pureString()); + assertEquals("message", Objects.requireNonNull(action.process(message).getResult()).pureString()); + message = IMessage.create(source, sender, "message"); + assertEquals("message", message.pureString()); + assertEquals("message", Objects.requireNonNull(action.process(message).getResult()).pureString()); + message = IMessage.create(source, sender, "mess#age"); + assertEquals("mess#age", message.pureString()); + assertEquals("mess#age", Objects.requireNonNull(action.process(message).getResult()).pureString()); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/action/ReFilterActionTest.java b/src/test/java/com/keuin/crosslink/messaging/action/ReFilterActionTest.java new file mode 100644 index 0000000..257a54c --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/action/ReFilterActionTest.java @@ -0,0 +1,42 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.testable.FakeEndpoint; +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +class ReFilterActionTest { + @Test + public void testFilterHeadingSharp() { + var action = new ReFilterAction(Pattern.compile("#.*")); + var sender = ISender.create("123", UUID.randomUUID()); + var source = new FakeEndpoint(); + var message1 = IMessage.create(source, sender, "#good"); + var message2 = IMessage.create(source, sender, "bad"); + assertFalse(action.process(message1).isFiltered()); + assertFalse(action.process(message1).isDropped()); + assertNotNull(action.process(message1).getResult()); + assertTrue(action.process(message2).isFiltered()); + assertFalse(action.process(message2).isDropped()); + } + + @Test + public void testFilterAlwaysTrue() { + var action = new ReFilterAction(Pattern.compile(".*")); + var sender = ISender.create("123", UUID.randomUUID()); + var source = new FakeEndpoint(); + var message1 = IMessage.create(source, sender, "#good"); + var message2 = IMessage.create(source, sender, "bad"); + assertFalse(action.process(message1).isFiltered()); + assertFalse(action.process(message1).isDropped()); + assertNotNull(action.process(message1).getResult()); + assertFalse(action.process(message2).isFiltered()); + assertFalse(action.process(message2).isDropped()); + assertNotNull(action.process(message2).getResult()); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/action/RouteActionTest.java b/src/test/java/com/keuin/crosslink/messaging/action/RouteActionTest.java new file mode 100644 index 0000000..b9ad631 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/action/RouteActionTest.java @@ -0,0 +1,46 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.testable.FakeEndpoint; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class RouteActionTest { + + @Test + void testRouteNoBackflow() { + var dest1 = new FakeEndpoint("a"); + var dest2 = new FakeEndpoint("b"); + var dest3 = new FakeEndpoint("c"); + var dests = new HashSet<IEndpoint>(Arrays.asList(dest1, dest2, dest3)); + var action = new RouteAction(() -> dests, false); + var sender = ISender.create("123", UUID.randomUUID()); + var message = IMessage.create(dest1, sender, "message"); + assertSame(message, action.process(message).getResult()); + assertTrue(dest1.messages.isEmpty()); + assertEquals(1, dest2.messages.size()); + assertEquals(1, dest3.messages.size()); + } + + @Test + void testRouteWithBackflow() { + var dest1 = new FakeEndpoint("a"); + var dest2 = new FakeEndpoint("b"); + var dest3 = new FakeEndpoint("c"); + var dests = new HashSet<IEndpoint>(Arrays.asList(dest1, dest2, dest3)); + var action = new RouteAction(() -> dests, true); + var sender = ISender.create("123", UUID.randomUUID()); + var message = IMessage.create(dest1, sender, "message"); + assertSame(message, action.process(message).getResult()); + assertEquals(1, dest1.messages.size()); + assertEquals(1, dest2.messages.size()); + assertEquals(1, dest3.messages.size()); + } +} diff --git a/src/test/java/com/keuin/crosslink/messaging/config/RouterConfigurerTest.java b/src/test/java/com/keuin/crosslink/messaging/config/RouterConfigurerTest.java new file mode 100644 index 0000000..6c9738e --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/config/RouterConfigurerTest.java @@ -0,0 +1,89 @@ +package com.keuin.crosslink.messaging.config; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.keuin.crosslink.messaging.config.router.RouterConfigurer; +import com.keuin.crosslink.messaging.rule.IRule; +import com.keuin.crosslink.messaging.rule.ObjectType; +import com.keuin.crosslink.testable.FakeRouter; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RouterConfigurerTest { + + private static final String config = """ + [ + // all rules are processed sequentially + // a message may match multiple rules and thus may be duplicate in your case + // if the message is dropped in an action in one rule, + // (the action type is just "drop" and it does not have any argument) + // all subsequent rules will NOT see this message + { + // inbound chat messages (remote -> all servers) + "object": "chat_message", // match chat messages + "from": "remote:.*", // regexp matching source, + // only messages with matched source will be + // processed by this rule, otherwise this rule is skipped + "actions": [{ // actions run sequentially + "type": "route", // route this message to matched destinations + "to": "server:.*" // regexp matching destination \s + }, { + "type": "format", + "color": "green" + }] + }, + { + // outbound messages (starting with '#', server -> all remotes) + "object": "chat_message", + "from": "server:.*", + "actions": [{ + "type": "filter", // filter the message using given regexp + // if the message does not match given pattern, + // it won't be passed into subsequent actions + "pattern": "#.+" // match all messages starts with char '#' + }, { + "type": "replace", // replace the message, removing heading '#' + "from": "^#\\\\(.*\\\\)", // capture all chars after the heading '#' + "to": "$1" // and make them as the output + }, { + "type": "route", // send the message to all remotes + "to": "remote:.*" + }] + }, + { + // cross-server messages (server -> all other servers) + "object": "chat_message", + "from": "server:.*", + "actions": [{ + "type": "route", + "to": "server:.*", + "backflow": false // do not repeat to sender, true by default + // since the destination pattern will match the source, + // we have to set backflow to false to prevent + // players from seeing duplicate messages + }] + } + ]"""; + + @Test + void decode() throws ConfigSyntaxError, JsonProcessingException { + var router = new FakeRouter(); + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true); + var rc = new RouterConfigurer(mapper.readTree(config)); + rc.configure(router); + var chain = router.getRules(); + + IRule r; + assertEquals(3, chain.size()); + r = chain.get(0); + assertEquals(r.object(), ObjectType.CHAT_MESSAGE); + r = chain.get(1); + assertEquals(r.object(), ObjectType.CHAT_MESSAGE); + r = chain.get(2); + assertEquals(r.object(), ObjectType.CHAT_MESSAGE); + // TODO + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/config/RuleListsTest.java b/src/test/java/com/keuin/crosslink/messaging/config/RuleListsTest.java new file mode 100644 index 0000000..b9a10d6 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/config/RuleListsTest.java @@ -0,0 +1,38 @@ +package com.keuin.crosslink.messaging.config; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +class RuleListsTest { + + @Test + void fromJson() throws JsonProcessingException { + var json = new String(Base64.getDecoder() + .decode("WwogICAgLy8gYWxsIHJ1bGVzIGFyZSBwcm9jZXNzZWQgc2VxdWVudGlhbGx5CiAgICAvLyBhIG1lc3NhZ2UgbWF5IG1hdGNoIG11bHRpcGxlIHJ1bGVzIGFuZCB0aHVzIG1heSBiZSBkdXBsaWNhdGUgaW4geW91ciBjYXNlCiAgICAvLyBpZiB0aGUgbWVzc2FnZSBpcyBkcm9wcGVkIGluIGFuIGFjdGlvbiBpbiBvbmUgcnVsZSwKICAgIC8vICh0aGUgYWN0aW9uIHR5cGUgaXMganVzdCAiZHJvcCIgYW5kIGl0IGRvZXMgbm90IGhhdmUgYW55IGFyZ3VtZW50KQogICAgLy8gYWxsIHN1YnNlcXVlbnQgcnVsZXMgd2lsbCBOT1Qgc2VlIHRoaXMgbWVzc2FnZQogICAgewogICAgICAgIC8vIGluYm91bmQgY2hhdCBtZXNzYWdlcyAocmVtb3RlIC0+IGFsbCBzZXJ2ZXJzKQogICAgICAgICJvYmplY3QiOiAiY2hhdF9tZXNzYWdlIiwgLy8gbWF0Y2ggY2hhdCBtZXNzYWdlcwogICAgICAgICJmcm9tIjogInJlbW90ZTouKiIsICAgICAgLy8gcmVnZXhwIG1hdGNoaW5nIHNvdXJjZSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC8vIG9ubHkgbWVzc2FnZXMgd2l0aCBtYXRjaGVkIHNvdXJjZSB3aWxsIGJlCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAvLyBwcm9jZXNzZWQgYnkgdGhpcyBydWxlLCBvdGhlcndpc2UgdGhpcyBydWxlIGlzIHNraXBwZWQKICAgICAgICAiYWN0aW9ucyI6IFt7ICAgICAgICAgICAgIC8vIGFjdGlvbnMgcnVuIHNlcXVlbnRpYWxseQogICAgICAgICAgICAidHlwZSI6ICJyb3V0ZSIsICAgICAgLy8gcm91dGUgdGhpcyBtZXNzYWdlIHRvIG1hdGNoZWQgZGVzdGluYXRpb25zCiAgICAgICAgICAgICJ0byI6ICJzZXJ2ZXI6LioiICAgICAvLyByZWdleHAgbWF0Y2hpbmcgZGVzdGluYXRpb24gIAogICAgICAgIH1dCiAgICB9LAogICAgewogICAgICAgIC8vIG91dGJvdW5kIG1lc3NhZ2VzIChzdGFydGluZyB3aXRoICcjJywgc2VydmVyIC0+IGFsbCByZW1vdGVzKQogICAgICAgICJvYmplY3QiOiAiY2hhdF9tZXNzYWdlIiwKICAgICAgICAiZnJvbSI6ICJzZXJ2ZXI6LioiLAogICAgICAgICJhY3Rpb25zIjogW3sKICAgICAgICAgICAgInR5cGUiOiAiZmlsdGVyIiwgICAgIC8vIGZpbHRlciB0aGUgbWVzc2FnZSB1c2luZyBnaXZlbiByZWdleHAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC8vIGlmIHRoZSBtZXNzYWdlIGRvZXMgbm90IG1hdGNoIGdpdmVuIHBhdHRlcm4sCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAvLyBpdCB3b24ndCBiZSBwYXNzZWQgaW50byBzdWJzZXF1ZW50IGFjdGlvbnMKICAgICAgICAgICAgInBhdHRlcm4iOiAiIy4rIiAgICAgIC8vIG1hdGNoIGFsbCBtZXNzYWdlcyBzdGFydHMgd2l0aCBjaGFyICcjJwogICAgICAgIH0sIHsKICAgICAgICAgICAgInR5cGUiOiAicmVwbGFjZSIsICAgIC8vIHJlcGxhY2UgdGhlIG1lc3NhZ2UsIHJlbW92aW5nIGhlYWRpbmcgJyMnCiAgICAgICAgICAgICJmcm9tIjogIl4jXFwoLipcXCkiLCAvLyBjYXB0dXJlIGFsbCBjaGFycyBhZnRlciB0aGUgaGVhZGluZyAnIycKICAgICAgICAgICAgInRvIjogIiQxIiAgICAgICAgICAgIC8vIGFuZCBtYWtlIHRoZW0gYXMgdGhlIG91dHB1dAogICAgICAgIH0sIHsKICAgICAgICAgICAgInR5cGUiOiAicm91dGUiLCAgICAgIC8vIHNlbmQgdGhlIG1lc3NhZ2UgdG8gYWxsIHJlbW90ZXMKICAgICAgICAgICAgInRvIjogInJlbW90ZTouKiIKICAgICAgICB9XQogICAgfSwKICAgIHsKICAgICAgICAvLyBjcm9zcy1zZXJ2ZXIgbWVzc2FnZXMgKHNlcnZlciAtPiBhbGwgb3RoZXIgc2VydmVycykKICAgICAgICAib2JqZWN0IjogImNoYXRfbWVzc2FnZSIsCiAgICAgICAgImZvcm0iOiAic2VydmVyOi4qIiwKICAgICAgICAiYWN0aW9ucyI6IFt7CiAgICAgICAgICAgICJ0eXBlIjogInJvdXRlIiwKICAgICAgICAgICAgInRvIjogInNlcnZlcjouKiIsCiAgICAgICAgICAgICJiYWNrZmxvdyI6IGZhbHNlICAvLyBkbyBub3QgcmVwZWF0IHRvIHNlbmRlciwgdHJ1ZSBieSBkZWZhdWx0CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAvLyBzaW5jZSB0aGUgZGVzdGluYXRpb24gcGF0dGVybiB3aWxsIG1hdGNoIHRoZSBzb3VyY2UsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAvLyB3ZSBoYXZlIHRvIHNldCBiYWNrZmxvdyB0byBmYWxzZSB0byBwcmV2ZW50CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAvLyBwbGF5ZXJzIGZyb20gc2VlaW5nIGR1cGxpY2F0ZSBtZXNzYWdlcwogICAgICAgIH1dCiAgICB9Cl0="), + StandardCharsets.UTF_8); + var mapper = new ObjectMapper(); + mapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true); + var t = mapper.readTree(json); + for (var rule : t) { + var object = rule.get("object"); + var from = rule.get("from"); + var actions = rule.get("actions"); + System.out.printf("%s | %s | %s%n", object, from, actions); + } + } + + @Test + void testReadObjectType() { + + } + + @Test + void testReadReIdFilter() { + + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/filter/IFilterTest.java b/src/test/java/com/keuin/crosslink/messaging/filter/IFilterTest.java new file mode 100644 index 0000000..be20077 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/filter/IFilterTest.java @@ -0,0 +1,32 @@ +package com.keuin.crosslink.messaging.filter; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class IFilterTest { + + @Test + void fromPatternString() throws ReIdFilter.InvalidPatternStringException { + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString(":::")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("::")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString(":")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("server:::")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("server::")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("server:")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString(":server::")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString(":server:")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString(":server")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString(":::server")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("::server")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("server")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("server:")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("server:bbb:")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("server:aaa:bbb")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("remote:aa\n")); + assertThrows(ReIdFilter.InvalidPatternStringException.class, () -> IFilter.fromPatternString("server:aa\n")); + assertDoesNotThrow(() -> IFilter.fromPatternString("server:b")); + assertDoesNotThrow(() -> IFilter.fromPatternString("remote:bbb")); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/message/ComponentBackedMessageTest.java b/src/test/java/com/keuin/crosslink/messaging/message/ComponentBackedMessageTest.java new file mode 100644 index 0000000..ad483d5 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/message/ComponentBackedMessageTest.java @@ -0,0 +1,22 @@ +package com.keuin.crosslink.messaging.message; + +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.testable.FakeEndpoint; +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ComponentBackedMessageTest { + + @Test + void testPureString() { + var source = new FakeEndpoint("endpoint"); + var sender = ISender.create("sender", UUID.randomUUID()); + var comp = Component.text("text").append(Component.text("message")); + var msg = new ComponentBackedMessage(source, sender, comp); + assertEquals("textmessage", msg.pureString()); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/router/ConcreteRouterTest.java b/src/test/java/com/keuin/crosslink/messaging/router/ConcreteRouterTest.java new file mode 100644 index 0000000..b9ace8d --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/router/ConcreteRouterTest.java @@ -0,0 +1,132 @@ +package com.keuin.crosslink.messaging.router; + +import com.keuin.crosslink.messaging.action.DropAction; +import com.keuin.crosslink.messaging.action.ReFilterAction; +import com.keuin.crosslink.messaging.action.RouteAction; +import com.keuin.crosslink.messaging.filter.IFilter; +import com.keuin.crosslink.messaging.filter.ReIdFilter; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.rule.IRule; +import com.keuin.crosslink.messaging.rule.ImmutableRule; +import com.keuin.crosslink.messaging.rule.ObjectType; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.testable.FakeEndpoint; +import com.keuin.crosslink.testable.FakeRouter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +class ConcreteRouterTest { + + private ConcreteRouter router; + private FakeEndpoint ep1 = new FakeEndpoint("a"); + private FakeEndpoint ep2 = new FakeEndpoint("b"); + private FakeEndpoint ep3 = new FakeEndpoint("c"); + + @BeforeEach + void setUp() { + router = new ConcreteRouter(); + router.addEndpoint(ep1); + router.addEndpoint(ep2); + router.addEndpoint(ep3); + } + + @Test + void testBroadcast() throws ReIdFilter.InvalidPatternStringException { + var ep0 = new FakeEndpoint("z"); + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(ep0, sender, "MSG,,,"); + var action = new RouteAction(() -> router.resolveEndpoints("server", Pattern.compile(".*")), true); + var rule = new ImmutableRule(ObjectType.CHAT_MESSAGE, IFilter.fromPatternString("server:z"), Collections.singletonList(action)); + router.updateRuleChain(Collections.singletonList(rule)); + router.sendMessage(msg); + assertEquals(1, ep1.messages.size()); + assertEquals(1, ep2.messages.size()); + assertEquals(1, ep3.messages.size()); + assertEquals(msg, ep1.messages.get(0)); + assertEquals(msg, ep2.messages.get(0)); + assertEquals(msg, ep3.messages.get(0)); + } + + @Test + void testBroadcastBackflow() throws ReIdFilter.InvalidPatternStringException { + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(ep1, sender, "MSG,,,"); + var action = new RouteAction(() -> router.resolveEndpoints("server", Pattern.compile(".*")), true); + var rule = new ImmutableRule(ObjectType.CHAT_MESSAGE, IFilter.fromPatternString("server:a"), Collections.singletonList(action)); + router.updateRuleChain(Collections.singletonList(rule)); + router.sendMessage(msg); + assertEquals(1, ep1.messages.size()); + assertEquals(1, ep2.messages.size()); + assertEquals(1, ep3.messages.size()); + assertEquals(msg, ep1.messages.get(0)); + assertEquals(msg, ep2.messages.get(0)); + assertEquals(msg, ep3.messages.get(0)); + } + + @Test + void testBroadcastBackflowDisabled() throws ReIdFilter.InvalidPatternStringException { + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(ep1, sender, "MSG,,,"); + var action = new RouteAction(() -> router.resolveEndpoints("server", Pattern.compile(".*")), false); + var rule = new ImmutableRule(ObjectType.CHAT_MESSAGE, IFilter.fromPatternString("server:a"), Collections.singletonList(action)); + router.updateRuleChain(Collections.singletonList(rule)); + router.sendMessage(msg); + assertEquals(0, ep1.messages.size()); + assertEquals(1, ep2.messages.size()); + assertEquals(1, ep3.messages.size()); + assertEquals(msg, ep2.messages.get(0)); + assertEquals(msg, ep3.messages.get(0)); + } + + @Test + void testFilteredMessagePass() throws ReIdFilter.InvalidPatternStringException { + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(ep1, sender, "MSG,,,"); + var filter = new ReFilterAction(Pattern.compile("asdasdasda")); + var action = new RouteAction(() -> router.resolveEndpoints("server", Pattern.compile(".*")), false); + var rule1 = new ImmutableRule(ObjectType.CHAT_MESSAGE, IFilter.fromPatternString("server:.+"), Collections.singletonList(filter)); + var rule2 = new ImmutableRule(ObjectType.CHAT_MESSAGE, IFilter.fromPatternString("server:a"), List.of(filter, action)); + var rule3 = new ImmutableRule(ObjectType.CHAT_MESSAGE, IFilter.fromPatternString("server:a"), List.of(action)); + router.updateRuleChain(List.of(rule1, rule2, rule3)); + router.sendMessage(msg); + assertEquals(0, ep1.messages.size()); + assertEquals(1, ep2.messages.size()); + assertEquals(1, ep3.messages.size()); + assertEquals(msg, ep2.messages.get(0)); + assertEquals(msg, ep3.messages.get(0)); + } + + @Test + void testDropMessagePass() throws ReIdFilter.InvalidPatternStringException { + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(ep1, sender, "MSG,,,"); + var drop = new DropAction(); + var action = new RouteAction(() -> router.resolveEndpoints("server", Pattern.compile(".*")), false); + var rule1 = new ImmutableRule(ObjectType.CHAT_MESSAGE, IFilter.fromPatternString("server:a"), List.of(action)); + var rule2 = new ImmutableRule(ObjectType.CHAT_MESSAGE, IFilter.fromPatternString("server:.+"), Collections.singletonList(drop)); + var rule3 = new ImmutableRule(ObjectType.CHAT_MESSAGE, IFilter.fromPatternString("server:a"), List.of(action)); + router.updateRuleChain(List.of(rule1, rule2, rule3)); + router.sendMessage(msg); + assertEquals(0, ep1.messages.size()); + assertEquals(1, ep2.messages.size()); + assertEquals(1, ep3.messages.size()); + assertEquals(msg, ep2.messages.get(0)); + assertEquals(msg, ep3.messages.get(0)); + } + + @AfterEach + void tearDown() throws Exception { + ep1.close(); + ep2.close(); + ep3.close(); + router.close(); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/messaging/rule/ImmutableRuleTest.java b/src/test/java/com/keuin/crosslink/messaging/rule/ImmutableRuleTest.java new file mode 100644 index 0000000..f50425f --- /dev/null +++ b/src/test/java/com/keuin/crosslink/messaging/rule/ImmutableRuleTest.java @@ -0,0 +1,95 @@ +package com.keuin.crosslink.messaging.rule; + +import com.keuin.crosslink.messaging.action.Re2placeAction; +import com.keuin.crosslink.messaging.action.ReFilterAction; +import com.keuin.crosslink.messaging.filter.IFilter; +import com.keuin.crosslink.messaging.filter.ReIdFilter; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.testable.FakeEndpoint; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +class ImmutableRuleTest { + + @Test + void testEmptyRule() throws ReIdFilter.InvalidPatternStringException { + var fromFilter = IFilter.fromPatternString("server:.*"); + var rule = new ImmutableRule(ObjectType.CHAT_MESSAGE, fromFilter, Collections.emptyList()); + var source = new FakeEndpoint("endpoint"); + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(source, sender, "message"); + var result = rule.process(msg); + assertTrue(result.isValid()); + assertEquals(msg, result.getResult()); + } + + @Test + void testSingleFilter() throws ReIdFilter.InvalidPatternStringException { + var fromFilter = IFilter.fromPatternString("server:.*"); + var msgFilter = new ReFilterAction(Pattern.compile("mess...")); + var rule = new ImmutableRule(ObjectType.CHAT_MESSAGE, fromFilter, Collections.singletonList(msgFilter)); + var source = new FakeEndpoint("endpoint"); + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(source, sender, "message"); + var result = rule.process(msg); + assertTrue(result.isValid()); + assertEquals(msg, result.getResult()); + } + + @Test + void testFilterReplace() throws ReIdFilter.InvalidPatternStringException { + var fromFilter = IFilter.fromPatternString("server:.*"); + var msgFilter = new ReFilterAction(Pattern.compile("mess...")); + var msgReplace = new Re2placeAction(Pattern.compile("me."), "u"); + var rule = new ImmutableRule(ObjectType.CHAT_MESSAGE, fromFilter, List.of(msgFilter, msgReplace)); + var source = new FakeEndpoint("endpoint"); + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(source, sender, "message"); + var result = rule.process(msg); + assertTrue(result.isValid()); + assertFalse(result.isFiltered()); + assertFalse(result.isDropped()); + assertEquals("usage", Objects.requireNonNull(result.getResult()).pureString()); + } + + @Test + void testFilterReplaceFilter1() throws ReIdFilter.InvalidPatternStringException { + var fromFilter = IFilter.fromPatternString("server:.*"); + var msgFilter = new ReFilterAction(Pattern.compile("mess...")); + var msgReplace = new Re2placeAction(Pattern.compile("me."), "u"); + var rule = new ImmutableRule(ObjectType.CHAT_MESSAGE, fromFilter, List.of(msgFilter, msgReplace, msgFilter)); + var source = new FakeEndpoint("endpoint"); + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(source, sender, "message"); + var result = rule.process(msg); + assertTrue(result.isFiltered()); + assertFalse(result.isDropped()); + assertFalse(result.isValid()); + } + + @Test + void testFilterReplaceFilter2() throws ReIdFilter.InvalidPatternStringException { + var fromFilter = IFilter.fromPatternString("server:.*"); + var msgFilter = new ReFilterAction(Pattern.compile("mess...")); + var msgReplace = new Re2placeAction(Pattern.compile("me."), "u"); + var msgFilter2 = new ReFilterAction(Pattern.compile("us...")); + var rule = new ImmutableRule(ObjectType.CHAT_MESSAGE, fromFilter, List.of(msgFilter, msgReplace, msgFilter2)); + var source = new FakeEndpoint("endpoint"); + var sender = ISender.create("sender", UUID.randomUUID()); + var msg = IMessage.create(source, sender, "message"); + var result = rule.process(msg); + assertFalse(result.isFiltered()); + assertFalse(result.isDropped()); + assertTrue(result.isValid()); + assertEquals("usage", Objects.requireNonNull(result.getResult()).pureString()); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/testable/FakeEndpoint.java b/src/test/java/com/keuin/crosslink/testable/FakeEndpoint.java new file mode 100644 index 0000000..ada299e --- /dev/null +++ b/src/test/java/com/keuin/crosslink/testable/FakeEndpoint.java @@ -0,0 +1,51 @@ +package com.keuin.crosslink.testable; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class FakeEndpoint implements IEndpoint { + public final List<IMessage> messages = new ArrayList<>(); + public IRouter router = null; + private final String id; + + public FakeEndpoint(String id) { + this.id = id; + } + + public FakeEndpoint() { + this.id = "fake"; + } + + @Override + public void sendMessage(IMessage message) { + Objects.requireNonNull(message); + messages.add(message); + } + + @Override + public void setRouter(IRouter router) { + this.router = router; + } + + @Override + public void close() { + + } + + @Override + public @NotNull String id() { + return id; + } + + @Override + public @NotNull EndpointNamespace namespace() { + return EndpointNamespace.SERVER; + } +} diff --git a/src/test/java/com/keuin/crosslink/testable/FakeRouter.java b/src/test/java/com/keuin/crosslink/testable/FakeRouter.java new file mode 100644 index 0000000..0512b3e --- /dev/null +++ b/src/test/java/com/keuin/crosslink/testable/FakeRouter.java @@ -0,0 +1,65 @@ +package com.keuin.crosslink.testable; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import com.keuin.crosslink.messaging.rule.IRule; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class FakeRouter implements IRouter { + private final List<IMessage> messages = new ArrayList<>(); + private final AtomicBoolean isClosed = new AtomicBoolean(false); + private final List<IEndpoint> endpoints = new ArrayList<>(); + private List<IRule> rules = Collections.emptyList(); + + @Override + public void sendMessage(IMessage message) { + messages.add(message); + } + + @Override + public void close() throws Exception { + isClosed.set(true); + } + + @Override + public boolean addEndpoint(@NotNull IEndpoint endpoint) { + return endpoints.add(endpoint); + } + + @Override + public @NotNull Set<IEndpoint> resolveEndpoints(@NotNull String namespace, @NotNull Pattern idPattern) { + return endpoints.stream() + .filter((ep) -> ep.namespace().toString().equals(namespace) && idPattern.matcher(ep.id()).matches()) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + public void updateRuleChain(@NotNull List<IRule> newChain) { + this.rules = newChain; + } + + public List<IMessage> getMessages() { + return messages; + } + + public AtomicBoolean getIsClosed() { + return isClosed; + } + + public List<IEndpoint> getEndpoints() { + return endpoints; + } + + public List<IRule> getRules() { + return rules; + } +} diff --git a/src/test/java/com/keuin/crosslink/util/EggFactoryTest.java b/src/test/java/com/keuin/crosslink/util/EggFactoryTest.java new file mode 100644 index 0000000..2c26e38 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/util/EggFactoryTest.java @@ -0,0 +1,38 @@ +package com.keuin.crosslink.util; + +import net.time4j.PlainDate; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EggFactoryTest { + + @Test + void getEgg() { + // 2021 lunar new year (2021.02.12) + assertFalse(EggFactory.getEgg(PlainDate.of(2021, 2, 11)).isPresent()); + assertTrue(EggFactory.getEgg(PlainDate.of(2021, 2, 12)).isPresent()); + assertFalse(EggFactory.getEgg(PlainDate.of(2021, 2, 13)).isPresent()); + + // 2022 lunar new year (2022.02.01) + assertFalse(EggFactory.getEgg(PlainDate.of(2022, 1, 31)).isPresent()); + assertTrue(EggFactory.getEgg(PlainDate.of(2022, 2, 1)).isPresent()); + assertFalse(EggFactory.getEgg(PlainDate.of(2022, 2, 2)).isPresent()); + + // 2023 lunar new year (2023.01.22) + assertFalse(EggFactory.getEgg(PlainDate.of(2023, 1, 21)).isPresent()); + assertTrue(EggFactory.getEgg(PlainDate.of(2023, 1, 22)).isPresent()); + assertFalse(EggFactory.getEgg(PlainDate.of(2023, 1, 23)).isPresent()); + + // 2024 lunar new year (2024.02.10) + assertFalse(EggFactory.getEgg(PlainDate.of(2024, 2, 9)).isPresent()); + assertTrue(EggFactory.getEgg(PlainDate.of(2024, 2, 10)).isPresent()); + assertFalse(EggFactory.getEgg(PlainDate.of(2024, 2, 11)).isPresent()); + + // 2025 lunar new year (2025.01.29) + assertFalse(EggFactory.getEgg(PlainDate.of(2025, 1, 28)).isPresent()); + assertTrue(EggFactory.getEgg(PlainDate.of(2025, 1, 29)).isPresent()); + assertFalse(EggFactory.getEgg(PlainDate.of(2025, 1, 30)).isPresent()); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/util/HttpQueryTest.java b/src/test/java/com/keuin/crosslink/util/HttpQueryTest.java new file mode 100644 index 0000000..480c854 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/util/HttpQueryTest.java @@ -0,0 +1,48 @@ +package com.keuin.crosslink.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpQueryTest { + @Test + void testMixedFlagValue() { + var map = HttpQuery.getParamMap("var1=1&flag1"); + assertTrue(map.containsKey("flag1")); + assertTrue(map.containsKey("var1")); + assertEquals("1", map.get("var1")); + assertEquals("", map.get("flag1")); + } + + @Test + void testSingleFlag() { + var map = HttpQuery.getParamMap("flag1"); + assertTrue(map.containsKey("flag1")); + assertEquals("", map.get("flag1")); + } + + @Test + void testSingleVar() { + var map = HttpQuery.getParamMap("var1=value"); + assertTrue(map.containsKey("var1")); + assertEquals("value", map.get("var1")); + } + + @Test + void testMultipleValue() { + var map = HttpQuery.getParamMap("var1=111&var2=222"); + assertTrue(map.containsKey("var1")); + assertTrue(map.containsKey("var2")); + assertEquals("111", map.get("var1")); + assertEquals("222", map.get("var2")); + } + + @Test + void testMultipleKey() { + var map = HttpQuery.getParamMap("flag1&flag2"); + assertTrue(map.containsKey("flag1")); + assertTrue(map.containsKey("flag2")); + assertEquals("", map.get("flag1")); + assertEquals("", map.get("flag2")); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/util/LazyEvaluatedTest.java b/src/test/java/com/keuin/crosslink/util/LazyEvaluatedTest.java new file mode 100644 index 0000000..e8ffdc9 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/util/LazyEvaluatedTest.java @@ -0,0 +1,28 @@ +package com.keuin.crosslink.util; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class LazyEvaluatedTest { + + @Test + void get() { + var flag = new AtomicBoolean(false); // if evaluated + var cnt = new AtomicInteger(0); // counter + var le = new LazyEvaluated<>(() -> { + flag.set(true); + return cnt.getAndIncrement(); + }); + assertFalse(flag.get()); + assertEquals(0, le.get()); + assertTrue(flag.get()); + assertEquals(0, le.get()); + assertEquals(0, le.get()); + assertEquals(0, le.get()); + assertEquals(0, le.get()); + } +}
\ No newline at end of file diff --git a/src/test/java/com/keuin/crosslink/util/LoggerNamingTest.java b/src/test/java/com/keuin/crosslink/util/LoggerNamingTest.java new file mode 100644 index 0000000..5fbdcf1 --- /dev/null +++ b/src/test/java/com/keuin/crosslink/util/LoggerNamingTest.java @@ -0,0 +1,14 @@ +package com.keuin.crosslink.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LoggerNamingTest { + + @Test + void name() { + assertEquals("crosslink", LoggerNaming.name().toString()); + assertEquals("crosslink.actions.replace", LoggerNaming.name().of("actions").of("replace").toString()); + } +}
\ No newline at end of file |