commit b57c74eeb6755c774f47279b885c9f5854b7b6cc Author: Andrew Ferrazzutti Date: Fri Feb 25 02:22:50 2022 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b57937 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +/.idea/ +/.*project +/.settings/ + +/.venv +/env/ +pip-selfcheck.json +*.pyc +__pycache__ +/build +/dist +/*.egg-info +/.eggs + +profiles +puppet/extension_files + +/config*.yaml +/registration*.yaml +*.log* +*.db +*.pickle +*.bak diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/matrix_appservice_kakaotalk/__init__.py b/matrix_appservice_kakaotalk/__init__.py new file mode 100644 index 0000000..e3d360a --- /dev/null +++ b/matrix_appservice_kakaotalk/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.0.1" +__author__ = "Andrew Ferrazzutti " diff --git a/matrix_appservice_kakaotalk/__main__.py b/matrix_appservice_kakaotalk/__main__.py new file mode 100644 index 0000000..3a05681 --- /dev/null +++ b/matrix_appservice_kakaotalk/__main__.py @@ -0,0 +1,129 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import Any + +from mautrix.bridge import Bridge +from mautrix.types import RoomID, UserID + +from .config import Config +from .db import init as init_db, upgrade_table +from .matrix import MatrixHandler +from .portal import Portal +from .puppet import Puppet +from .user import User +from .kt.client import Client as KakaoTalkClient +from .version import linkified_version, version +from .web import PublicBridgeWebsite +from . import commands as _ + + +class KakaoTalkBridge(Bridge): + name = "matrix-appservice-kakaotalk" + module = "matrix_appservice_kakaotalk" + command = "python -m matrix-appservice-kakaotalk" + description = "A Matrix-KakaoTalk puppeting bridge." + repo_url = "https://src.miscworks.net/fair/matrix-appservice-kakaotalk" + version = version + markdown_version = linkified_version + config_class = Config + matrix_class = MatrixHandler + upgrade_table = upgrade_table + + config: Config + matrix: MatrixHandler + public_website: PublicBridgeWebsite | None + + def prepare_config(self)->None: + super().prepare_config() + + def prepare_db(self) -> None: + super().prepare_db() + init_db(self.db) + + def prepare_bridge(self) -> None: + super().prepare_bridge() + if self.config["appservice.public.enabled"]: + secret = self.config["appservice.public.shared_secret"] + self.public_website = PublicBridgeWebsite(loop=self.loop, shared_secret=secret) + self.az.app.add_subapp( + self.config["appservice.public.prefix"], self.public_website.app + ) + else: + self.public_website = None + + def prepare_stop(self) -> None: + self.log.debug("Stopping puppet syncers") + for puppet in Puppet.by_custom_mxid.values(): + puppet.stop() + self.log.debug("Stopping kakaotalk listeners") + User.shutdown = True + for user in User.by_ktid.values(): + user.stop_listen() + self.add_shutdown_actions(user.save() for user in User.by_mxid.values()) + self.add_shutdown_actions(KakaoTalkClient.stop_cls()) + + async def start(self) -> None: + # Block all other startup actions until RPC is ready + # TODO Remove when/if node backend is replaced with native + await KakaoTalkClient.init_cls(self.config) + + self.add_startup_actions(User.init_cls(self)) + self.add_startup_actions(Puppet.init_cls(self)) + Portal.init_cls(self) + if self.config["bridge.resend_bridge_info"]: + self.add_startup_actions(self.resend_bridge_info()) + await super().start() + if self.public_website: + self.public_website.ready_wait.set_result(None) + + async def resend_bridge_info(self) -> None: + self.config["bridge.resend_bridge_info"] = False + self.config.save() + self.log.info("Re-sending bridge info state event to all portals") + async for portal in Portal.all(): + await portal.update_bridge_info() + self.log.info("Finished re-sending bridge info state events") + + async def get_portal(self, room_id: RoomID) -> Portal: + return await Portal.get_by_mxid(room_id) + + async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet: + return await Puppet.get_by_mxid(user_id, create=create) + + async def get_double_puppet(self, user_id: UserID) -> Puppet: + return await Puppet.get_by_custom_mxid(user_id) + + async def get_user(self, user_id: UserID, create: bool = True) -> User: + return await User.get_by_mxid(user_id, create=create) + + def is_bridge_ghost(self, user_id: UserID) -> bool: + return bool(Puppet.get_id_from_mxid(user_id)) + + async def count_logged_in_users(self) -> int: + return len([user for user in User.by_ktid.values() if user.ktid]) + + async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]: + return { + **await super().manhole_global_namespace(user_id), + "User": User, + "Portal": Portal, + "Puppet": Puppet, + } + + +KakaoTalkBridge().run() diff --git a/matrix_appservice_kakaotalk/commands/__init__.py b/matrix_appservice_kakaotalk/commands/__init__.py new file mode 100644 index 0000000..7d42116 --- /dev/null +++ b/matrix_appservice_kakaotalk/commands/__init__.py @@ -0,0 +1,2 @@ +from .auth import SECTION_AUTH#, enter_2fa_code +from .conn import SECTION_CONNECTION diff --git a/matrix_appservice_kakaotalk/commands/auth.py b/matrix_appservice_kakaotalk/commands/auth.py new file mode 100644 index 0000000..75e02be --- /dev/null +++ b/matrix_appservice_kakaotalk/commands/auth.py @@ -0,0 +1,165 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import time + +from yarl import URL + +from mautrix.bridge.commands import HelpSection, command_handler +from mautrix.errors import MForbidden +from mautrix.util.signed_token import sign_token + +from ..kt.client import Client as KakaoTalkClient +from ..kt.client.errors import DeviceVerificationRequired, IncorrectPasscode, IncorrectPassword, CommandException + +#from .. import puppet as pu +from .typehint import CommandEvent + +SECTION_AUTH = HelpSection("Authentication", 10, "") + +web_unsupported = ( + "This instance of the KakaoTalk bridge does not support the web-based login interface" +) +alternative_web_login = ( + "Alternatively, you may use [the web-based login interface]({url}) " + "to prevent the bridge and homeserver from seeing your password" +) +forced_web_login = ( + "This instance of the KakaoTalk bridge does not allow in-Matrix login. " + "Please use [the web-based login interface]({url})." +) +send_password = "Please send your password here to log in" +missing_email = "Please use `$cmdprefix+sp login ` to log in here" +try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up." + + +@command_handler( + needs_auth=False, + management_only=True, + help_section=SECTION_AUTH, + help_text="Log in to KakaoTalk", + help_args="[_email_]", +) +async def login(evt: CommandEvent) -> None: + if evt.sender.client: + await evt.reply("You're already logged in") + return + + email = evt.args[0] if len(evt.args) > 0 else None + + if email: + evt.sender.command_status = { + "action": "Login", + "room_id": evt.room_id, + "next": enter_password, + "email": evt.args[0], + } + + if evt.bridge.public_website: + external_url = URL(evt.config["appservice.public.external"]) + token = sign_token( + evt.bridge.public_website.secret_key, + { + "mxid": evt.sender.mxid, + "expiry": int(time.time()) + 30 * 60, + }, + ) + url = (external_url / "login.html").with_fragment(token) + if not evt.config["appservice.public.allow_matrix_login"]: + await evt.reply(forced_web_login.format(url=url)) + elif email: + await evt.reply(f"{send_password}. {alternative_web_login.format(url=url)}.") + else: + await evt.reply(f"{missing_email}. {alternative_web_login.format(url=url)}.") + elif not email: + await evt.reply(f"{missing_email}. {web_unsupported}.") + else: + await evt.reply(f"{send_password}. {web_unsupported}.") + + +async def enter_password(evt: CommandEvent) -> None: + try: + await evt.az.intent.redact(evt.room_id, evt.event_id) + except MForbidden: + pass + + assert(evt.sender.command_status) + req = { + "uuid": await evt.sender.get_uuid(), + "form": { + "email": evt.sender.command_status["email"], + "password": evt.content.body, + } + } + try: + await _do_login(evt, req) + except DeviceVerificationRequired: + await evt.reply( + "Open KakaoTalk on your smartphone. It should show a device registration passcode. " + "Enter that passcode here." + ) + evt.sender.command_status = { + "action": "Login", + "room_id": evt.room_id, + "next": enter_dv_code, + "req": req, + } + except IncorrectPassword: + await evt.reply(f"Incorrect password. {try_again_or_cancel}") + #except OAuthException as e: + # await evt.reply(f"Error from KakaoTalk:\n\n> {e}") + except Exception as e: + await _handle_login_failure(evt, e) + + +async def enter_dv_code(evt: CommandEvent) -> None: + assert(evt.sender.command_status) + req: dict = evt.sender.command_status["req"] + passcode = evt.content.body + try: + await KakaoTalkClient.register_device(passcode, **req) + await _do_login(evt, req) + except IncorrectPasscode: + await evt.reply(f"Incorrect device registration passcode. {try_again_or_cancel}") + #except OAuthException as e: + # await evt.reply(f"Error from KakaoTalk:\n\n> {e}") + except Exception as e: + await _handle_login_failure(evt, e) + + +async def _do_login(evt: CommandEvent, req: dict) -> None: + oauth_credential = await KakaoTalkClient.login(**req) + await evt.sender.on_logged_in(oauth_credential) + evt.sender.command_status = None + await evt.reply("Successfully logged in") + +async def _handle_login_failure(evt: CommandEvent, e: Exception) -> None: + evt.sender.command_status = None + if isinstance(e, CommandException): + message = "Failed to log in" + evt.log.error(message) + else: + message = "Error while logging in" + evt.log.exception(message) + await evt.reply(f"{message}: {e}") + + +@command_handler(needs_auth=True, help_section=SECTION_AUTH, help_text="Log out of KakaoTalk") +async def logout(evt: CommandEvent) -> None: + #puppet = await pu.Puppet.get_by_ktid(evt.sender.ktid) + await evt.sender.logout() + #if puppet.is_real_user: + # await puppet.switch_mxid(None, None) + await evt.reply("Successfully logged out") diff --git a/matrix_appservice_kakaotalk/commands/conn.py b/matrix_appservice_kakaotalk/commands/conn.py new file mode 100644 index 0000000..00446c4 --- /dev/null +++ b/matrix_appservice_kakaotalk/commands/conn.py @@ -0,0 +1,101 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from mautrix.bridge.commands import HelpSection, command_handler + +from .typehint import CommandEvent + +SECTION_CONNECTION = HelpSection("Connection management", 15, "") + + +@command_handler( + needs_auth=False, + management_only=True, + help_section=SECTION_CONNECTION, + help_text="Mark this room as your bridge notice room", +) +async def set_notice_room(evt: CommandEvent) -> None: + evt.sender.notice_room = evt.room_id + await evt.sender.save() + await evt.reply("This room has been marked as your bridge notice room") + + +""" +@command_handler( + needs_auth=True, + management_only=True, + help_section=SECTION_CONNECTION, + help_text="Disconnect from KakaoTalk", +) +async def disconnect(evt: CommandEvent) -> None: + if not evt.sender.mqtt: + await evt.reply("You don't have a KakaoTalk MQTT connection") + return + evt.sender.mqtt.disconnect() + + +@command_handler( + needs_auth=True, + management_only=True, + help_section=SECTION_CONNECTION, + help_text="Connect to KakaoTalk", + aliases=["reconnect"], +) +async def connect(evt: CommandEvent) -> None: + if evt.sender.listen_task and not evt.sender.listen_task.done(): + await evt.reply("You already have a KakaoTalk MQTT connection") + return + evt.sender.start_listen() +""" + + +@command_handler( + needs_auth=True, + management_only=True, + help_section=SECTION_CONNECTION, + help_text="Check if you're logged into KakaoTalk", +) +async def ping(evt: CommandEvent) -> None: + if not await evt.sender.is_logged_in(): + await evt.reply("You're not logged into KakaoTalk") + return + # try: + own_info = await evt.sender.get_own_info() + # TODO catch errors + # except fbchat.PleaseRefresh as e: + # await evt.reply(f"{e}\n\nUse `$cmdprefix+sp refresh` refresh the session.") + # return + await evt.reply(f"You're logged in as {own_info.nickname} (user ID {evt.sender.ktid})") + + """ + if not evt.sender.listen_task or evt.sender.listen_task.done(): + await evt.reply("You don't have a KakaoTalk MQTT connection. Use `connect` to connect.") + elif not evt.sender.is_connected: + await evt.reply("The KakaoTalk MQTT listener is **disconnected**.") + else: + await evt.reply("The KakaoTalk MQTT listener is connected.") + """ + + +""" +@command_handler( + needs_auth=True, + management_only=True, + help_section=SECTION_CONNECTION, + help_text="Resync chats and reconnect to MQTT", +) +async def refresh(evt: CommandEvent) -> None: + await evt.sender.refresh(force_notice=True) +""" diff --git a/matrix_appservice_kakaotalk/commands/handler.py b/matrix_appservice_kakaotalk/commands/handler.py new file mode 100644 index 0000000..a79d5fc --- /dev/null +++ b/matrix_appservice_kakaotalk/commands/handler.py @@ -0,0 +1,26 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import NamedTuple + +from mautrix.bridge.commands import HelpSection + +HelpCacheKey = NamedTuple("FBHelpCacheKey", is_management=bool, is_admin=bool, is_logged_in=bool) + +SECTION_AUTH = HelpSection("Authentication", 10, "") +SECTION_CONNECTION = HelpSection("Connection management", 15, "") +SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "") +SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "") +SECTION_ADMIN = HelpSection("Administration", 50, "") diff --git a/matrix_appservice_kakaotalk/commands/typehint.py b/matrix_appservice_kakaotalk/commands/typehint.py new file mode 100644 index 0000000..9833394 --- /dev/null +++ b/matrix_appservice_kakaotalk/commands/typehint.py @@ -0,0 +1,12 @@ +from typing import TYPE_CHECKING + +from mautrix.bridge.commands import CommandEvent as BaseCommandEvent + +if TYPE_CHECKING: + from ..__main__ import KakaoTalkBridge + from ..user import User + + +class CommandEvent(BaseCommandEvent): + bridge: "KakaoTalkBridge" + sender: "User" diff --git a/matrix_appservice_kakaotalk/config.py b/matrix_appservice_kakaotalk/config.py new file mode 100644 index 0000000..d98b852 --- /dev/null +++ b/matrix_appservice_kakaotalk/config.py @@ -0,0 +1,163 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import Any +import os + +from mautrix.bridge.config import BaseBridgeConfig +from mautrix.types import UserID +from mautrix.util.config import ConfigUpdateHelper, ForbiddenDefault, ForbiddenKey + + +# TODO Remove unneeded configs!! +class Config(BaseBridgeConfig): + def __getitem__(self, key: str) -> Any: + try: + return os.environ[f"MATRIX_APPSERVICE_KAKAOTALK_{key.replace('.', '_').upper()}"] + except KeyError: + return super().__getitem__(key) + + @property + def forbidden_defaults(self) -> list[ForbiddenDefault]: + return [ + *super().forbidden_defaults, + ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"), + ForbiddenDefault( + "appservice.public.external", + "https://example.com/public", + condition="appservice.public.enabled", + ), + ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")), + ] + + def do_update(self, helper: ConfigUpdateHelper) -> None: + super().do_update(helper) + + copy, copy_dict, base = helper + + copy("homeserver.asmux") + + if self["appservice.bot_avatar"] == "mxc://maunium.net/ddtNPZSKMNqaUzqrHuWvUADv": + base["appservice.bot_avatar"] = "mxc://maunium.net/ygtkteZsXnGJLJHRchUwYWak" + + copy("appservice.public.enabled") + copy("appservice.public.prefix") + copy("appservice.public.external") + if self["appservice.public.shared_secret"] == "generate": + base["appservice.public.shared_secret"] = self._new_token() + else: + copy("appservice.public.shared_secret") + copy("appservice.public.allow_matrix_login") + copy("appservice.public.segment_key") + + copy("metrics.enabled") + copy("metrics.listen_port") + + copy("bridge.username_template") + copy("bridge.displayname_template") + copy("bridge.displayname_preference") + copy("bridge.command_prefix") + + copy("bridge.initial_chat_sync") + copy("bridge.invite_own_puppet_to_pm") + copy("bridge.sync_with_custom_puppets") + copy("bridge.sync_direct_chat_list") + copy("bridge.double_puppet_server_map") + copy("bridge.double_puppet_allow_discovery") + if "bridge.login_shared_secret" in self: + base["bridge.login_shared_secret_map"] = { + base["homeserver.domain"]: self["bridge.login_shared_secret"] + } + else: + copy("bridge.login_shared_secret_map") + copy("bridge.update_avatar_initial_sync") + copy("bridge.encryption.allow") + copy("bridge.encryption.default") + copy("bridge.encryption.key_sharing.allow") + copy("bridge.encryption.key_sharing.require_cross_signing") + copy("bridge.encryption.key_sharing.require_verification") + copy("bridge.delivery_receipts") + copy("bridge.federate_rooms") + copy("bridge.allow_invites") + copy("bridge.backfill.invite_own_puppet") + copy("bridge.backfill.initial_limit") + copy("bridge.backfill.missed_limit") + copy("bridge.backfill.disable_notifications") + if "bridge.periodic_reconnect_interval" in self: + base["bridge.periodic_reconnect.interval"] = self["bridge.periodic_reconnect_interval"] + base["bridge.periodic_reconnect.mode"] = self["bridge.periodic_reconnect_mode"] + else: + copy("bridge.periodic_reconnect.interval") + copy("bridge.periodic_reconnect.mode") + copy("bridge.periodic_reconnect.always") + copy("bridge.periodic_reconnect.min_connected_time") + copy("bridge.resync_max_disconnected_time") + copy("bridge.sync_on_startup") + copy("bridge.temporary_disconnect_notices") + copy("bridge.disable_bridge_notices") + if "bridge.refresh_on_reconnection_fail" in self: + base["bridge.on_reconnection_fail.action"] = ( + "refresh" if self["bridge.refresh_on_reconnection_fail"] else None + ) + base["bridge.on_reconnection_fail.wait_for"] = 0 + elif "bridge.on_reconnection_fail.refresh" in self: + base["bridge.on_reconnection_fail.action"] = ( + "refresh" if self["bridge.on_reconnection_fail.refresh"] else None + ) + copy("bridge.on_reconnection_fail.wait_for") + else: + copy("bridge.on_reconnection_fail.action") + copy("bridge.on_reconnection_fail.wait_for") + copy("bridge.resend_bridge_info") + copy("bridge.mute_bridging") + copy("bridge.tag_only_on_create") + copy("bridge.sandbox_media_download") + + copy_dict("bridge.permissions") + + for key in ( + "bridge.periodic_reconnect.interval", + "bridge.on_reconnection_fail.wait_for", + ): + value = base.get(key, None) + if isinstance(value, list) and len(value) != 2: + raise ValueError(f"{key} must only be a list of two items") + + copy("rpc.connection.type") + if base["rpc.connection.type"] == "unix": + copy("rpc.connection.path") + else: + copy("rpc.connection.host") + copy("rpc.connection.port") + + def _get_permissions(self, key: str) -> tuple[bool, bool, bool, str]: + level = self["bridge.permissions"].get(key, "") + admin = level == "admin" + user = level == "user" or admin + relay = level == "relay" or user + return relay, user, admin, level + + def get_permissions(self, mxid: UserID) -> tuple[bool, bool, bool, str]: + permissions = self["bridge.permissions"] or {} + if mxid in permissions: + return self._get_permissions(mxid) + + homeserver = mxid[mxid.index(":") + 1 :] + if homeserver in permissions: + return self._get_permissions(homeserver) + + return self._get_permissions("*") diff --git a/matrix_appservice_kakaotalk/db/__init__.py b/matrix_appservice_kakaotalk/db/__init__.py new file mode 100644 index 0000000..8c3c932 --- /dev/null +++ b/matrix_appservice_kakaotalk/db/__init__.py @@ -0,0 +1,25 @@ +from mautrix.util.async_db import Database + +from .message import Message +from .portal import Portal +from .puppet import Puppet +from .reaction import Reaction +from .upgrade import upgrade_table +from .user import User + + +def init(db: Database) -> None: + for table in (Portal, Message, Reaction, User, Puppet): + table.db = db + + +__all__ = [ + "upgrade_table", + "init", + "Message", + "Reaction", + "Portal", + "ThreadType", + "Puppet", + "User", +] diff --git a/matrix_appservice_kakaotalk/db/message.py b/matrix_appservice_kakaotalk/db/message.py new file mode 100644 index 0000000..847740f --- /dev/null +++ b/matrix_appservice_kakaotalk/db/message.py @@ -0,0 +1,160 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from asyncpg import Record +from attr import dataclass + +from mautrix.types import EventID, RoomID +from mautrix.util.async_db import Database + +fake_db = Database.create("") if TYPE_CHECKING else None + + +@dataclass +class Message: + db: ClassVar[Database] = fake_db + + mxid: EventID + mx_room: RoomID + ktid: str | None + kt_txn_id: int | None + index: int + kt_chat: int + kt_receiver: int + kt_sender: int + timestamp: int + + @classmethod + def _from_row(cls, row: Record | None) -> Message | None: + if row is None: + return None + return cls(**row) + + columns = 'mxid, mx_room, ktid, kt_txn_id, "index", kt_chat, kt_receiver, kt_sender, timestamp' + + @classmethod + async def get_all_by_ktid(cls, ktid: str, kt_receiver: int) -> list[Message]: + q = f"SELECT {cls.columns} FROM message WHERE ktid=$1 AND kt_receiver=$2" + rows = await cls.db.fetch(q, ktid, kt_receiver) + return [cls._from_row(row) for row in rows] + + @classmethod + async def get_by_ktid(cls, ktid: str, kt_receiver: int, index: int = 0) -> Message | None: + q = f'SELECT {cls.columns} FROM message WHERE ktid=$1 AND kt_receiver=$2 AND "index"=$3' + row = await cls.db.fetchrow(q, ktid, kt_receiver, index) + return cls._from_row(row) + + @classmethod + async def get_by_ktid_or_oti( + cls, ktid: str, oti: int, kt_receiver: int, kt_sender: int, index: int = 0 + ) -> Message | None: + q = ( + f"SELECT {cls.columns} " + "FROM message WHERE (ktid=$1 OR (kt_txn_id=$2 AND kt_sender=$3)) AND " + ' kt_receiver=$4 AND "index"=$5' + ) + row = await cls.db.fetchrow(q, ktid, oti, kt_sender, kt_receiver, index) + return cls._from_row(row) + + @classmethod + async def delete_all_by_room(cls, room_id: RoomID) -> None: + await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id) + + @classmethod + async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Message | None: + q = f"SELECT {cls.columns} FROM message WHERE mxid=$1 AND mx_room=$2" + row = await cls.db.fetchrow(q, mxid, mx_room) + return cls._from_row(row) + + @classmethod + async def get_most_recent(cls, kt_chat: int, kt_receiver: int) -> Message | None: + q = ( + f"SELECT {cls.columns} " + "FROM message WHERE kt_chat=$1 AND kt_receiver=$2 AND ktid IS NOT NULL " + "ORDER BY timestamp DESC LIMIT 1" + ) + row = await cls.db.fetchrow(q, kt_chat, kt_receiver) + return cls._from_row(row) + + @classmethod + async def get_closest_before( + cls, kt_chat: int, kt_receiver: int, timestamp: int + ) -> Message | None: + q = ( + f"SELECT {cls.columns} " + "FROM message WHERE kt_chat=$1 AND kt_receiver=$2 AND timestamp<=$3 AND " + " ktid IS NOT NULL " + "ORDER BY timestamp DESC LIMIT 1" + ) + row = await cls.db.fetchrow(q, kt_chat, kt_receiver, timestamp) + return cls._from_row(row) + + _insert_query = ( + 'INSERT INTO message (mxid, mx_room, ktid, kt_txn_id, "index", kt_chat, kt_receiver, ' + " kt_sender, timestamp) " + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" + ) + + @classmethod + async def bulk_create( + cls, + ktid: str, + oti: int, + kt_chat: int, + kt_receiver: int, + kt_sender: int, + event_ids: list[EventID], + timestamp: int, + mx_room: RoomID, + ) -> None: + if not event_ids: + return + columns = [col.strip('"') for col in cls.columns.split(", ")] + records = [ + (mxid, mx_room, ktid, oti, index, kt_chat, kt_receiver, kt_sender, timestamp) + for index, mxid in enumerate(event_ids) + ] + async with cls.db.acquire() as conn, conn.transaction(): + if cls.db.scheme == "postgres": + await conn.copy_records_to_table("message", records=records, columns=columns) + else: + await conn.executemany(cls._insert_query, records) + + async def insert(self) -> None: + q = self._insert_query + await self.db.execute( + q, + self.mxid, + self.mx_room, + self.ktid, + self.kt_txn_id, + self.index, + self.kt_chat, + self.kt_receiver, + self.kt_sender, + self.timestamp, + ) + + async def delete(self) -> None: + q = 'DELETE FROM message WHERE ktid=$1 AND kt_receiver=$2 AND "index"=$3' + await self.db.execute(q, self.ktid, self.kt_receiver, self.index) + + async def update(self) -> None: + q = "UPDATE message SET ktid=$1, timestamp=$2 WHERE mxid=$3 AND mx_room=$4" + await self.db.execute(q, self.ktid, self.timestamp, self.mxid, self.mx_room) diff --git a/matrix_appservice_kakaotalk/db/portal.py b/matrix_appservice_kakaotalk/db/portal.py new file mode 100644 index 0000000..ead8b38 --- /dev/null +++ b/matrix_appservice_kakaotalk/db/portal.py @@ -0,0 +1,133 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from asyncpg import Record +from attr import dataclass + +from mautrix.types import ContentURI, RoomID, UserID +from mautrix.util.async_db import Database + +from ..kt.types.bson import Long +from ..kt.types.channel.channel_type import ChannelType + +fake_db = Database.create("") if TYPE_CHECKING else None + + +@dataclass +class Portal: + db: ClassVar[Database] = fake_db + + ktid: Long + kt_receiver: Long + kt_type: ChannelType + mxid: RoomID | None + name: str | None + photo_id: str | None + avatar_url: ContentURI | None + encrypted: bool + name_set: bool + avatar_set: bool + relay_user_id: UserID | None + + @classmethod + def _from_row(cls, row: Record) -> Portal: + data = {**row} + ktid = data.pop("ktid") + kt_receiver = data.pop("kt_receiver") + return cls(**data, ktid=Long.from_optional_bytes(ktid), kt_receiver=Long.from_optional_bytes(kt_receiver)) + + @classmethod + def _from_optional_row(cls, row: Record | None) -> Portal | None: + return cls._from_row(row) if row is not None else None + + @classmethod + async def get_by_ktid(cls, ktid: Long, kt_receiver: Long) -> Portal | None: + q = """ + SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted, + name_set, avatar_set, relay_user_id + FROM portal WHERE ktid=$1 AND kt_receiver=$2 + """ + row = await cls.db.fetchrow(q, bytes(ktid), bytes(kt_receiver)) + return cls._from_optional_row(row) + + @classmethod + async def get_by_mxid(cls, mxid: RoomID) -> Portal | None: + q = """ + SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted, + name_set, avatar_set, relay_user_id + FROM portal WHERE mxid=$1 + """ + row = await cls.db.fetchrow(q, mxid) + return cls._from_optional_row(row) + + @classmethod + async def get_all_by_receiver(cls, kt_receiver: Long) -> list[Portal]: + q = """ + SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted, + name_set, avatar_set, relay_user_id + FROM portal WHERE kt_receiver=$1 + """ + rows = await cls.db.fetch(q, bytes(kt_receiver)) + return [cls._from_row(row) for row in rows if row] + + @classmethod + async def all(cls) -> list[Portal]: + q = """ + SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted, + name_set, avatar_set, relay_user_id + FROM portal + """ + rows = await cls.db.fetch(q) + return [cls._from_row(row) for row in rows if row] + + @property + def _values(self): + return ( + Long.to_optional_bytes(self.ktid), + Long.to_optional_bytes(self.kt_receiver), + self.kt_type, + self.mxid, + self.name, + self.photo_id, + self.avatar_url, + self.encrypted, + self.name_set, + self.avatar_set, + self.relay_user_id, + ) + + async def insert(self) -> None: + q = """ + INSERT INTO portal (ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, + encrypted, name_set, avatar_set, relay_user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + """ + await self.db.execute(q, *self._values) + + async def delete(self) -> None: + q = "DELETE FROM portal WHERE ktid=$1 AND kt_receiver=$2" + await self.db.execute(q, Long.to_optional_bytes(self.ktid), Long.to_optional_bytes(self.kt_receiver)) + + async def save(self) -> None: + q = """ + UPDATE portal SET kt_type=$3, mxid=$4, name=$5, photo_id=$6, avatar_url=$7, + encrypted=$8, name_set=$9, avatar_set=$10, relay_user_id=$11 + WHERE ktid=$1 AND kt_receiver=$2 + """ + await self.db.execute(q, *self._values) diff --git a/matrix_appservice_kakaotalk/db/puppet.py b/matrix_appservice_kakaotalk/db/puppet.py new file mode 100644 index 0000000..937949c --- /dev/null +++ b/matrix_appservice_kakaotalk/db/puppet.py @@ -0,0 +1,135 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from asyncpg import Record +from attr import dataclass +from yarl import URL + +from mautrix.types import ContentURI, SyncToken, UserID +from mautrix.util.async_db import Database + +from ..kt.types.bson import Long + +fake_db = Database.create("") if TYPE_CHECKING else None + + +@dataclass +class Puppet: + db: ClassVar[Database] = fake_db + + ktid: Long + name: str | None + photo_id: str | None + photo_mxc: ContentURI | None + name_set: bool + avatar_set: bool + is_registered: bool + + custom_mxid: UserID | None + access_token: str | None + next_batch: SyncToken | None + base_url: URL | None + + @classmethod + def _from_row(cls, row: Record) -> Puppet: + data = {**row} + ktid = data.pop("ktid") + base_url = data.pop("base_url", None) + return cls(**data, ktid=Long.from_optional_bytes(ktid), base_url=URL(base_url) if base_url else None) + + @classmethod + def _from_optional_row(cls, row: Record | None) -> Puppet | None: + return cls._from_row(row) if row is not None else None + + @classmethod + async def get_by_ktid(cls, ktid: Long) -> Puppet | None: + q = ( + "SELECT ktid, name, photo_id, photo_mxc, name_set, avatar_set, is_registered, " + " custom_mxid, access_token, next_batch, base_url " + "FROM puppet WHERE ktid=$1" + ) + row = await cls.db.fetchrow(q, bytes(ktid)) + return cls._from_optional_row(row) + + @classmethod + async def get_by_name(cls, name: str) -> Puppet | None: + q = ( + "SELECT ktid, name, photo_id, photo_mxc, name_set, avatar_set, is_registered, " + " custom_mxid, access_token, next_batch, base_url " + "FROM puppet WHERE name=$1" + ) + row = await cls.db.fetchrow(q, name) + return cls._from_optional_row(row) + + @classmethod + async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None: + q = ( + "SELECT ktid, name, photo_id, photo_mxc, name_set, avatar_set, is_registered, " + " custom_mxid, access_token, next_batch, base_url " + "FROM puppet WHERE custom_mxid=$1" + ) + row = await cls.db.fetchrow(q, mxid) + return cls._from_optional_row(row) + + @classmethod + async def get_all_with_custom_mxid(cls) -> list[Puppet]: + q = ( + "SELECT ktid, name, photo_id, photo_mxc, name_set, avatar_set, is_registered, " + " custom_mxid, access_token, next_batch, base_url " + "FROM puppet WHERE custom_mxid<>''" + ) + rows = await cls.db.fetch(q) + return [cls._from_row(row) for row in rows if row] + + @property + def _values(self): + return ( + bytes(self.ktid), + self.name, + self.photo_id, + self.photo_mxc, + self.name_set, + self.avatar_set, + self.is_registered, + self.custom_mxid, + self.access_token, + self.next_batch, + str(self.base_url) if self.base_url else None, + ) + + async def insert(self) -> None: + q = """ + INSERT INTO puppet (ktid, name, photo_id, photo_mxc, name_set, avatar_set, + is_registered, custom_mxid, access_token, next_batch, base_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + """ + await self.db.execute(q, *self._values) + + async def delete(self) -> None: + q = "DELETE FROM puppet WHERE ktid=$1" + await self.db.execute(q, bytes(self.ktid)) + + async def save(self) -> None: + q = """ + UPDATE puppet SET name=$2, photo_id=$3, photo_mxc=$4, name_set=$5, avatar_set=$6, + is_registered=$7, custom_mxid=$8, access_token=$9, next_batch=$10, + base_url=$11 + WHERE ktid=$1 + """ + await self.db.execute(q, *self._values) diff --git a/matrix_appservice_kakaotalk/db/reaction.py b/matrix_appservice_kakaotalk/db/reaction.py new file mode 100644 index 0000000..c7b3bd1 --- /dev/null +++ b/matrix_appservice_kakaotalk/db/reaction.py @@ -0,0 +1,91 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from asyncpg import Record +from attr import dataclass + +from mautrix.types import EventID, RoomID +from mautrix.util.async_db import Database + +fake_db = Database.create("") if TYPE_CHECKING else None + + +@dataclass +class Reaction: + db: ClassVar[Database] = fake_db + + mxid: EventID + mx_room: RoomID + kt_msgid: str + kt_receiver: int + kt_sender: int + reaction: str + + @classmethod + def _from_row(cls, row: Record | None) -> Reaction | None: + if row is None: + return None + return cls(**row) + + @classmethod + async def get_by_ktid(cls, kt_msgid: str, kt_receiver: int, kt_sender: int) -> Reaction | None: + q = ( + "SELECT mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction " + "FROM reaction WHERE kt_msgid=$1 AND kt_receiver=$2 AND kt_sender=$3" + ) + row = await cls.db.fetchrow(q, kt_msgid, kt_receiver, kt_sender) + return cls._from_row(row) + + @classmethod + async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None: + q = ( + "SELECT mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction " + "FROM reaction WHERE mxid=$1 AND mx_room=$2" + ) + row = await cls.db.fetchrow(q, mxid, mx_room) + return cls._from_row(row) + + @property + def _values(self): + return ( + self.mxid, + self.mx_room, + self.kt_msgid, + self.kt_receiver, + self.kt_sender, + self.reaction, + ) + + async def insert(self) -> None: + q = ( + "INSERT INTO reaction (mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction) " + "VALUES ($1, $2, $3, $4, $5, $6)" + ) + await self.db.execute(q, *self._values) + + async def delete(self) -> None: + q = "DELETE FROM reaction WHERE kt_msgid=$1 AND kt_receiver=$2 AND kt_sender=$3" + await self.db.execute(q, self.kt_msgid, self.kt_receiver, self.kt_sender) + + async def save(self) -> None: + q = ( + "UPDATE reaction SET mxid=$1, mx_room=$2, reaction=$6 " + "WHERE kt_msgid=$3 AND kt_receiver=$4 AND kt_sender=$5" + ) + await self.db.execute(q, *self._values) diff --git a/matrix_appservice_kakaotalk/db/upgrade/__init__.py b/matrix_appservice_kakaotalk/db/upgrade/__init__.py new file mode 100644 index 0000000..146e713 --- /dev/null +++ b/matrix_appservice_kakaotalk/db/upgrade/__init__.py @@ -0,0 +1,5 @@ +from mautrix.util.async_db import UpgradeTable + +upgrade_table = UpgradeTable() + +from . import v01_initial_revision diff --git a/matrix_appservice_kakaotalk/db/upgrade/v01_initial_revision.py b/matrix_appservice_kakaotalk/db/upgrade/v01_initial_revision.py new file mode 100644 index 0000000..e37dbf4 --- /dev/null +++ b/matrix_appservice_kakaotalk/db/upgrade/v01_initial_revision.py @@ -0,0 +1,99 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from mautrix.util.async_db import Connection + +from . import upgrade_table + + +@upgrade_table.register(description="Initial revision", transaction=False) +async def upgrade_v1(conn: Connection) -> None: + async with conn.transaction(): + await create_v1_tables(conn) + + +async def create_v1_tables(conn: Connection) -> None: + await conn.execute( + """CREATE TABLE "user" ( + mxid TEXT PRIMARY KEY, + ktid BYTES UNIQUE, + uuid TEXT, + access_token TEXT, + refresh_token TEXT, + notice_room TEXT + )""" + ) + await conn.execute( + """CREATE TABLE portal ( + ktid BYTES, + kt_receiver BYTES, + kt_type TEXT, + mxid TEXT UNIQUE, + name TEXT, + photo_id TEXT, + avatar_url TEXT, + encrypted BOOLEAN NOT NULL DEFAULT false, + name_set BOOLEAN NOT NULL DEFAULT false, + avatar_set BOOLEAN NOT NULL DEFAULT false, + relay_user_id TEXT, + PRIMARY KEY (ktid, kt_receiver) + )""" + ) + await conn.execute( + """CREATE TABLE puppet ( + ktid BYTES PRIMARY KEY, + name TEXT, + photo_id TEXT, + photo_mxc TEXT, + + name_set BOOLEAN NOT NULL DEFAULT false, + avatar_set BOOLEAN NOT NULL DEFAULT false, + is_registered BOOLEAN NOT NULL DEFAULT false, + + custom_mxid TEXT, + access_token TEXT, + next_batch TEXT, + base_url TEXT + )""" + ) + await conn.execute( + """CREATE TABLE message ( + mxid TEXT, + mx_room TEXT, + ktid TEXT, + kt_receiver BYTES, + "index" SMALLINT, + kt_chat BYTES, + timestamp BIGINT, + PRIMARY KEY (ktid, kt_receiver, "index"), + FOREIGN KEY (kt_chat, kt_receiver) REFERENCES portal(ktid, kt_receiver) + ON UPDATE CASCADE ON DELETE CASCADE, + UNIQUE (mxid, mx_room) + )""" + ) + await conn.execute( + """CREATE TABLE reaction ( + mxid TEXT, + mx_room TEXT, + kt_msgid TEXT, + kt_receiver BYTES, + kt_sender BYTES, + reaction TEXT, + PRIMARY KEY (kt_msgid, kt_receiver, kt_sender), + UNIQUE (mxid, mx_room) + )""" + ) diff --git a/matrix_appservice_kakaotalk/db/user.py b/matrix_appservice_kakaotalk/db/user.py new file mode 100644 index 0000000..005621f --- /dev/null +++ b/matrix_appservice_kakaotalk/db/user.py @@ -0,0 +1,97 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, List, Set + +from asyncpg import Record +from attr import dataclass + +from mautrix.types import RoomID, UserID +from mautrix.util.async_db import Database + +from ..kt.types.bson import Long + +fake_db = Database.create("") if TYPE_CHECKING else None + + +@dataclass +class User: + db: ClassVar[Database] = fake_db + + mxid: UserID + ktid: Long | None + uuid: str | None + access_token: str | None + refresh_token: str | None + notice_room: RoomID | None + + @classmethod + def _from_row(cls, row: Record) -> User: + data = {**row} + ktid = data.pop("ktid", None) + return cls(**data, ktid=Long.from_optional_bytes(ktid)) + + @classmethod + def _from_optional_row(cls, row: Record | None) -> User | None: + return cls._from_row(row) if row is not None else None + + @classmethod + async def all_logged_in(cls) -> List[User]: + q = """ + SELECT mxid, ktid, uuid, access_token, refresh_token, notice_room FROM "user" + WHERE ktid<>0 + """ + rows = await cls.db.fetch(q) + return [cls._from_row(row) for row in rows if row] + + @classmethod + async def get_by_ktid(cls, ktid: Long) -> User | None: + q = 'SELECT mxid, ktid, uuid, access_token, refresh_token, notice_room FROM "user" WHERE ktid=$1' + row = await cls.db.fetchrow(q, bytes(ktid)) + return cls._from_optional_row(row) + + @classmethod + async def get_by_mxid(cls, mxid: UserID) -> User | None: + q = 'SELECT mxid, ktid, uuid, access_token, refresh_token, notice_room FROM "user" WHERE mxid=$1' + row = await cls.db.fetchrow(q, mxid) + return cls._from_optional_row(row) + + @classmethod + async def get_all_uuids(cls) -> Set[str]: + q = 'SELECT uuid FROM "user" WHERE uuid IS NOT NULL' + return {tuple(record)[0] for record in await cls.db.fetch(q)} + + async def insert(self) -> None: + q = """ + INSERT INTO "user" (mxid, ktid, uuid, access_token, refresh_token, notice_room) + VALUES ($1, $2, $3, $4, $5, $6) + """ + await self.db.execute( + q, self.mxid, Long.to_optional_bytes(self.ktid), self.uuid, self.access_token, self.refresh_token, self.notice_room + ) + + async def delete(self) -> None: + await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid) + + async def save(self) -> None: + q = """ + UPDATE "user" SET ktid=$1, uuid=$2, access_token=$3, refresh_token=$4, notice_room=$5 + WHERE mxid=$6 + """ + await self.db.execute( + q, Long.to_optional_bytes(self.ktid), self.uuid, self.access_token, self.refresh_token, self.notice_room, self.mxid + ) diff --git a/matrix_appservice_kakaotalk/example-config.yaml b/matrix_appservice_kakaotalk/example-config.yaml new file mode 100644 index 0000000..3197d93 --- /dev/null +++ b/matrix_appservice_kakaotalk/example-config.yaml @@ -0,0 +1,322 @@ +# Homeserver details +homeserver: + # The address that this appservice can use to connect to the homeserver. + address: https://example.com + # The domain of the homeserver (for MXIDs, etc). + domain: example.com + # Whether or not to verify the SSL certificate of the homeserver. + # Only applies if address starts with https:// + verify_ssl: true + # Whether or not the homeserver supports asmux-specific endpoints, + # such as /_matrix/client/unstable/net.maunium.asmux/dms for atomically + # updating m.direct. + asmux: false + # Number of retries for all HTTP requests if the homeserver isn't reachable. + http_retry_count: 4 + # The URL to push real-time bridge status to. + # If set, the bridge will make POST requests to this URL whenever a user's Facebook MQTT connection state changes. + # The bridge will use the appservice as_token to authorize requests. + status_endpoint: null + # Endpoint for reporting per-message status. + message_send_checkpoint_endpoint: null + +# Application service host/registration related details +# Changing these values requires regeneration of the registration. +appservice: + # The address that the homeserver can use to connect to this appservice. + address: http://localhost:11115 + + # The hostname and port where this appservice should listen. + hostname: localhost + port: 11115 + # The maximum body size of appservice API requests (from the homeserver) in mebibytes + # Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s + max_body_size: 1 + + # The full URI to the database. SQLite and Postgres are supported. + # Format examples: + # SQLite: sqlite:///filename.db + # Postgres: postgres://username:password@hostname/dbname + database: postgres://username:password@hostname/db + # Additional arguments for asyncpg.create_pool() or sqlite3.connect() + # https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool + # https://docs.python.org/3/library/sqlite3.html#sqlite3.connect + # For sqlite, min_size is used as the connection thread pool size and max_size is ignored. + database_opts: + min_size: 5 + max_size: 10 + + # Public part of web server for out-of-Matrix interaction with the bridge. + public: + # Whether or not the public-facing endpoints should be enabled. + enabled: false + # The prefix to use in the public-facing endpoints. + prefix: /public + # The base URL where the public-facing endpoints are available. The prefix is not added + # implicitly. + external: https://example.com/public + # Shared secret for integration managers such as mautrix-manager. + # If set to "generate", a random string will be generated on the next startup. + # If null, integration manager access to the API will not be possible. + shared_secret: generate + # Allow logging in within Matrix. If false, users can only log in using the web interface. + allow_matrix_login: true + # Segment API key to enable analytics tracking for web server endpoints. Set to null to disable. + # Currently the only events are login start, success and fail. + segment_key: null + + # The unique ID of this appservice. + id: kakaotalk + # Username of the appservice bot. + bot_username: kakaotalkbot + # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty + # to leave display name/avatar as-is. + bot_displayname: KakaoTalk bridge bot + bot_avatar: + + # Whether or not to receive ephemeral events via appservice transactions. + # Requires MSC2409 support (i.e. Synapse 1.22+). + # You should disable bridge -> sync_with_custom_puppets when this is enabled. + ephemeral_events: false + + # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. + as_token: "This value is generated when generating the registration" + hs_token: "This value is generated when generating the registration" + +# Prometheus telemetry config. Requires prometheus-client to be installed. +metrics: + enabled: false + listen_port: 8000 + +# Manhole config. +manhole: + # Whether or not opening the manhole is allowed. + enabled: false + # The path for the unix socket. + path: /var/tmp/matrix-appservice-kakaotalk.manhole + # The list of UIDs who can be added to the whitelist. + # If empty, any UIDs can be specified in the open-manhole command. + whitelist: + - 0 + +# Bridge config +bridge: + # Localpart template of MXIDs for KakaoTalk users. + # {userid} is replaced with the user ID of the KakaoTalk user. + username_template: "kakaotalk_{userid}" + # Displayname template for KakaoTalk users. + # {displayname} is replaced with the display name of the KakaoTalk user + # as defined below in displayname_preference. + # Keys available for displayname_preference are also available here. + displayname_template: "{displayname} (KT)" + # Available keys: TODO + # "name" (full name) + # "first_name" + # "last_name" + # "nickname" + # "own_nickname" (user-specific!) + displayname_preference: + - nickname + + # The prefix for commands. Only required in non-management rooms. + command_prefix: "!kt" + + # Number of chats to sync (and create portals for) on startup/login. + # Set 0 to disable automatic syncing. + initial_chat_sync: 20 + # Whether or not the KakaoTalk users of logged in Matrix users should be + # invited to private chats when the user sends a message from another client. + invite_own_puppet_to_pm: false + # Whether or not to use /sync to get presence, read receipts and typing notifications + # when double puppeting is enabled + sync_with_custom_puppets: true + # Whether or not to update the m.direct account data event when double puppeting is enabled. + # Note that updating the m.direct event is not atomic (except with mautrix-asmux) + # and is therefore prone to race conditions. + sync_direct_chat_list: false + # Servers to always allow double puppeting from + double_puppet_server_map: + example.com: https://example.com + # Allow using double puppeting from any server with a valid client .well-known file. + double_puppet_allow_discovery: false + # Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth + # + # If set, custom puppets will be enabled automatically for local users + # instead of users having to find an access token and run `login-matrix` + # manually. + # If using this for other servers than the bridge's server, + # you must also set the URL in the double_puppet_server_map. + login_shared_secret_map: + example.com: foobar + # Whether or not to update avatars when syncing all contacts at startup. + update_avatar_initial_sync: true + # End-to-bridge encryption support options. These require matrix-nio to be installed with pip + # and login_shared_secret to be configured in order to get a device for the bridge bot. + # + # Additionally, https://github.com/matrix-org/synapse/pull/5758 is required if using a normal + # application service. + encryption: + # Allow encryption, work in group chat rooms with e2ee enabled + allow: false + # Default to encryption, force-enable encryption in all portals the bridge creates + # This will cause the bridge bot to be in private chats for the encryption to work properly. + default: false + # Options for automatic key sharing. + key_sharing: + # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. + # You must use a client that supports requesting keys from other users to use this feature. + allow: false + # Require the requesting device to have a valid cross-signing signature? + # This doesn't require that the bridge has verified the device, only that the user has verified it. + # Not yet implemented. + require_cross_signing: false + # Require devices to be verified by the bridge? + # Verification by the bridge is not yet implemented. + require_verification: true + # Whether or not the bridge should send a read receipt from the bridge bot when a message has + # been sent to KakaoTalk. + delivery_receipts: false + # Whether to allow inviting arbitrary mxids to portal rooms + allow_invites: false + # Whether or not created rooms should have federation enabled. + # If false, created portal rooms will never be federated. + federate_rooms: true + # Settings for backfilling messages from KakaoTalk. + backfill: + # Whether or not the KakaoTalk users of logged in Matrix users should be + # invited to private chats when backfilling history from KakaoTalk. This is + # usually needed to prevent rate limits and to allow timestamp massaging. + # TODO Is this necessary? + invite_own_puppet: true + # Maximum number of messages to backfill initially. + # Set to 0 to disable backfilling when creating portal. + initial_limit: 0 + # Maximum number of messages to backfill if messages were missed while + # the bridge was disconnected. + # Set to 0 to disable backfilling missed messages. + missed_limit: 1000 + # If using double puppeting, should notifications be disabled + # while the initial backfill is in progress? + disable_notifications: false + # TODO Confirm this isn't needed + #periodic_reconnect: + # # Interval in seconds in which to automatically reconnect all users. + # # This can be used to automatically mitigate the bug where KakaoTalk stops sending messages. + # # Set to -1 to disable periodic reconnections entirely. + # # Set to a list of two items to randomize the interval (min, max). + # interval: -1 + # # What to do in periodic reconnects. Either "refresh" or "reconnect" + # mode: refresh + # # Should even disconnected users be reconnected? + # always: false + # # Only reconnect if the user has been connected for longer than this value + # min_connected_time: 0 + # The number of seconds that a disconnection can last without triggering an automatic re-sync + # and missed message backfilling when reconnecting. + # Set to 0 to always re-sync, or -1 to never re-sync automatically. + resync_max_disconnected_time: 5 + # Should the bridge do a resync on startup? + sync_on_startup: true + # Whether or not temporary disconnections should send notices to the notice room. + # If this is false, disconnections will never send messages and connections will only send + # messages if it was disconnected for more than resync_max_disconnected_time seconds. + # TODO Probably don't need this + temporary_disconnect_notices: true + # Disable bridge notices entirely + disable_bridge_notices: false + on_reconnection_fail: + # Whether or not the bridge should try to "refresh" the connection if a normal reconnection + # attempt fails. + refresh: false + # Seconds to wait before attempting to refresh the connection, set a list of two items to + # to randomize the interval (min, max). + wait_for: 0 + # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. + # This field will automatically be changed back to false after it, + # except if the config file is not writable. + resend_bridge_info: false + # When using double puppeting, should muted chats be muted in Matrix? + mute_bridging: false + # Whether or not mute status and tags should only be bridged when the portal room is created. + tag_only_on_create: true + # If set to true, downloading media from the CDN will use a plain aiohttp client without the usual headers or + # other configuration. This may be useful if you don't want to use the default proxy for large files. + sandbox_media_download: false + + # Permissions for using the bridge. + # Permitted values: + # relay - Allowed to be relayed through the bridge, no access to commands. + # user - Use the bridge with puppeting. + # admin - Use and administrate the bridge. + # Permitted keys: + # * - All Matrix users + # domain - All users on that homeserver + # mxid - Specific user + permissions: + "*": "relay" + "example.com": "user" + "@admin:example.com": "admin" + + relay: + # Whether relay mode should be allowed. If allowed, `!fb set-relay` can be used to turn any + # authenticated user into a relaybot for that chat. + enabled: false + # The formats to use when sending messages to KakaoTalk via a relay user. + # + # Available variables: + # $sender_displayname - The display name of the sender (e.g. Example User) + # $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser) + # $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com) + # $message - The message content + message_formats: + m.text: '$sender_displayname: $message' + m.notice: '$sender_displayname: $message' + m.emote: '* $sender_displayname $message' + m.file: '$sender_displayname sent a file' + m.image: '$sender_displayname sent an image' + m.audio: '$sender_displayname sent an audio file' + m.video: '$sender_displayname sent a video' + m.location: '$sender_displayname sent a location' + +rpc: + connection: + # Either unix or tcp + type: unix + # Only for type: unix + path: /var/run/matrix-appservice-kakaotalk/rpc.sock + # Only for type: tcp + host: localhost + port: 29392 + +# Python logging configuration. +# +# See section 16.7.2 of the Python documentation for more info: +# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema +logging: + version: 1 + formatters: + colored: + (): matrix_appservice_kakaotalk.util.ColorFormatter + format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" + normal: + format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" + handlers: + file: + class: logging.handlers.RotatingFileHandler + formatter: normal + filename: ./matrix-appservice-kakaotalk.log + maxBytes: 10485760 + backupCount: 10 + console: + class: logging.StreamHandler + formatter: colored + loggers: + mau: + level: DEBUG + paho: + level: INFO + aiohttp: + level: INFO + root: + level: DEBUG + handlers: [file, console] diff --git a/matrix_appservice_kakaotalk/formatter/__init__.py b/matrix_appservice_kakaotalk/formatter/__init__.py new file mode 100644 index 0000000..da0da8e --- /dev/null +++ b/matrix_appservice_kakaotalk/formatter/__init__.py @@ -0,0 +1,2 @@ +from .from_kakaotalk import kakaotalk_to_matrix +from .from_matrix import matrix_to_kakaotalk diff --git a/matrix_appservice_kakaotalk/formatter/from_kakaotalk.py b/matrix_appservice_kakaotalk/formatter/from_kakaotalk.py new file mode 100644 index 0000000..4ab4c8d --- /dev/null +++ b/matrix_appservice_kakaotalk/formatter/from_kakaotalk.py @@ -0,0 +1,170 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import Match +from html import escape +import re + +from mautrix.types import Format, MessageType, TextMessageEventContent + +from .. import puppet as pu, user as u + +_START = r"^|\s" +_END = r"$|\s" +_TEXT_NO_SURROUNDING_SPACE = r"(?:[^\s].*?[^\s])|[^\s]" +COMMON_REGEX = re.compile(rf"({_START})([_~*])({_TEXT_NO_SURROUNDING_SPACE})\2({_END})") +INLINE_CODE_REGEX = re.compile(rf"({_START})(`)(.+?)`({_END})") +MENTION_REGEX = re.compile(r"@([0-9]{1,15})\u2063(.+?)\u2063") + +tags = {"_": "em", "*": "strong", "~": "del", "`": "code"} + + +def _handle_match(html: str, match: Match, nested: bool) -> tuple[str, int]: + start, end = match.start(), match.end() + prefix, sigil, text, suffix = match.groups() + if nested: + text = _convert_formatting(text) + tag = tags[sigil] + # We don't want to include the whitespace suffix length, as that could be used as the + # whitespace prefix right after this formatting block. + pos = start + len(prefix) + (2 * len(tag) + 5) + len(text) + html = f"{html[:start]}{prefix}<{tag}>{text}{suffix}{html[end:]}" + return html, pos + + +def _convert_formatting(html: str) -> str: + pos = 0 + while pos < len(html): + i_match = INLINE_CODE_REGEX.search(html, pos) + c_match = COMMON_REGEX.search(html, pos) + if i_match and c_match: + match = min(i_match, c_match, key=lambda match: match.start()) + else: + match = i_match or c_match + + if match: + html, pos = _handle_match(html, match, nested=match != i_match) + else: + break + return html + + +def _handle_blockquote(output: list[str], blockquote: bool, line: str) -> tuple[bool, str]: + if not blockquote and line.startswith("> "): + line = line[len("> ") :] + output.append("
") + blockquote = True + elif blockquote: + if line.startswith(">"): + line = line[len(">") :] + if line.startswith(" "): + line = line[1:] + else: + output.append("
") + blockquote = False + return blockquote, line + + +def _handle_codeblock_pre( + output: list[str], codeblock: bool, line: str +) -> tuple[bool, str, tuple[str | None, str | None, str | None]]: + cb = line.find("```") + cb_lang = None + cb_content = None + post_cb_content = None + if cb != -1: + if not codeblock: + cb_lang = line[cb + 3 :] + if "```" in cb_lang: + end = cb_lang.index("```") + cb_content = cb_lang[:end] + post_cb_content = cb_lang[end + 3 :] + cb_lang = "" + else: + codeblock = True + line = line[:cb] + else: + output.append("") + codeblock = False + line = line[cb + 3 :] + return codeblock, line, (cb_lang, cb_content, post_cb_content) + + +def _handle_codeblock_post( + output: list[str], cb_lang: str | None, cb_content: str | None, post_cb_content: str | None +) -> None: + if cb_lang is not None: + if cb_lang: + output.append(f'
')
+        else:
+            output.append("
")
+        if cb_content:
+            output.append(cb_content)
+            output.append("
") + output.append(_convert_formatting(post_cb_content)) + + +async def kakaotalk_to_matrix(msg: str) -> TextMessageEventContent: + text = msg or "" + mentions = [] + content = TextMessageEventContent(msgtype=MessageType.TEXT, body=text) + mention_user_ids = [] + for m in reversed(mentions): + original = text[m.offset : m.offset + m.length] + if len(original) > 0 and original[0] == "@": + original = original[1:] + mention_user_ids.append(int(m.user_id)) + text = f"{text[:m.offset]}@{m.user_id}\u2063{original}\u2063{text[m.offset + m.length:]}" + html = escape(text) + output = [] + if html: + codeblock = False + blockquote = False + line: str + lines = html.split("\n") + for i, line in enumerate(lines): + blockquote, line = _handle_blockquote(output, blockquote, line) + codeblock, line, post_args = _handle_codeblock_pre(output, codeblock, line) + output.append(_convert_formatting(line)) + if i != len(lines) - 1: + if codeblock: + output.append("\n") + else: + output.append("
") + _handle_codeblock_post(output, *post_args) + html = "".join(output) + + mention_user_map = {} + for ktid in mention_user_ids: + user = await u.User.get_by_ktid(ktid) + if user: + mention_user_map[ktid] = user.mxid + else: + puppet = await pu.Puppet.get_by_ktid(ktid, create=False) + mention_user_map[ktid] = puppet.mxid if puppet else None + + def _mention_replacer(match: Match) -> str: + mxid = mention_user_map[int(match.group(1))] + if not mxid: + return match.group(2) + return f'{match.group(2)}' + + html = MENTION_REGEX.sub(_mention_replacer, html) + if html != escape(content.body).replace("\n", "
\n"): + content.format = Format.HTML + content.formatted_body = html + return content diff --git a/matrix_appservice_kakaotalk/formatter/from_matrix.py b/matrix_appservice_kakaotalk/formatter/from_matrix.py new file mode 100644 index 0000000..0a1b0d6 --- /dev/null +++ b/matrix_appservice_kakaotalk/formatter/from_matrix.py @@ -0,0 +1,120 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import NamedTuple + +from mautrix.types import Format, MessageEventContent, RelationType, RoomID +from mautrix.util.formatter import ( + EntityString, + EntityType, + MarkdownString, + MatrixParser as BaseMatrixParser, + SimpleEntity, +) +from mautrix.util.logging import TraceLogger + +from .. import puppet as pu, user as u +from ..db import Message as DBMessage + + +class SendParams(NamedTuple): + text: str + mentions: list[None] + reply_to: str + + +class FacebookFormatString(EntityString[SimpleEntity, EntityType], MarkdownString): + def format(self, entity_type: EntityType, **kwargs) -> FacebookFormatString: + prefix = suffix = "" + if entity_type == EntityType.USER_MENTION: + self.entities.append( + SimpleEntity( + type=entity_type, + offset=0, + length=len(self.text), + extra_info={"user_id": kwargs["user_id"]}, + ) + ) + return self + elif entity_type == EntityType.BOLD: + prefix = suffix = "*" + elif entity_type == EntityType.ITALIC: + prefix = suffix = "_" + elif entity_type == EntityType.STRIKETHROUGH: + prefix = suffix = "~" + elif entity_type == EntityType.URL: + if kwargs["url"] != self.text: + suffix = f" ({kwargs['url']})" + elif entity_type == EntityType.PREFORMATTED: + prefix = f"```{kwargs['language']}\n" + suffix = "\n```" + elif entity_type == EntityType.INLINE_CODE: + prefix = suffix = "`" + elif entity_type == EntityType.BLOCKQUOTE: + children = self.trim().split("\n") + children = [child.prepend("> ") for child in children] + return self.join(children, "\n") + elif entity_type == EntityType.HEADER: + prefix = "#" * kwargs["size"] + " " + else: + return self + + self._offset_entities(len(prefix)) + self.text = f"{prefix}{self.text}{suffix}" + return self + + +class MatrixParser(BaseMatrixParser[FacebookFormatString]): + fs = FacebookFormatString + + +async def matrix_to_kakaotalk( + content: MessageEventContent, room_id: RoomID, log: TraceLogger +) -> SendParams: + mentions = [] + reply_to = None + if content.relates_to.rel_type == RelationType.REPLY: + message = await DBMessage.get_by_mxid(content.relates_to.event_id, room_id) + if message: + content.trim_reply_fallback() + reply_to = message.ktid + else: + log.warning( + f"Couldn't find reply target {content.relates_to.event_id}" + " to bridge text message reply metadata to Facebook" + ) + if content.get("format", None) == Format.HTML and content["formatted_body"]: + parsed = await MatrixParser().parse(content["formatted_body"]) + text = parsed.text + mentions = [] + for mention in parsed.entities: + mxid = mention.extra_info["user_id"] + user = await u.User.get_by_mxid(mxid, create=False) + if user and user.ktid: + ktid = user.ktid + else: + puppet = await pu.Puppet.get_by_mxid(mxid, create=False) + if puppet: + ktid = puppet.ktid + else: + continue + #mentions.append( + # Mention(user_id=str(ktid), offset=mention.offset, length=mention.length) + #) + else: + text = content.body + return SendParams(text=text, mentions=mentions, reply_to=reply_to) diff --git a/matrix_appservice_kakaotalk/get_version.py b/matrix_appservice_kakaotalk/get_version.py new file mode 100644 index 0000000..7524340 --- /dev/null +++ b/matrix_appservice_kakaotalk/get_version.py @@ -0,0 +1,49 @@ +import os +import shutil +import subprocess + +from . import __version__ + +cmd_env = { + "PATH": os.environ["PATH"], + "HOME": os.environ["HOME"], + "LANG": "C", + "LC_ALL": "C", +} + + +def run(cmd): + return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env) + + +if os.path.exists(".git") and shutil.which("git"): + try: + git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii") + git_revision_url = f"https://github.com/mautrix/facebook/commit/{git_revision}" + git_revision = git_revision[:8] + except (subprocess.SubprocessError, OSError): + git_revision = "unknown" + git_revision_url = "" + + try: + git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii") + except (subprocess.SubprocessError, OSError): + git_tag = None +else: + git_revision = "unknown" + git_revision_url = "" + git_tag = None + +git_tag_url = f"https://github.com/mautrix/facebook/releases/tag/{git_tag}" if git_tag else None + +if git_tag and __version__ == git_tag[1:].replace("-", ""): + version = __version__ + linkified_version = f"[{version}]({git_tag_url})" +else: + if not __version__.endswith("+dev"): + __version__ += "+dev" + version = f"{__version__}.{git_revision}" + if git_revision_url: + linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})" + else: + linkified_version = version diff --git a/matrix_appservice_kakaotalk/kt/__init__.py b/matrix_appservice_kakaotalk/kt/__init__.py new file mode 100644 index 0000000..ba0173f --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/__init__.py @@ -0,0 +1,16 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Utilities for interacting with KakaoTalk.""" diff --git a/matrix_appservice_kakaotalk/kt/client/__init__.py b/matrix_appservice_kakaotalk/kt/client/__init__.py new file mode 100644 index 0000000..11ea577 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/client/__init__.py @@ -0,0 +1,18 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Wrappers around the KakaoTalk API.""" + +from .client import Client diff --git a/matrix_appservice_kakaotalk/kt/client/client.py b/matrix_appservice_kakaotalk/kt/client/client.py new file mode 100644 index 0000000..0bfba14 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/client/client.py @@ -0,0 +1,289 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +""" +Client functionality for the KakaoTalk API. +Currently a wrapper around a Node backend, but +the abstraction used here should be compatible +with any other potential backend. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast, Awaitable, Callable, Type + +import logging +import urllib.request + +from aiohttp import ClientSession +from aiohttp.client import _RequestContextManager +from yarl import URL + +from mautrix.util.logging import TraceLogger + +from ...config import Config +from ...rpc import RPCClient + +from ..types.api.struct.profile import ProfileReqStruct, ProfileStruct +from ..types.bson import Long +from ..types.client.client_session import LoginResult +from ..types.chat.chat import Chatlog +from ..types.oauth import OAuthCredential, OAuthInfo +from ..types.request import ( + deserialize_result, + ResultType, RootCommandResult, CommandResultDoneValue) + +from .types import ChannelInfoUnion +from .types import PortalChannelInfo + +from .errors import InvalidAccessToken +from .error_helper import raise_unsuccessful_response + +try: + from aiohttp_socks import ProxyConnector +except ImportError: + ProxyConnector = None + +if TYPE_CHECKING: + from mautrix.types import JSON + from ...user import User + from ...rpc.rpc import EventHandler + + +# TODO Consider defining an interface for this, with node/native backend as swappable implementations +# TODO If no state is stored, consider using free functions instead of classmethods +class Client: + _rpc_client: RPCClient + + @classmethod + async def init_cls(cls, config: Config) -> None: + """Initialize RPC to the Node backend.""" + cls._rpc_client = RPCClient(config) + await cls._rpc_client.connect() + + @classmethod + async def stop_cls(cls) -> None: + """Stop and disconnect from the Node backend.""" + await cls._rpc_client.request("stop") + await cls._rpc_client.disconnect() + + + # region tokenless commands + + @classmethod + async def generate_uuid(cls, used_uuids: set[str]) -> str: + """Randomly generate a UUID for a (fake) device.""" + tries_remaining = 10 + while True: + uuid = await cls._rpc_client.request("generate_uuid") + if uuid not in used_uuids: + return uuid + tries_remaining -= 1 + if tries_remaining == 0: + raise Exception( + "Unable to generate a UUID that hasn't been used before. " + "Either use a different RNG, or buy a lottery ticket" + ) + + @classmethod + async def register_device(cls, passcode: str, **req) -> None: + """Register a (fake) device that will be associated with the provided login credentials.""" + await cls._api_request_void("register_device", passcode=passcode, **req) + + @classmethod + async def login(cls, **req) -> OAuthCredential: + """ + Obtain a session token by logging in with user-provided credentials. + Must have first called register_device with these credentials. + """ + return await cls._api_request_result(OAuthCredential, "login", is_secret=True, **req) + + # endregion + + + http: ClientSession + log: TraceLogger + + def __init__(self, user: User, log: TraceLogger | None = None): + """Create a per-user client object for user-specific client functionality.""" + self.user = user + + # TODO Let the Node backend use a proxy too! + connector = None + try: + http_proxy = urllib.request.getproxies()["http"] + except KeyError: + pass + else: + if ProxyConnector: + connector = ProxyConnector.from_url(http_proxy) + else: + self.log.warning("http_proxy is set, but aiohttp-socks is not installed") + self.http = ClientSession(connector=connector) + + self.log = log or logging.getLogger("mw.ktclient") + + @property + def _oauth_credential(self) -> JSON: + return self.user.oauth_credential.serialize() + + def _get_user_data(self) -> JSON: + return dict( + mxid=self.user.mxid, + oauth_credential=self._oauth_credential + ) + + # region HTTP + + def get( + self, + url: str | URL, + headers: dict[str, str] | None = None, + **kwargs, + ) -> _RequestContextManager: + # TODO Is auth ever needed? + headers = { + **self._headers, + **(headers or {}), + } + url = URL(url) + return self.http.get(url, headers=headers, **kwargs) + + # endregion + + + # region post-token commands + + async def renew(self) -> OAuthInfo: + """Get a new set of tokens from a refresh token.""" + return await self._api_request_result(OAuthInfo, "renew", oauth_credential=self._oauth_credential) + + async def renew_and_save(self) -> None: + """Renew and save the user's session tokens.""" + oauth_info = await self.renew() + self.user.oauth_credential = oauth_info.credential + await self.user.save() + + async def start(self) -> LoginResult: + """ + Start a new session by providing a token obtained from a prior login. + Receive a snapshot of account state in response. + """ + login_result = await self._api_user_request_result(LoginResult, "start") + assert self.user.ktid == login_result.userId, f"User ID mismatch: expected {self.user.ktid}, got {login_result.userId}" + return login_result + + async def fetch_logged_in_user(self, post_login: bool = False) -> ProfileStruct: + profile_req_struct = await self._api_user_request_result(ProfileReqStruct, "get_own_profile") + return profile_req_struct.profile + + """ + async def is_connected(self) -> bool: + resp = await self._rpc_client.request("is_connected") + return resp["is_connected"] + """ + + async def get_portal_channel_info(self, channel_info: ChannelInfoUnion) -> PortalChannelInfo: + req = await self._api_user_request_result( + PortalChannelInfo, + "get_portal_channel_info", + channel_id=channel_info.channelId.serialize() + ) + req.channel_info = channel_info + return req + + async def get_profile(self, user_id: Long) -> ProfileStruct: + profile_req_struct = await self._api_user_request_result( + ProfileReqStruct, + "get_profile", + user_id=user_id.serialize() + ) + return profile_req_struct.profile + + async def stop(self) -> None: + # TODO Stop all event handlers + await self._api_user_request_void("stop") + + + # TODO Combine these into one + + async def _api_user_request_result( + self, result_type: Type[ResultType], command: str, **data: JSON + ) -> ResultType: + renewed = False + while True: + try: + return await self._api_request_result(result_type, command, **self._get_user_data(), **data) + except InvalidAccessToken: + if renewed: + raise + await self.renew_and_save() + renewed = True + + async def _api_user_request_void(self, command: str, **data: JSON) -> None: + renewed = False + while True: + try: + return await self._api_request_void(command, **self._get_user_data(), **data) + except InvalidAccessToken: + if renewed: + raise + await self.renew_and_save() + renewed = True + + # endregion + + + # region listeners + + async def on_message(self, func: Callable[[Chatlog, Long], Awaitable[None]]) -> None: + async def wrapper(data: dict[str, JSON]) -> None: + await func(Chatlog.deserialize(data["chatlog"]), data["channelId"]) + + self._add_user_handler("chat", wrapper) + + + def _add_user_handler(self, command: str, handler: EventHandler) -> str: + self._rpc_client.add_event_handler(f"{command}:{self.mxid}", handler) + + # endregion + + + @classmethod + async def _api_request_result( + cls, result_type: Type[ResultType], command: str, **data: JSON + ) -> ResultType: + """ + Call a command via RPC, and return its result object. + On failure, raise an appropriate exception. + """ + resp = deserialize_result( + result_type, + await cls._rpc_client.request(command, **data) + ) + if not resp.success: + raise_unsuccessful_response(resp) + # NOTE Not asserting against CommandResultDoneValue because it's generic! + # TODO Check if there really is no way to do it. + assert type(resp) is not RootCommandResult, "Result object missing from successful response" + return cast(CommandResultDoneValue[ResultType], resp).result + + @classmethod + async def _api_request_void(cls, command: str, **data: JSON) -> None: + resp = RootCommandResult.deserialize( + await cls._rpc_client.request(command, **data) + ) + if not resp.success: + raise_unsuccessful_response(resp) diff --git a/matrix_appservice_kakaotalk/kt/client/error_helper.py b/matrix_appservice_kakaotalk/kt/client/error_helper.py new file mode 100644 index 0000000..b178803 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/client/error_helper.py @@ -0,0 +1,125 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Internal helpers for error handling.""" + +from __future__ import annotations + +from typing import NoReturn, Type + +from .errors import ( + CommandException, + AuthenticationRequired, + DeviceVerificationRequired, + IncorrectPasscode, + IncorrectPassword, + InvalidAccessToken, +) +from ..types.request import RootCommandResult, KnownDataStatusCode +from ..types.api.auth_api_client import KnownAuthStatusCode + + +def raise_unsuccessful_response(resp: RootCommandResult) -> NoReturn: + raise _error_code_class_map.get(resp.status, CommandException)(resp) + + +_error_code_class_map: dict[KnownAuthStatusCode | KnownDataStatusCode | int, Type[CommandException]] = { + #KnownAuthStatusCode.INVALID_PHONE_NUMBER: "Invalid phone number", + #KnownAuthStatusCode.SUCCESS_WITH_ACCOUNT: "Success", + #KnownAuthStatusCode.SUCCESS_WITH_DEVICE_CHANGED: "Success (device changed)", + KnownAuthStatusCode.MISMATCH_PASSWORD: IncorrectPassword, + #KnownAuthStatusCode.EXCEED_LOGIN_LIMIT: "Login limit exceeded", + #KnownAuthStatusCode.MISMATCH_PHONE_NUMBER: "Phone number mismatch", + #KnownAuthStatusCode.EXCEED_PHONE_NUMBER_CHECK_LIMIT: "Phone number limit exceeded", + #KnownAuthStatusCode.NOT_EXIST_ACCOUNT: "Account does not exist", + #KnownAuthStatusCode.NEED_CHECK_PHONE_NUMBER: "Must check phone number", + #KnownAuthStatusCode.NEED_CHECK_QUIZ: "Must check quiz", + #KnownAuthStatusCode.DORMANT_ACCOUNT: "Dormant account", + #KnownAuthStatusCode.RESTRICTED_ACCOUNT: "Restricted account", + KnownAuthStatusCode.LOGIN_FAILED: IncorrectPassword, + #KnownAuthStatusCode.NOT_VERIFIED_EMAIL: "Unverified email address", + #KnownAuthStatusCode.MOBILE_UNREGISTERED: "Mobile device not registered", + #KnownAuthStatusCode.UNKNOWN_PHONE_NUMBER: "Unknown phone number", + #KnownAuthStatusCode.SUCCESS_SAME_USER: "Success (same user)", + #KnownAuthStatusCode.SUCCESS_SAME_USER_BY_MIGRATION: "Success (same user by migration)", + #KnownAuthStatusCode.TOO_MANY_REQUEST_A_DAY: "Too many requests a day", + #KnownAuthStatusCode.TOO_MANY_REQUEST_AT_A_TIME: " Too many requests at a time", + KnownAuthStatusCode.MISMATCH_PASSCODE: IncorrectPasscode, + #KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT: "Daily request limit exceeded", + #KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_VOICECALL: "Daily voicecall limit exceeded", + #KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_WITHOUT_TOKEN: "Daily tokenless request limit exceeded", + KnownAuthStatusCode.DEVICE_NOT_REGISTERED: DeviceVerificationRequired, + #KnownAuthStatusCode.ANOTHER_LOGON: "Another logon detected", + #KnownAuthStatusCode.DEVICE_REGISTER_FAILED: "Device registration failed", + #KnownAuthStatusCode.INVALID_DEVICE_REGISTER: "Invalid device", + KnownAuthStatusCode.INVALID_PASSCODE: IncorrectPasscode, + #KnownAuthStatusCode.PASSCODE_REQUEST_FAILED: "Passcode request failed", + #KnownAuthStatusCode.NEED_TERMS_AGREE: "Must agree to terms", + #KnownAuthStatusCode.DENIED_DEVICE_MODEL: "Denied device model", + #KnownAuthStatusCode.RESET_STEP: "Reset step", + #KnownAuthStatusCode.NEED_PROTECTOR_AGREE: "Must agree to protector terms", + #KnownAuthStatusCode.ACCOUNT_RESTRICTED: "Account restricted", + ##KnownAuthStatusCode.INVALID_STAGE_ERROR: "Same as KnownAuthStatusCode.AUTH_REQUIRED", + #KnownAuthStatusCode.UPGRADE_REQUIRED: "Upgrade required", + #KnownAuthStatusCode.VOICE_CALL_ONLY: "Voice call only", + #KnownAuthStatusCode.ACCESSIBILITY_ARS_ONLY: "Accessibility only", + #KnownAuthStatusCode.MIGRATION_FAILURE: "Migration failure", + #KnownAuthStatusCode.INVAILD_TOKEN: "Invalid token", + #KnownAuthStatusCode.UNDEFINED: "Undefined error", + + #KnownDataStatusCode.SUCCESS: "Success", + #KnownDataStatusCode.INVALID_USER: "Invalid user", + #KnownDataStatusCode.CLIENT_ERROR: "Client error", + #KnownDataStatusCode.NOT_LOGON: "Not logged in", + #KnownDataStatusCode.INVALID_METHOD: "Invalid method", + #KnownDataStatusCode.INVALID_PARAMETER: "Invalid parameter", + #KnownDataStatusCode.INVALID_BODY: "Invalid body", + #KnownDataStatusCode.INVALID_HEADER: "Invalid header", + #KnownDataStatusCode.UNAUTHORIZED_CHAT_DELETE: "Unauthorized chat deletion", + #KnownDataStatusCode.MEDIA_SERVER_ERROR: "Media server error", + #KnownDataStatusCode.CHAT_SPAM_LIMIT: "Chat spam limit exceeded", + #KnownDataStatusCode.RESTRICTED_APP: "Restricted app", + #KnownDataStatusCode.LOGINLIST_CHATLIST_FAILED: "Login chat list failed", + #KnownDataStatusCode.MEDIA_NOT_FOUND: "Media not found", + #KnownDataStatusCode.MEDIA_THUMB_GEN_FAILED: "Could not generate media thumbnail", + #KnownDataStatusCode.UNSUPPORTED: "Unsupported", + #KnownDataStatusCode.PARTIAL: "Parial", + #KnownDataStatusCode.LINK_JOIN_TPS_EXCEEDED: "Link join TPS exceeded", + #KnownDataStatusCode.CHAT_SEND_RESTRICTED: "Chat send restricted", + #KnownDataStatusCode.CHANNEL_CREATE_TEMP_RESTRICTED: "Channel creation temporarily restricted", + #KnownDataStatusCode.CHANNEL_CREATE_RESTRICTED: "Channel creation restricted", + #KnownDataStatusCode.OPENLINK_UNAVAILABLE: "Openlink unavailable", + #KnownDataStatusCode.INVITE_COUNT_LIMITED: "Invite count limited", + #KnownDataStatusCode.OPENLINK_CREATE_RESTRICTED: "Openlink creation restricted", + #KnownDataStatusCode.INVALID_CHANNEL: "Invalid channel", + #KnownDataStatusCode.CHAT_BLOCKED_BY_FRIEND: "Blocked by friend", + #KnownDataStatusCode.NOT_CHATABLE_USER: "Non-chattable user", + #KnownDataStatusCode.GAME_MESSAGE_BLOCKED: "Game message blocked", + #KnownDataStatusCode.BLOCKED_IP: "Blocked IP", + #KnownDataStatusCode.BACKGROUND_LOGIN_BLOCKED: "Background login blocked", + #KnownDataStatusCode.OPERATION_DENIED: "Operation denied", + #KnownDataStatusCode.CHANNEL_USER_LIMITED: "Channel user limited", + #KnownDataStatusCode.TEMP_RESTRICTED: "Temporarily restricted", + #KnownDataStatusCode.WRITE_WHILE_BLOCKED: "Write while blocked", + #KnownDataStatusCode.OPENCHAT_REJOIN_REQUIRED: "Openchat rejoin required", + #KnownDataStatusCode.OPENCHAT_TIME_RESTRICTED: "Openchat time restricted", + KnownDataStatusCode.INVALID_ACCESS_TOKEN: InvalidAccessToken, + ##KnownDataStatusCode.BLOCKED_ACCOUNT: "Same as KnownAuthStatusCode.ACCOUNT_RESTRICTED" + KnownDataStatusCode.AUTH_REQUIRED: AuthenticationRequired, + ##KnownDataStatusCode.UPDATE_REQUIRED = "Same as KnownAuthStatusCode.UPGRADE_REQUIRED" + #KnownDataStatusCode.SERVER_UNDER_MAINTENANCE: "Server under maintenance", + + 401: InvalidAccessToken, +} diff --git a/matrix_appservice_kakaotalk/kt/client/errors.py b/matrix_appservice_kakaotalk/kt/client/errors.py new file mode 100644 index 0000000..90a6989 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/client/errors.py @@ -0,0 +1,160 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Helper functions & types for status codes for the KakaoTalk API.""" + +from __future__ import annotations + +from typing import Type + +from ..types.api.auth_api_client import KnownAuthStatusCode +from ..types.request import KnownDataStatusCode, RootCommandResult + + +class CommandException(Exception): + def __init__(self, result: RootCommandResult): + """ + Base type for errors raised from KakaoTalk. Subclasses identify different kinds of errors. + Which subclass to use should be based on the status code of the "result" object. + In the case that different status codes map to the same error subclass, the status code + can be retrieved from the "status" property. + """ + # NOTE unsuccessful responses do not set a result, hence using RootCommandResult here + # TODO Print _unrecognized? + self.status = result.status + self.message = _status_code_message_map.get(self.status, self._default_message) + + @property + def _default_message(self): + return "Unknown error" + + def __str__(self): + return f"{self.message} ({self.status})" + + +class OAuthException(CommandException): + pass + +class DeviceVerificationRequired(OAuthException): + pass + +class IncorrectPassword(OAuthException): + pass + +class IncorrectPasscode(OAuthException): + pass + +class InvalidAccessToken(OAuthException): + """Thrown when the session token is invalid and must be refreshed""" + @property + def _default_message(self): + return "Invalid access token" + +class AuthenticationRequired(OAuthException): + """Thrown when both the session token and refresh token are invalid""" + pass + + +class ResponseError(Exception): + """TODO Use for network failures to the KakaoTalk API""" + pass + + +_status_code_message_map: dict[KnownAuthStatusCode | KnownDataStatusCode | int] = { + KnownAuthStatusCode.INVALID_PHONE_NUMBER: "Invalid phone number", + KnownAuthStatusCode.SUCCESS_WITH_ACCOUNT: "Success", + KnownAuthStatusCode.SUCCESS_WITH_DEVICE_CHANGED: "Success (device changed)", + KnownAuthStatusCode.MISMATCH_PASSWORD: "Password mismatch", + KnownAuthStatusCode.EXCEED_LOGIN_LIMIT: "Login limit exceeded", + KnownAuthStatusCode.MISMATCH_PHONE_NUMBER: "Phone number mismatch", + KnownAuthStatusCode.EXCEED_PHONE_NUMBER_CHECK_LIMIT: "Phone number limit exceeded", + KnownAuthStatusCode.NOT_EXIST_ACCOUNT: "Account does not exist", + KnownAuthStatusCode.NEED_CHECK_PHONE_NUMBER: "Must check phone number", + KnownAuthStatusCode.NEED_CHECK_QUIZ: "Must check quiz", + KnownAuthStatusCode.DORMANT_ACCOUNT: "Dormant account", + KnownAuthStatusCode.RESTRICTED_ACCOUNT: "Restricted account", + KnownAuthStatusCode.LOGIN_FAILED: "Login failed", + KnownAuthStatusCode.NOT_VERIFIED_EMAIL: "Unverified email address", + KnownAuthStatusCode.MOBILE_UNREGISTERED: "Mobile device not registered", + KnownAuthStatusCode.UNKNOWN_PHONE_NUMBER: "Unknown phone number", + KnownAuthStatusCode.SUCCESS_SAME_USER: "Success (same user)", + KnownAuthStatusCode.SUCCESS_SAME_USER_BY_MIGRATION: "Success (same user by migration)", + KnownAuthStatusCode.TOO_MANY_REQUEST_A_DAY: "Too many requests a day", + KnownAuthStatusCode.TOO_MANY_REQUEST_AT_A_TIME: " Too many requests at a time", + KnownAuthStatusCode.MISMATCH_PASSCODE: "Passcode mismatch", + KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT: "Daily request limit exceeded", + KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_VOICECALL: "Daily voicecall limit exceeded", + KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_WITHOUT_TOKEN: "Daily tokenless request limit exceeded", + KnownAuthStatusCode.DEVICE_NOT_REGISTERED: "Device not registered", + KnownAuthStatusCode.ANOTHER_LOGON: "Another logon detected", + KnownAuthStatusCode.DEVICE_REGISTER_FAILED: "Device registration failed", + KnownAuthStatusCode.INVALID_DEVICE_REGISTER: "Invalid device", + KnownAuthStatusCode.INVALID_PASSCODE: "Invalid passcode", + KnownAuthStatusCode.PASSCODE_REQUEST_FAILED: "Passcode request failed", + KnownAuthStatusCode.NEED_TERMS_AGREE: "Must agree to terms", + KnownAuthStatusCode.DENIED_DEVICE_MODEL: "Denied device model", + KnownAuthStatusCode.RESET_STEP: "Reset step", + KnownAuthStatusCode.NEED_PROTECTOR_AGREE: "Must agree to protector terms", + KnownAuthStatusCode.ACCOUNT_RESTRICTED: "Account restricted", + #KnownAuthStatusCode.INVALID_STAGE_ERROR: "Same as KnownAuthStatusCode.AUTH_REQUIRED", + KnownAuthStatusCode.UPGRADE_REQUIRED: "Upgrade required", + KnownAuthStatusCode.VOICE_CALL_ONLY: "Voice call only", + KnownAuthStatusCode.ACCESSIBILITY_ARS_ONLY: "Accessibility only", + KnownAuthStatusCode.MIGRATION_FAILURE: "Migration failure", + KnownAuthStatusCode.INVAILD_TOKEN: "Invalid token", + KnownAuthStatusCode.UNDEFINED: "Undefined error", + + KnownDataStatusCode.SUCCESS: "Success", + KnownDataStatusCode.INVALID_USER: "Invalid user", + KnownDataStatusCode.CLIENT_ERROR: "Client error", + KnownDataStatusCode.NOT_LOGON: "Not logged in", + KnownDataStatusCode.INVALID_METHOD: "Invalid method", + KnownDataStatusCode.INVALID_PARAMETER: "Invalid parameter", + KnownDataStatusCode.INVALID_BODY: "Invalid body", + KnownDataStatusCode.INVALID_HEADER: "Invalid header", + KnownDataStatusCode.UNAUTHORIZED_CHAT_DELETE: "Unauthorized chat deletion", + KnownDataStatusCode.MEDIA_SERVER_ERROR: "Media server error", + KnownDataStatusCode.CHAT_SPAM_LIMIT: "Chat spam limit exceeded", + KnownDataStatusCode.RESTRICTED_APP: "Restricted app", + KnownDataStatusCode.LOGINLIST_CHATLIST_FAILED: "Login chat list failed", + KnownDataStatusCode.MEDIA_NOT_FOUND: "Media not found", + KnownDataStatusCode.MEDIA_THUMB_GEN_FAILED: "Could not generate media thumbnail", + KnownDataStatusCode.UNSUPPORTED: "Unsupported", + KnownDataStatusCode.PARTIAL: "Parial", + KnownDataStatusCode.LINK_JOIN_TPS_EXCEEDED: "Link join TPS exceeded", + KnownDataStatusCode.CHAT_SEND_RESTRICTED: "Chat send restricted", + KnownDataStatusCode.CHANNEL_CREATE_TEMP_RESTRICTED: "Channel creation temporarily restricted", + KnownDataStatusCode.CHANNEL_CREATE_RESTRICTED: "Channel creation restricted", + KnownDataStatusCode.OPENLINK_UNAVAILABLE: "Openlink unavailable", + KnownDataStatusCode.INVITE_COUNT_LIMITED: "Invite count limited", + KnownDataStatusCode.OPENLINK_CREATE_RESTRICTED: "Openlink creation restricted", + KnownDataStatusCode.INVALID_CHANNEL: "Invalid channel", + KnownDataStatusCode.CHAT_BLOCKED_BY_FRIEND: "Blocked by friend", + KnownDataStatusCode.NOT_CHATABLE_USER: "Non-chattable user", + KnownDataStatusCode.GAME_MESSAGE_BLOCKED: "Game message blocked", + KnownDataStatusCode.BLOCKED_IP: "Blocked IP", + KnownDataStatusCode.BACKGROUND_LOGIN_BLOCKED: "Background login blocked", + KnownDataStatusCode.OPERATION_DENIED: "Operation denied", + KnownDataStatusCode.CHANNEL_USER_LIMITED: "Channel user limited", + KnownDataStatusCode.TEMP_RESTRICTED: "Temporarily restricted", + KnownDataStatusCode.WRITE_WHILE_BLOCKED: "Write while blocked", + KnownDataStatusCode.OPENCHAT_REJOIN_REQUIRED: "Openchat rejoin required", + KnownDataStatusCode.OPENCHAT_TIME_RESTRICTED: "Openchat time restricted", + KnownDataStatusCode.INVALID_ACCESS_TOKEN: "Invalid access token", + #KnownDataStatusCode.BLOCKED_ACCOUNT: "Same as KnownAuthStatusCode.ACCOUNT_RESTRICTED" + KnownDataStatusCode.AUTH_REQUIRED: "Authentication required", + #KnownDataStatusCode.UPDATE_REQUIRED = "Same as KnownAuthStatusCode.UPGRADE_REQUIRED" + KnownDataStatusCode.SERVER_UNDER_MAINTENANCE: "Server under maintenance", +} diff --git a/matrix_appservice_kakaotalk/kt/client/types.py b/matrix_appservice_kakaotalk/kt/client/types.py new file mode 100644 index 0000000..47c94fd --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/client/types.py @@ -0,0 +1,35 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Custom wrapper classes around types defined by the KakaoTalk API.""" + +from attr import dataclass + +from mautrix.types import SerializableAttrs + +from ..types.channel.channel_info import NormalChannelInfo +from ..types.openlink.open_channel_info import OpenChannelInfo +from ..types.user.channel_user_info import NormalChannelUserInfo, OpenChannelUserInfo + + +ChannelInfoUnion = NormalChannelInfo | OpenChannelInfo +UserInfoUnion = NormalChannelUserInfo | OpenChannelUserInfo + +@dataclass +class PortalChannelInfo(SerializableAttrs): + name: str + #participants: list[PuppetUserInfo] + # TODO Image + channel_info: ChannelInfoUnion | None = None # Should be set manually by caller diff --git a/matrix_appservice_kakaotalk/kt/types/__init__.py b/matrix_appservice_kakaotalk/kt/types/__init__.py new file mode 100644 index 0000000..c7fef7d --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/__init__.py @@ -0,0 +1,29 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Types defined by the KakaoTalk API.""" + +""" +from . import api +from .api import AuthLoginData, KnownAuthStatusCode +from .channel import * +from .chat import * +from .client import * +from .oauth import * +from .openlink import * +from .request import * +from .user import * +from .bson import Long +""" diff --git a/matrix_appservice_kakaotalk/kt/types/api/__init__.py b/matrix_appservice_kakaotalk/kt/types/api/__init__.py new file mode 100644 index 0000000..55d6c91 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/__init__.py @@ -0,0 +1,18 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +""" +from .auth_api_client import * +""" diff --git a/matrix_appservice_kakaotalk/kt/types/api/auth_api_client.py b/matrix_appservice_kakaotalk/kt/types/api/auth_api_client.py new file mode 100644 index 0000000..d744093 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/auth_api_client.py @@ -0,0 +1,101 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass +from enum import IntEnum + +from mautrix.types import SerializableAttrs + +from ..oauth import OAuthCredential + + +@dataclass +class AuthLoginData(OAuthCredential): + """aka LoginData""" + countryIso: str + countryCode: str + accountId: int + serverTime: int + resetUserData: bool + storyURL: str + tokenType: str + autoLoginAccountId: str + displayAccountId: str + mainDeviceAgentName: str + mainDeviceAppVersion: str + + +@dataclass +class LoginForm(SerializableAttrs): + email: str + password: str + + +@dataclass +class TokenLoginForm(LoginForm): + autowithlock: bool + + +class KnownAuthStatusCode(IntEnum): + INVALID_PHONE_NUMBER = 1 + SUCCESS_WITH_ACCOUNT = 10 + SUCCESS_WITH_DEVICE_CHANGED = 11 + MISMATCH_PASSWORD = 12 + EXCEED_LOGIN_LIMIT = 13 + MISMATCH_PHONE_NUMBER = 14 + EXCEED_PHONE_NUMBER_CHECK_LIMIT = 15 + NOT_EXIST_ACCOUNT = 16 + NEED_CHECK_PHONE_NUMBER = 20 + NEED_CHECK_QUIZ = 25 + DORMANT_ACCOUNT = 26 + RESTRICTED_ACCOUNT = 27 + LOGIN_FAILED = 30 + NOT_VERIFIED_EMAIL = 31 + MOBILE_UNREGISTERED = 32 + UNKNOWN_PHONE_NUMBER = 99 + SUCCESS_SAME_USER = 100 + SUCCESS_SAME_USER_BY_MIGRATION = 101 + TOO_MANY_REQUEST_A_DAY = -20 + TOO_MANY_REQUEST_AT_A_TIME = -30 + MISMATCH_PASSCODE = -31 + EXCEED_DAILY_REQUEST_LIMIT = -32 + EXCEED_DAILY_REQUEST_LIMIT_VOICECALL = -33 + EXCEED_DAILY_REQUEST_LIMIT_WITHOUT_TOKEN = -34 + DEVICE_NOT_REGISTERED = -100 + ANOTHER_LOGON = -101 + DEVICE_REGISTER_FAILED = -102 + INVALID_DEVICE_REGISTER = -110 + INVALID_PASSCODE = -111 + PASSCODE_REQUEST_FAILED = -112 + NEED_TERMS_AGREE = -126 + DENIED_DEVICE_MODEL = -132 + RESET_STEP = -940 + NEED_PROTECTOR_AGREE = -991 + ACCOUNT_RESTRICTED = -997 + INVALID_STAGE_ERROR = -998 + UPGRADE_REQUIRED = -999 + VOICE_CALL_ONLY = -10002 + ACCESSIBILITY_ARS_ONLY = -10003 + MIGRATION_FAILURE = -100001 + INVAILD_TOKEN = -100002 + UNDEFINED = -999999 + + +__all__ = [ + "AuthLoginData", + "LoginForm", + "TokenLoginForm", + "KnownAuthStatusCode", +] diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/__init__.py b/matrix_appservice_kakaotalk/kt/types/api/struct/__init__.py new file mode 100644 index 0000000..06fa069 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/__init__.py @@ -0,0 +1,18 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +""" +from .profile import * +""" diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/profile.py b/matrix_appservice_kakaotalk/kt/types/api/struct/profile.py new file mode 100644 index 0000000..6065b58 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/profile.py @@ -0,0 +1,123 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from mautrix.types import SerializableAttrs, JSON + +from ...bson import Long + + +@dataclass +class ProfileFeedObject(SerializableAttrs): + type: str + value: str + + +@dataclass(kw_only=True) +class ProfileFeed(SerializableAttrs): + id: str + serviceName: str + typeIconUrl: str + downloadId: str + contents: list[ProfileFeedObject] + url: str + serviceUrl: str | None = None # NOTE Made optional + webUrl: str + serviceWebUrl: str | None = None # NOTE Made optional + updatedAt: int + cursor: int + feedMessage: str + permission: int + type: int + isCurrent: bool + extra: JSON # instead of "unknown" + + +@dataclass(kw_only=True) +class ProfileFeedList(SerializableAttrs): + totalCnt: int # NOTE Renamed from "totalCnts" + feeds: list[ProfileFeed] + + +@dataclass +class ProfileDecorationObject(SerializableAttrs): + resourceUrl: str + + +@dataclass +class ProfileDecoration(SerializableAttrs): + itemKind: str + itemId: str + parameters: ProfileDecorationObject + + +@dataclass +class BgEffectDecoration(ProfileDecoration): + itemKind = 'BgEffect' + + +@dataclass +class StickerDecoration(ProfileDecoration): + itemKind = 'Sticker' + x: int + y: int + cx: int + cy: int + width: int + height: int + rotation: int + + +@dataclass +class ProfileStruct(SerializableAttrs): + backgroundImageUrl: str + originalBackgroundImageUrl: str + statusMessage: str + profileImageUrl: str + fullProfileImageUrl: str + originalProfileImageUrl: str + decoration: list[ProfileDecoration] + profileFeeds: ProfileFeedList + backgroundFeeds: ProfileFeedList + allowStory: bool + allowStoryPost: bool + hasProfile2Photos: bool + allowPay: bool + screenToken: int + # NEW + nickname: str + userId: int | str | Long # NOTE Should confirm this + suspended: bool + meBadge: bool + # TODO feeds = {feeds: list, last: bool} + + +@dataclass +class ProfileReqStruct(SerializableAttrs): + profile: ProfileStruct + itemNewBadgeToken: int + lastSeenAt: int + + +___all___ = [ + "ProfileFeed", + "ProfileFeedList", + "ProfileDecoration", + "BgEffectDecoration", + "StickerDecoration", + "ProfileStruct", + "ProfileReqStruct", +] diff --git a/matrix_appservice_kakaotalk/kt/types/attachment/__init__.py b/matrix_appservice_kakaotalk/kt/types/attachment/__init__.py new file mode 100644 index 0000000..9854339 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/attachment/__init__.py @@ -0,0 +1,34 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from mautrix.types import SerializableAttrs + +from ..bson import Long +from .mention import MentionStruct + + +@dataclass(kw_only=True) +class Attachment(SerializableAttrs): + shout: bool | None = None + mentions: list[MentionStruct] | None = None + urls: list[str] | None = None + + +@dataclass +class PathAttachment(Attachment): + path: str + s: int diff --git a/matrix_appservice_kakaotalk/kt/types/attachment/mention.py b/matrix_appservice_kakaotalk/kt/types/attachment/mention.py new file mode 100644 index 0000000..103a606 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/attachment/mention.py @@ -0,0 +1,27 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from mautrix.types import SerializableAttrs + +from ..bson import Long + + +@dataclass +class MentionStruct(SerializableAttrs): + at: list[int] + len: int + user_id: Long | int diff --git a/matrix_appservice_kakaotalk/kt/types/bson.py b/matrix_appservice_kakaotalk/kt/types/bson.py new file mode 100644 index 0000000..60939cb --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/bson.py @@ -0,0 +1,60 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import ClassVar, Optional + +from attr import dataclass, asdict +import bson + +from mautrix.types import SerializableAttrs, JSON + + +@dataclass(frozen=True) +class Long(SerializableAttrs): + high: int + low: int + unsigned: bool + + @classmethod + def from_bytes(cls, raw: bytes) -> "Long": + return cls(**bson.loads(raw)) + + @classmethod + def from_optional_bytes(cls, raw: bytes | None) -> Optional["Long"]: + return cls(**bson.loads(raw)) if raw is not None else None + + @classmethod + def to_optional_bytes(cls, value: Optional["Long"]) -> bytes | None: + return bytes(value) if value is not None else None + + def serialize(self) -> JSON: + data = super().serialize() + data["__type__"] = "Long" + return data + + def __bytes__(self) -> bytes: + return bson.dumps(asdict(self)) + + def __int__(self) -> int: + # TODO Is this right? + return self.high << 32 + self.low + + def __str__(self) -> str: + return f"{self.high << 32 if self.high else ''}{self.low}" + + ZERO: ClassVar["Long"] + + +Long.ZERO = Long(0, 0, False) diff --git a/matrix_appservice_kakaotalk/kt/types/channel/__init__.py b/matrix_appservice_kakaotalk/kt/types/channel/__init__.py new file mode 100644 index 0000000..7b5e161 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/channel/__init__.py @@ -0,0 +1,21 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +""" +from .channel_info import * +from .channel_type import * +from .channel import * +from .meta import * +""" diff --git a/matrix_appservice_kakaotalk/kt/types/channel/channel.py b/matrix_appservice_kakaotalk/kt/types/channel/channel.py new file mode 100644 index 0000000..db80101 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/channel/channel.py @@ -0,0 +1,30 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from mautrix.types import SerializableAttrs + +from ..bson import Long + + +@dataclass +class Channel(SerializableAttrs): + channelId: Long + + +__all__ = [ + "Channel", +] diff --git a/matrix_appservice_kakaotalk/kt/types/channel/channel_info.py b/matrix_appservice_kakaotalk/kt/types/channel/channel_info.py new file mode 100644 index 0000000..b151575 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/channel/channel_info.py @@ -0,0 +1,93 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Generic, TypeVar + +from attr import dataclass + +from mautrix.types import SerializableAttrs, JSON + +from ..bson import Long +from ..chat.chat import Chatlog +from ..user.channel_user_info import DisplayUserInfo +from .channel import Channel +from .channel_type import ChannelType +from .meta import ChannelMetaType + + +@dataclass +class ChannelMeta(SerializableAttrs): + content: str + +@dataclass +class SetChannelMeta(ChannelMeta): + revision: int + authorId: Long + updatedAt: int + +ChannelMetaMap = dict[ChannelMetaType, SetChannelMeta] # Substitute for Record + + +@dataclass(kw_only=True) +class ChannelInfo(Channel): + type: ChannelType + activeUserCount: int + newChatCount: int + newChatCountInvalid: bool + lastChatLogId: Long + lastSeenLogId: Long + lastChatLog: Chatlog | None = None + metaMap: ChannelMetaMap + displayUserList: list[DisplayUserInfo] + pushAlert: bool + + +@dataclass +class NormalChannelInfo(ChannelInfo): + joinTime: int + + +T = TypeVar("T", bound=SerializableAttrs) + +@dataclass +class ChannelData(SerializableAttrs, Generic[T]): + info: T + + +@dataclass +class ChannelLoginData(SerializableAttrs, Generic[T]): + """aka non-auth LoginData""" + lastUpdate: int + channel: T + + +@dataclass +class NormalChannelData(Channel, ChannelData[NormalChannelInfo]): + @classmethod + def deserialize(cls, data: JSON) -> "NormalChannelData": + data["info"] = NormalChannelInfo.deserialize(data["info"]) + return cls.deserialize(data) + + +__all__ = [ + "ChannelMeta", + "SetChannelMeta", + "ChannelMetaMap", + "ChannelInfo", + "NormalChannelInfo", + "ChannelData", + "ChannelLoginData", + "NormalChannelData", +] diff --git a/matrix_appservice_kakaotalk/kt/types/channel/channel_type.py b/matrix_appservice_kakaotalk/kt/types/channel/channel_type.py new file mode 100644 index 0000000..5a0925a --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/channel/channel_type.py @@ -0,0 +1,39 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Union + +from enum import Enum + + +class KnownChannelType(str, Enum): + MultiChat = "MultiChat" + DirectChat = "DirectChat" + PlusChat = "PlusChat" + MemoChat = "MemoChat" + OM = "OM" + OD = "OD" + + @classmethod + def is_direct(cls, value: Union["KnownChannelType", str]) -> bool: + return str in [KnownChannelType.DirectChat, KnownChannelType.OD] + + +ChannelType = KnownChannelType | str # Substitute for ChannelType = "name1" | ... | "nameN" | str + + +__all__ = [ + "ChannelType", +] diff --git a/matrix_appservice_kakaotalk/kt/types/channel/meta.py b/matrix_appservice_kakaotalk/kt/types/channel/meta.py new file mode 100644 index 0000000..c2bd4c0 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/channel/meta.py @@ -0,0 +1,175 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass +from enum import Enum, IntEnum + +from mautrix.types import SerializableAttrs, field + +from ..bson import Long + +class KnownChannelMetaType(IntEnum): + UNDEFINED = 0 + NOTICE = 1 + GROUP = 2 + TITLE = 3 + PROFILE = 4 + TV = 5 + PRIVILEGE = 6 + TV_LIVE = 7 + PLUS_BACKGROUND = 8 + LIVE_TALK_INFO = 11 + LIVE_TALK_COUNT = 12 + OPEN_CHANNEL_CHAT = 13 + BOT = 14 + +ChannelMetaType = KnownChannelMetaType | int + + +class ChannelClientMetaType(str, Enum): + UNDEFINED = "undefined" + NAME = "name" + IMAGE_PATH = "image_path" + FAVORITE = "favorite" + PUSH_SOUND = "push_sound" + CHAT_HIDE = "chat_hide" + FULL_IMAGE_URL = "full_image_url" + IMAGE_URL = "imageUrl" + + +@dataclass(kw_only=True) +class ChannelMetaStruct(SerializableAttrs): + type: ChannelMetaType + revision: Long + authorId: Long | None = None + content: str + updatedAt: int + + +@dataclass(kw_only=True) +class ChannelClientMetaStruct(SerializableAttrs): + name: str | None = None + image_path: str | None = None + favourite: bool | None = None + push_sound: bool | None = None + chat_hide: bool | None = None + fullImageUrl: str | None = None + imageUrl: str | None = None + + +@dataclass +class PrivilegeMetaContent(SerializableAttrs): + pin_notice: bool + + +@dataclass +class ProfileMetaContent(SerializableAttrs): + imageUrl: str + fullImageUrl: str + + +@dataclass +class TvMetaContent(SerializableAttrs): + url: str + + +@dataclass(kw_only=True) +class TvLiveMetaContent(SerializableAttrs): + url: str + live: str | None = 'on' + + +@dataclass +class LiveTalkInfoOnMetaContent(SerializableAttrs): + liveon: bool + title: str + startTime: int + userId: int | Long + csIP: str + csIP6: str + csPort: int + callId: str + + +@dataclass(kw_only=True) +class LiveTalkInfoOffMetaContent(SerializableAttrs): + """Substitute for LiveTalkInfoOffMetaContent extends Partial""" + liveon: bool + title: str | None = None + startTime: int | None = None + userId: int | Long | None + csIP: str | None = None + csIP6: str | None = None + csPort: int | None = None + callId: str | None = None + + +LiveTalkInfoMetaContent = LiveTalkInfoOnMetaContent | LiveTalkInfoOffMetaContent; + + +@dataclass +class LiveTalkCountMetaContent(SerializableAttrs): + count: int + + +@dataclass +class GroupMetaContent(SerializableAttrs): + group_id: int + group_name: str + group_profile_thumbnail_url: str + group_profile_url: str + + +@dataclass +class BotCommandStruct(SerializableAttrs): + id: str + +@dataclass +class BotAddCommandStruct(BotCommandStruct): + name: str + updatedAt: int + botId: Long + +BotDelCommandStruct = BotCommandStruct + + +@dataclass(kw_only=True) +class BotMetaContent(SerializableAttrs): + add: list[BotAddCommandStruct] | None = None + update: list[BotAddCommandStruct] | None = None + full: list[BotAddCommandStruct] | None = None + delete: list[BotDelCommandStruct] | None = field(json="del", default=None) + + +__all__ = [ + "KnownChannelMetaType", + "ChannelMetaType", + "ChannelClientMetaType", + "ChannelMetaStruct", + "ChannelClientMetaStruct", + "PrivilegeMetaContent", + "ProfileMetaContent", + "TvMetaContent", + "TvLiveMetaContent", + "LiveTalkInfoOnMetaContent", + "LiveTalkInfoOffMetaContent", + "LiveTalkInfoMetaContent", + "LiveTalkCountMetaContent", + "GroupMetaContent", + "BotCommandStruct", + "BotAddCommandStruct", + "BotDelCommandStruct", + "BotMetaContent", +] diff --git a/matrix_appservice_kakaotalk/kt/types/chat/__init__.py b/matrix_appservice_kakaotalk/kt/types/chat/__init__.py new file mode 100644 index 0000000..c0eff5d --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/chat/__init__.py @@ -0,0 +1,19 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +""" +from .chat_type import * +from .chat import * +""" diff --git a/matrix_appservice_kakaotalk/kt/types/chat/chat.py b/matrix_appservice_kakaotalk/kt/types/chat/chat.py new file mode 100644 index 0000000..6aeaf21 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/chat/chat.py @@ -0,0 +1,90 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from mautrix.types import SerializableAttrs + +from ..bson import Long +from ..user.channel_user import ChannelUser +from ..attachment import Attachment +from .chat_type import ChatType + + +@dataclass +class ChatTypeComponent(SerializableAttrs): + type: ChatType # Substitute for T, where T extends ChatType = ChatType + +@dataclass(kw_only=True) +class Chat(ChatTypeComponent): + text: str | None = None + attachment: Attachment | None = None + supplement: dict | None = None + + +@dataclass +class TypedChat(Chat, ChatTypeComponent): + """Substitute for TypedChat = Chat & ChatTypeComponent""" + pass + + +@dataclass +class ChatLogged(SerializableAttrs): + logId: Long + +@dataclass +class ChatLoggedType(ChatLogged): + type: ChatType + +@dataclass +class ChatLogLinked(ChatLogged): + prevLogId: Long + + +@dataclass +class ChatWritten(Chat): + sender: ChannelUser + sendAt: int + messageId: int | Long + + +@dataclass +class Chatlog(ChatLogLinked, ChatWritten): + pass + + +@dataclass +class TypedChatlog(Chatlog, TypedChat): + """Substitute for TypedChatlog = Chatlog & TypedChat""" + pass + + +@dataclass(kw_only=True) +class ChatOptions(SerializableAttrs): + shout: bool | None = None + + +__all__ = [ + "ChatTypeComponent", + "Chat", + "TypedChat", + "ChatLogged", + "ChatLoggedType", + "ChatLogLinked", + "ChatWritten", + "Chatlog", + "TypedChatlog", + "ChatOptions", +] diff --git a/matrix_appservice_kakaotalk/kt/types/chat/chat_type.py b/matrix_appservice_kakaotalk/kt/types/chat/chat_type.py new file mode 100644 index 0000000..e65521b --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/chat/chat_type.py @@ -0,0 +1,67 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from enum import IntEnum + + +class KnownChatType(IntEnum): + FEED = 0 + TEXT = 1 + PHOTO = 2 + VIDEO = 3 + CONTACT = 4 + AUDIO = 5 + DITEMEMOTICON = 6 + DITEMGIFT = 7 + DITEMIMG = 8 + KAKAOLINKV1 = 9 + AVATAR = 11 + STICKER = 12 + SCHEDULE = 13 + VOTE = 14 + LOTTERY = 15 + MAP = 16 + PROFILE = 17 + FILE = 18 + STICKERANI = 20 + NUDGE = 21 + ACTIONCON = 22 + SEARCH = 23 + POST = 24 + STICKERGIF = 25 + REPLY = 26 + MULTIPHOTO = 27 + VOIP = 51 + LIVETALK = 52 + CUSTOM = 71 + ALIM = 72 + PLUSFRIEND = 81 + PLUSEVENT = 82 + PLUSFRIENDVIRAL = 83 + OPEN_SCHEDULE = 96 + OPEN_VOTE = 97 + OPEN_POST = 98 + +ChatType = KnownChatType | int + + +DELETED_MESSAGE_OFFSET = 16384; + + +__all__ = [ + "KnownChatType", + "ChatType", + "DELETED_MESSAGE_OFFSET", +] diff --git a/matrix_appservice_kakaotalk/kt/types/client/__init__.py b/matrix_appservice_kakaotalk/kt/types/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/matrix_appservice_kakaotalk/kt/types/client/client_session.py b/matrix_appservice_kakaotalk/kt/types/client/client_session.py new file mode 100644 index 0000000..c6c7b2b --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/client/client_session.py @@ -0,0 +1,57 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import NewType + +from attr import dataclass + +from mautrix.types import SerializableAttrs, JSON, deserializer + +from ..bson import Long +from ..channel.channel_info import ChannelLoginData, NormalChannelData +from ..openlink.open_channel_info import OpenChannelData + + +ChannelLoginDataItem = NewType("ChannelLoginDataItem", ChannelLoginData[NormalChannelData | OpenChannelData]) + +@deserializer(ChannelLoginDataItem) +def deserialize_channel_login_data_item(data: JSON) -> ChannelLoginDataItem: + channel_data = data["channel"] + if "linkId" in channel_data: + data["channel"] = OpenChannelData.deserialize(channel_data) + else: + data["channel"] = NormalChannelData.deserialize(channel_data) + return ChannelLoginData.deserialize(data) + +setattr(ChannelLoginDataItem, "deserialize", deserialize_channel_login_data_item) + + +@dataclass +class LoginResult(SerializableAttrs): + """Return value of TalkClient.login""" + channelList: list[ChannelLoginDataItem] + userId: Long + lastChannelId: Long + lastTokenId: Long + mcmRevision: int + removedChannelIdList: list[Long] + revision: int + revisionInfo: str + minLogId: Long + + +__all__ = [ + "LoginResult", +] diff --git a/matrix_appservice_kakaotalk/kt/types/oauth.py b/matrix_appservice_kakaotalk/kt/types/oauth.py new file mode 100644 index 0000000..605c406 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/oauth.py @@ -0,0 +1,41 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from mautrix.types import SerializableAttrs + +from .bson import Long + + +@dataclass +class OAuthCredential(SerializableAttrs): + userId: Long + deviceUUID: str + accessToken: str + refreshToken: str + + +@dataclass +class OAuthInfo(SerializableAttrs): + type: str + credential: OAuthCredential + expiresIn: int + + +__all__ = [ + "OAuthCredential", + "OAuthInfo", +] diff --git a/matrix_appservice_kakaotalk/kt/types/openlink/__init__.py b/matrix_appservice_kakaotalk/kt/types/openlink/__init__.py new file mode 100644 index 0000000..a850328 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/openlink/__init__.py @@ -0,0 +1,146 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +""" +from .open_channel_info import * +from .open_channel import * +from .open_link_type import * +from .open_link_user_info import * +""" + + +from typing import TYPE_CHECKING + +from attr import dataclass +from enum import IntEnum + +from mautrix.types import SerializableAttrs + +from ..bson import Long +from .open_link_type import OpenLinkType + +if TYPE_CHECKING: + from .open_link_user_info import OpenLinkUserInfo + + +@dataclass +class OpenLinkComponent(SerializableAttrs): + linkId: Long + + +@dataclass +class OpenTokenComponent(SerializableAttrs): + openToken: int + + +# Moved before OpenPrivilegeComponent which requires this +class KnownLinkPrivilegeMask(IntEnum): + URL_SHARABLE = 2 + REPORTABLE = 4 + PROFILE_EDITABLE = 8 + ANY_PROFILE_ALLOWED = 32 + USE_PASS_CODE = 64 + BLINDABLE = 128 + NON_SPECIAL_LINK = 512 + USE_BOT = 1024 + +LinkPrivilegeMask = KnownLinkPrivilegeMask | int | Long; + + +@dataclass +class OpenPrivilegeComponent(SerializableAttrs): + privilege: LinkPrivilegeMask + + +@dataclass(kw_only=True) +class OpenLinkSettings(SerializableAttrs): + linkName: str + linkCoverURL: str | None = None + description: str | None = None + searchable: bool + activated: bool + + +@dataclass +class OpenLink(OpenLinkSettings, OpenLinkComponent, OpenTokenComponent, OpenPrivilegeComponent): + type: OpenLinkType + linkURL: str + openToken: int + linkOwner: "OpenLinkUserInfo" + profileTagList: list[str] + createdAt: int + + +@dataclass +class OpenLinkChannelInfo(SerializableAttrs): + userLimit: int + + +@dataclass +class OpenLinkProfileInfo(SerializableAttrs): + directLimit: int + + +@dataclass +class OpenLinkInfo(OpenLinkChannelInfo, OpenLinkProfileInfo): + pass + + +@dataclass +class InformedOpenLink(SerializableAttrs): + openLink: OpenLink + info: OpenLinkInfo + + +# KnownLinkPrivilegeMask and LinkPrivilegeMask moved from here + + +@dataclass(kw_only=True) +class OpenLinkUpdateTemplate(SerializableAttrs): + passcode: str | None = None + + +@dataclass(kw_only=True) +class OpenLinkCreateTemplate(SerializableAttrs): + mainProfileOnly: bool | None = None + + +@dataclass +class OpenLinkProfileTemplate(OpenLinkSettings, OpenLinkProfileInfo): + tags: str + + +@dataclass +class OpenLinkChannelTemplate(OpenLinkSettings, OpenLinkChannelInfo): + pass + + +__all__ = [ + "OpenLinkComponent", + "OpenTokenComponent", + "KnownLinkPrivilegeMask", + "LinkPrivilegeMask", + "OpenPrivilegeComponent", + "OpenLinkSettings", + "OpenLink", + "OpenLinkChannelInfo", + "OpenLinkProfileInfo", + "OpenLinkInfo", + "InformedOpenLink", + "OpenLinkUpdateTemplate", + "OpenLinkCreateTemplate", + "OpenLinkProfileTemplate", + "OpenLinkChannelTemplate", +] diff --git a/matrix_appservice_kakaotalk/kt/types/openlink/open_channel.py b/matrix_appservice_kakaotalk/kt/types/openlink/open_channel.py new file mode 100644 index 0000000..240c63d --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/openlink/open_channel.py @@ -0,0 +1,29 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from ..channel.channel import Channel +from . import OpenLinkComponent + + +@dataclass +class OpenChannel(Channel, OpenLinkComponent): + pass + + +__all__ = [ + "OpenChannel", +] diff --git a/matrix_appservice_kakaotalk/kt/types/openlink/open_channel_info.py b/matrix_appservice_kakaotalk/kt/types/openlink/open_channel_info.py new file mode 100644 index 0000000..425a097 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/openlink/open_channel_info.py @@ -0,0 +1,42 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from mautrix.types import JSON + +from ..channel.channel_info import ChannelInfo, ChannelData +from .open_channel import OpenChannel +from . import OpenTokenComponent, OpenLink + + +@dataclass(kw_only=True) +class OpenChannelInfo(ChannelInfo, OpenChannel, OpenTokenComponent): + directChannel: bool + openLink: OpenLink | None = None + + +@dataclass +class OpenChannelData(OpenChannel, ChannelData[OpenChannelInfo]): + @classmethod + def deserialize(cls, data: JSON) -> "OpenChannelData": + data["info"] = OpenChannelInfo.deserialize(data["info"]) + return super().deserialize(data) + + +__all__ = [ + "OpenChannelInfo", + "OpenChannelData", +] diff --git a/matrix_appservice_kakaotalk/kt/types/openlink/open_link_type.py b/matrix_appservice_kakaotalk/kt/types/openlink/open_link_type.py new file mode 100644 index 0000000..82efaec --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/openlink/open_link_type.py @@ -0,0 +1,50 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from enum import IntEnum + + +class OpenLinkType(IntEnum): + PROFILE = 1 + CHANNEL = 2 + + +class OpenChannelUserPerm(IntEnum): + OWNER = 1 + NONE = 2 + MANAGER = 4 + BOT = 8 + + +class OpenProfileType(IntEnum): + MAIN = 1 + KAKAO_ANON = 2 + KAKAO_ANON_2 = 4 + UNKNOWN_1 = 8 + OPEN_PROFILE = 16 + + +class OpenChannelType(IntEnum): + UNKNOWN = 0 + DIRECT = 1 + GROUP = 2 + + +__all__ = [ + "OpenLinkType", + "OpenChannelUserPerm", + "OpenProfileType", + "OpenChannelType", +] diff --git a/matrix_appservice_kakaotalk/kt/types/openlink/open_link_user_info.py b/matrix_appservice_kakaotalk/kt/types/openlink/open_link_user_info.py new file mode 100644 index 0000000..13b664b --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/openlink/open_link_user_info.py @@ -0,0 +1,61 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from ..bson import Long +from ..user.channel_user import ChannelUser +from ..user.channel_user_info import DisplayUserInfo, OpenChannelUserInfo +from . import OpenTokenComponent, OpenLinkComponent, OpenPrivilegeComponent +from .open_link_type import OpenChannelUserPerm, OpenProfileType + +@dataclass +class OpenLinkKickedUser(ChannelUser): + kickedChannelId: Long + + +@dataclass +class OpenLinkKickedUserInfo(OpenLinkKickedUser, DisplayUserInfo): + pass + + +@dataclass +class OpenLinkUserInfo(OpenLinkComponent, OpenTokenComponent, OpenPrivilegeComponent): + nickname: str + profileURL: str + fullProfileURL: str + originalProfileURL: str + profileType: OpenProfileType + perm: OpenChannelUserPerm + + +@dataclass +class OpenChannelUserInfoMix(OpenLinkComponent, OpenChannelUserInfo): + """Substitute for OpenChannelUserInfo = OpenLinkComponent & OpenChannelUserInfo""" + pass + + +@dataclass +class OpenLinkChannelUserInfo(OpenLinkUserInfo, OpenChannelUserInfoMix): + pass + + +__all__ = [ + "OpenLinkKickedUser", + "OpenLinkKickedUserInfo", + "OpenLinkUserInfo", + "OpenChannelUserInfoMix", + "OpenLinkChannelUserInfo", +] diff --git a/matrix_appservice_kakaotalk/kt/types/request.py b/matrix_appservice_kakaotalk/kt/types/request.py new file mode 100644 index 0000000..204ada3 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/request.py @@ -0,0 +1,114 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Generic, Type, TypeVar + +from attr import dataclass +from enum import IntEnum + +from mautrix.types import SerializableAttrs, JSON + +from .api.auth_api_client import KnownAuthStatusCode + + +class KnownDataStatusCode(IntEnum): + SUCCESS = 0 + INVALID_USER = -1 + CLIENT_ERROR = -200 + NOT_LOGON = -201 + INVALID_METHOD = -202 + INVALID_PARAMETER = -203 + INVALID_BODY = -203 + INVALID_HEADER = -204 + UNAUTHORIZED_CHAT_DELETE = -210 + MEDIA_SERVER_ERROR = -300 + CHAT_SPAM_LIMIT = -303 + RESTRICTED_APP = -304 + LOGINLIST_CHATLIST_FAILED = -305 + MEDIA_NOT_FOUND = -306 + MEDIA_THUMB_GEN_FAILED = -307 + UNSUPPORTED = -308 + PARTIAL = -310 + LINK_JOIN_TPS_EXCEEDED = -312 + CHAT_SEND_RESTRICTED = -321 + CHANNEL_CREATE_TEMP_RESTRICTED = -322 + CHANNEL_CREATE_RESTRICTED = -323 + OPENLINK_UNAVAILABLE = -324 + INVITE_COUNT_LIMITED = -325 + OPENLINK_CREATE_RESTRICTED = -326 + INVALID_CHANNEL = -401 + CHAT_BLOCKED_BY_FRIEND = -402 + NOT_CHATABLE_USER = -403 + GAME_MESSAGE_BLOCKED = -406 + BLOCKED_IP = -444 + BACKGROUND_LOGIN_BLOCKED = -445 + OPERATION_DENIED = -500 + CHANNEL_USER_LIMITED = -501 + TEMP_RESTRICTED = -805 + WRITE_WHILE_BLOCKED = -814 + OPENCHAT_REJOIN_REQUIRED = -815 + OPENCHAT_TIME_RESTRICTED = -819 + INVALID_ACCESS_TOKEN = -950 + BLOCKED_ACCOUNT = -997 + AUTH_REQUIRED = -998 + UPDATE_REQUIRED = -999 + SERVER_UNDER_MAINTENANCE = -9797 + +DataStatusCode = KnownDataStatusCode | int + + +@dataclass +class ResponseState(SerializableAttrs): + status: DataStatusCode | KnownAuthStatusCode # NOTE Added KnownAuthStatusCode + + +@dataclass +class RootCommandResult(ResponseState): + """For brevity, this also encompasses CommandResultFailed and CommandResultDoneVoid""" + success: bool + + +ResultType = TypeVar("ResultType", bound=SerializableAttrs) + +@dataclass +class CommandResultDoneValue(RootCommandResult, Generic[ResultType]): + result: ResultType + + @classmethod + def deserialize_result( + cls, result_type: Type[ResultType], data: JSON + ) -> "CommandResultDoneValue[ResultType]": + data["result"] = result_type.deserialize(data["result"]) + return cls.deserialize(data) + + +def deserialize_result( + result_type: Type[ResultType], data: JSON +) -> CommandResultDoneValue[ResultType] | RootCommandResult: + """Returns equivalent of CommandResult. Does no consistency checking on success & result properties.""" + if "result" in data: + # TODO Allow arbitrary result object? + return CommandResultDoneValue.deserialize_result(result_type, data) + else: + return RootCommandResult.deserialize(data) + + +__all__ = [ + "KnownDataStatusCode", + "DataStatusCode", + "ResponseState", + "RootCommandResult", + "CommandResultDoneValue", +] diff --git a/matrix_appservice_kakaotalk/kt/types/user/__init__.py b/matrix_appservice_kakaotalk/kt/types/user/__init__.py new file mode 100644 index 0000000..c169249 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/user/__init__.py @@ -0,0 +1,20 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +""" +from .channel_user_info import * +from .channel_user import * +from .user_type import * +""" diff --git a/matrix_appservice_kakaotalk/kt/types/user/channel_user.py b/matrix_appservice_kakaotalk/kt/types/user/channel_user.py new file mode 100644 index 0000000..914e2ac --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/user/channel_user.py @@ -0,0 +1,43 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from mautrix.types import SerializableAttrs + +from ..bson import Long + + +@dataclass +class ChannelUser(SerializableAttrs): + userId: Long + + +@dataclass(kw_only=True) +class PartialOpenLinkComponent(SerializableAttrs): + """Substitute for Partial""" + linkId: Long | None = None + + +@dataclass +class OpenChannelUser(ChannelUser, PartialOpenLinkComponent): + pass + + +__all__ = [ + "ChannelUser", + "PartialOpenLinkComponent", + "OpenChannelUser", +] diff --git a/matrix_appservice_kakaotalk/kt/types/user/channel_user_info.py b/matrix_appservice_kakaotalk/kt/types/user/channel_user_info.py new file mode 100644 index 0000000..2f44a7a --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/user/channel_user_info.py @@ -0,0 +1,57 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from attr import dataclass + +from ..openlink.open_link_type import OpenChannelUserPerm +from ..openlink import OpenTokenComponent +from .channel_user import ChannelUser, OpenChannelUser +from .user_type import UserType + + +@dataclass +class DisplayUserInfo(ChannelUser): + nickname: str + profileURL: str + + +@dataclass +class ChannelUserInfo(DisplayUserInfo): + fullProfileURL: str + originalProfileURL: str + userType: UserType + + +@dataclass +class NormalChannelUserInfo(ChannelUserInfo): + countryIso: str + accountId: int + statusMessage: str + linkedServices: str + ut: int + suspended: bool + + +@dataclass +class OpenChannelUserInfo(OpenChannelUser, ChannelUserInfo, OpenTokenComponent): + perm: OpenChannelUserPerm + + +__all__ = [ + "DisplayUserInfo", + "ChannelUserInfo", + "NormalChannelUserInfo", + "OpenChannelUserInfo", +] diff --git a/matrix_appservice_kakaotalk/kt/types/user/user_type.py b/matrix_appservice_kakaotalk/kt/types/user/user_type.py new file mode 100644 index 0000000..a3208a1 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/user/user_type.py @@ -0,0 +1,29 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from enum import IntEnum + + +class UserType(IntEnum): + UNDEFINED = -999999 + NOT_FRIEND = -100 + DEACTIVATED = 9 + FRIEND = 100 + OPEN_PROFILE = 1000 + + +__all__ = [ + "UserType", +] diff --git a/matrix_appservice_kakaotalk/matrix.py b/matrix_appservice_kakaotalk/matrix.py new file mode 100644 index 0000000..c4ab31d --- /dev/null +++ b/matrix_appservice_kakaotalk/matrix.py @@ -0,0 +1,265 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING +import time + +from mautrix.bridge import BaseMatrixHandler +from mautrix.errors import MatrixError +from mautrix.types import ( + Event, + EventID, + EventType, + MessageType, + PresenceEvent, + ReactionEvent, + ReactionEventContent, + ReceiptEvent, + RedactionEvent, + RelationType, + RoomID, + SingleReceiptEventContent, + TextMessageEventContent, + TypingEvent, + UserID, +) + +from . import portal as po, puppet as pu, user as u +from .db import Message as DBMessage + +if TYPE_CHECKING: + from .__main__ import KakaoTalkBridge + + +class MatrixHandler(BaseMatrixHandler): + def __init__(self, bridge: KakaoTalkBridge) -> None: + prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":") + homeserver = bridge.config["homeserver.domain"] + self.user_id_prefix = f"@{prefix}" + self.user_id_suffix = f"{suffix}:{homeserver}" + super().__init__(bridge=bridge) + + async def send_welcome_message(self, room_id: RoomID, inviter: u.User) -> None: + await super().send_welcome_message(room_id, inviter) + if not inviter.notice_room: + inviter.notice_room = room_id + await inviter.save() + await self.az.intent.send_notice( + room_id, "This room has been marked as your KakaoTalk bridge notice room." + ) + + async def handle_puppet_invite( + self, room_id: RoomID, puppet: pu.Puppet, invited_by: u.User, event_id: EventID + ) -> None: + intent = puppet.default_mxid_intent + self.log.debug(f"{invited_by.mxid} invited puppet for {puppet.ktid} to {room_id}") + if not await invited_by.is_logged_in(): + await intent.error_and_leave( + room_id, + text="Please log in before inviting KakaoTalk puppets to private chats.", + ) + return + + portal = await po.Portal.get_by_mxid(room_id) + if portal: + if portal.is_direct: + await intent.error_and_leave( + room_id, text="You can not invite additional users to private chats." + ) + return + # TODO add KakaoTalk inviting + # await portal.invite_kakaotalk(inviter, puppet) + # await intent.join_room(room_id) + return + await intent.join_room(room_id) + try: + members = await intent.get_room_members(room_id) + except MatrixError: + self.log.exception(f"Failed to get member list after joining {room_id}") + await intent.leave_room(room_id) + return + if len(members) > 2: + # TODO add KakaoTalk group creating + await intent.send_notice( + room_id, "You can not invite KakaoTalk puppets to multi-user rooms." + ) + await intent.leave_room(room_id) + return + portal = await po.Portal.get_by_ktid( + puppet.ktid, fb_receiver=invited_by.ktid # TODO kt_type=?? + ) + if portal.mxid: + try: + await intent.invite_user(portal.mxid, invited_by.mxid, check_cache=False) + await intent.send_notice( + room_id, + text=f"You already have a private chat with me in room {portal.mxid}", + html=( + "You already have a private chat with me: " + f"Link to room" + ), + ) + await intent.leave_room(room_id) + return + except MatrixError: + pass + portal.mxid = room_id + e2be_ok = await portal.check_dm_encryption() + await portal.save() + if e2be_ok is True: + evt_type, content = await self.e2ee.encrypt( + room_id, + EventType.ROOM_MESSAGE, + TextMessageEventContent( + msgtype=MessageType.NOTICE, + body="Portal to private chat created and end-to-bridge encryption enabled.", + ), + ) + await intent.send_message_event(room_id, evt_type, content) + else: + message = "Portal to private chat created." + if e2be_ok is False: + message += "\n\nWarning: Failed to enable end-to-bridge encryption" + await intent.send_notice(room_id, message) + + async def handle_invite( + self, room_id: RoomID, user_id: UserID, invited_by: u.User, event_id: EventID + ) -> None: + # TODO handle puppet and user invites for group chats + # The rest can probably be ignored + pass + + async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: + user = await u.User.get_by_mxid(user_id) + + portal = await po.Portal.get_by_mxid(room_id) + if not portal: + return + + if not user.relay_whitelisted: + await portal.main_intent.kick_user( + room_id, user.mxid, "You are not whitelisted on this KakaoTalk bridge." + ) + return + elif ( + not await user.is_logged_in() + and not portal.has_relay + and not self.config["bridge.allow_invites"] + ): + await portal.main_intent.kick_user( + room_id, user.mxid, "You are not logged in to this KakaoTalk bridge." + ) + return + + self.log.debug(f"{user.mxid} joined {room_id}") + # await portal.join_matrix(user, event_id) + + async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: + portal = await po.Portal.get_by_mxid(room_id) + if not portal: + return + + user = await u.User.get_by_mxid(user_id, create=False) + if not user: + return + + await portal.handle_matrix_leave(user) + + @staticmethod + async def handle_redaction( + room_id: RoomID, user_id: UserID, event_id: EventID, redaction_event_id: EventID + ) -> None: + user = await u.User.get_by_mxid(user_id) + if not user: + return + + portal = await po.Portal.get_by_mxid(room_id) + if not portal: + return + + await portal.handle_matrix_redaction(user, event_id, redaction_event_id) + + @classmethod + async def handle_reaction( + cls, + room_id: RoomID, + user_id: UserID, + event_id: EventID, + content: ReactionEventContent, + ) -> None: + if content.relates_to.rel_type != RelationType.ANNOTATION: + cls.log.debug( + f"Ignoring m.reaction event in {room_id} from {user_id} with unexpected " + f"relation type {content.relates_to.rel_type}" + ) + return + user = await u.User.get_by_mxid(user_id) + if not user: + return + + portal = await po.Portal.get_by_mxid(room_id) + if not portal: + return + + await portal.handle_matrix_reaction( + user, event_id, content.relates_to.event_id, content.relates_to.key + ) + + @staticmethod + async def handle_typing(room_id: RoomID, typing: list[UserID]) -> None: + portal = await po.Portal.get_by_mxid(room_id) + if not portal or not portal.is_direct: + return + + await portal.handle_matrix_typing(set(typing)) + + async def handle_read_receipt( + self, + user: u.User, + portal: po.Portal, + event_id: EventID, + data: SingleReceiptEventContent, + ) -> None: + self.log.info("TODO") + """ + if not user.mqtt: + return + timestamp = data.get("ts", int(time.time() * 1000)) + message = await DBMessage.get_by_mxid(event_id, portal.mxid) + await user.mqtt.mark_read( + portal.ktid, + True, # TODO + #portal.fb_type != ThreadType.USER, + read_to=message.timestamp if message else timestamp, + ) + """ + + async def handle_ephemeral_event( + self, evt: ReceiptEvent | PresenceEvent | TypingEvent + ) -> None: + if evt.type == EventType.TYPING: + await self.handle_typing(evt.room_id, evt.content.user_ids) + elif evt.type == EventType.RECEIPT: + await self.handle_receipt(evt) + + async def handle_event(self, evt: Event) -> None: + if evt.type == EventType.ROOM_REDACTION: + evt: RedactionEvent + await self.handle_redaction(evt.room_id, evt.sender, evt.redacts, evt.event_id) + elif evt.type == EventType.REACTION: + evt: ReactionEvent + await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content) diff --git a/matrix_appservice_kakaotalk/portal.py b/matrix_appservice_kakaotalk/portal.py new file mode 100644 index 0000000..e1d82a1 --- /dev/null +++ b/matrix_appservice_kakaotalk/portal.py @@ -0,0 +1,861 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, AsyncGenerator, Pattern, cast +from collections import deque +import asyncio +import re + +from mautrix.appservice import IntentAPI +from mautrix.bridge import BasePortal, NotificationDisabler, async_getter_lock +from mautrix.errors import MatrixError +from mautrix.types import ( + ContentURI, + EventID, + EventType, + LocationMessageEventContent, + MediaMessageEventContent, + Membership, + MessageEventContent, + MessageType, + RoomID, + TextMessageEventContent, + UserID, +) +from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus +from mautrix.util.simple_lock import SimpleLock + +from . import matrix as m, puppet as p, user as u +from .config import Config +from .db import ( + Message as DBMessage, + Portal as DBPortal, +) + +from .kt.types.bson import Long +from .kt.types.channel.channel_type import KnownChannelType, ChannelType +from .kt.types.user.channel_user_info import DisplayUserInfo + +from .kt.client.types import PortalChannelInfo + +if TYPE_CHECKING: + from .__main__ import KakaoTalkBridge + +try: + from PIL import Image +except ImportError: + Image = None + +try: + from mautrix.crypto.attachments import decrypt_attachment, encrypt_attachment +except ImportError: + decrypt_attachment = encrypt_attachment = None + +geo_uri_regex: Pattern = re.compile(r"^geo:(-?\d+.\d+),(-?\d+.\d+)$") + + +class FakeLock: + async def __aenter__(self) -> None: + pass + + async def __aexit__(self, exc_type, exc, tb) -> None: + pass + + +StateBridge = EventType.find("m.bridge", EventType.Class.STATE) +StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE) + + +class Portal(DBPortal, BasePortal): + invite_own_puppet_to_pm: bool = False + by_mxid: dict[RoomID, Portal] = {} + by_ktid: dict[tuple[Long, Long], Portal] = {} + matrix: m.MatrixHandler + config: Config + + _main_intent: IntentAPI | None + _create_room_lock: asyncio.Lock + _dedup: deque[str] + _oti_dedup: dict[int, DBMessage] + _send_locks: dict[int, asyncio.Lock] + _noop_lock: FakeLock = FakeLock() + _typing: set[UserID] + backfill_lock: SimpleLock + _backfill_leave: set[IntentAPI] | None + + def __init__( + self, + ktid: Long, + kt_receiver: Long, + kt_type: ChannelType, + mxid: RoomID | None = None, + name: str | None = None, + photo_id: str | None = None, + avatar_url: ContentURI | None = None, + encrypted: bool = False, + name_set: bool = False, + avatar_set: bool = False, + relay_user_id: UserID | None = None, + ) -> None: + super().__init__( + ktid, + kt_receiver, + kt_type, + mxid, + name, + photo_id, + avatar_url, + encrypted, + name_set, + avatar_set, + relay_user_id, + ) + self.log = self.log.getChild(self.ktid_log) + + self._main_intent = None + self._create_room_lock = asyncio.Lock() + self._dedup = deque(maxlen=100) + self._oti_dedup = {} + self._send_locks = {} + self._typing = set() + + self.backfill_lock = SimpleLock( + "Waiting for backfilling to finish before handling %s", log=self.log + ) + self._backfill_leave = None + + self._relay_user = None + + @classmethod + def init_cls(cls, bridge: KakaoTalkBridge) -> None: + BasePortal.bridge = bridge + cls.az = bridge.az + cls.config = bridge.config + cls.loop = bridge.loop + cls.matrix = bridge.matrix + cls.invite_own_puppet_to_pm = cls.config["bridge.invite_own_puppet_to_pm"] + NotificationDisabler.puppet_cls = p.Puppet + NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"] + + # region DB conversion + + async def delete(self) -> None: + if self.mxid: + await DBMessage.delete_all_by_room(self.mxid) + self.by_ktid.pop(self.ktid_full, None) + self.by_mxid.pop(self.mxid, None) + await super().delete() + + # endregion + # region Properties + + @property + def ktid_full(self) -> tuple[Long, Long]: + return self.ktid, self.kt_receiver + + @property + def ktid_log(self) -> str: + if self.is_direct: + return f"{self.ktid}<->{self.kt_receiver}" + return str(self.ktid) + + @property + def is_direct(self) -> bool: + return KnownChannelType.is_direct(self.kt_type) + + @property + def main_intent(self) -> IntentAPI: + if not self._main_intent: + raise ValueError("Portal must be postinit()ed before main_intent can be used") + return self._main_intent + + # endregion + # region Chat info updating + + async def update_info( + self, + source: u.User, + info: PortalChannelInfo, + force_save: bool = False, + ) -> None: + changed = False + if not self.is_direct: + changed = any( + await asyncio.gather( + self._update_name(info.name), + # TODO + #self._update_photo(source, info.image), + ) + ) + changed = await self._update_participants(source, info.channel_info.displayUserList) or changed + if changed or force_save: + await self.update_bridge_info() + await self.save() + + """ + @classmethod + async def _reupload_kt_file( + cls, + url: str, + source: u.User, + intent: IntentAPI, + *, + filename: str | None = None, + encrypt: bool = False, + referer: str = "messenger_thread_photo", + find_size: bool = False, + convert_audio: bool = False, + ) -> tuple[ContentURI, FileInfo | VideoInfo | AudioInfo | ImageInfo, EncryptedFile | None]: + if not url: + raise ValueError("URL not provided") + headers = {"referer": f"fbapp://{source.state.application.client_id}/{referer}"} + sandbox = cls.config["bridge.sandbox_media_download"] + async with source.client.get(url, headers=headers, sandbox=sandbox) as resp: + length = int(resp.headers["Content-Length"]) + if length > cls.matrix.media_config.upload_size: + raise ValueError("File not available: too large") + data = await resp.read() + mime = magic.from_buffer(data, mime=True) + if convert_audio and mime != "audio/ogg": + data = await ffmpeg.convert_bytes( + data, ".ogg", output_args=("-c:a", "libopus"), input_mime=mime + ) + mime = "audio/ogg" + info = FileInfo(mimetype=mime, size=len(data)) + if Image and mime.startswith("image/") and find_size: + with Image.open(BytesIO(data)) as img: + width, height = img.size + info = ImageInfo(mimetype=mime, size=len(data), width=width, height=height) + upload_mime_type = mime + decryption_info = None + if encrypt and encrypt_attachment: + data, decryption_info = encrypt_attachment(data) + upload_mime_type = "application/octet-stream" + filename = None + url = await intent.upload_media(data, mime_type=upload_mime_type, filename=filename) + if decryption_info: + decryption_info.url = url + return url, info, decryption_info + """ + + async def _update_name(self, name: str) -> bool: + if not name: + self.log.warning("Got empty name in _update_name call") + return False + if self.name != name or not self.name_set: + self.log.trace("Updating name %s -> %s", self.name, name) + self.name = name + if self.mxid and (self.encrypted or not self.is_direct): + try: + await self.main_intent.set_room_name(self.mxid, self.name) + self.name_set = True + except Exception: + self.log.exception("Failed to set room name") + self.name_set = False + return True + return False + + """ + async def _update_photo(self, source: u.User, photo: graphql.Picture) -> bool: + if self.is_direct and not self.encrypted: + return False + photo_id = self.get_photo_id(photo) + if self.photo_id != photo_id or not self.avatar_set: + self.photo_id = photo_id + if photo: + if self.photo_id != photo_id or not self.avatar_url: + # Reset avatar_url first in case the upload fails + self.avatar_url = None + self.avatar_url = await p.Puppet.reupload_avatar( + source, + self.main_intent, + photo.uri, + self.ktid, + use_graph=self.is_direct and (photo.height or 0) < 500, + ) + else: + self.avatar_url = ContentURI("") + if self.mxid: + try: + await self.main_intent.set_room_avatar(self.mxid, self.avatar_url) + self.avatar_set = True + except Exception: + self.log.exception("Failed to set room avatar") + self.avatar_set = False + return True + return False + """ + + async def _update_photo_from_puppet(self, puppet: p.Puppet) -> bool: + if self.photo_id == puppet.photo_id and self.avatar_set: + return False + self.photo_id = puppet.photo_id + if puppet.photo_mxc: + self.avatar_url = puppet.photo_mxc + elif self.photo_id: + profile = await self.main_intent.get_profile(puppet.default_mxid) + self.avatar_url = profile.avatar_url + puppet.photo_mxc = profile.avatar_url + else: + self.avatar_url = ContentURI("") + if self.mxid: + try: + await self.main_intent.set_room_avatar(self.mxid, self.avatar_url) + self.avatar_set = True + except Exception: + self.log.exception("Failed to set room avatar") + self.avatar_set = False + return True + + """ + async def sync_per_room_nick(self, puppet: p.Puppet, name: str) -> None: + intent = puppet.intent_for(self) + content = MemberStateEventContent( + membership=Membership.JOIN, + avatar_url=puppet.photo_mxc, + displayname=name or puppet.name, + ) + content[DOUBLE_PUPPET_SOURCE_KEY] = self.bridge.name + current_state = await intent.state_store.get_member(self.mxid, intent.mxid) + if not current_state or current_state.displayname != content.displayname: + self.log.debug( + "Syncing %s's per-room nick %s to the room", + puppet.ktid, + content.displayname, + ) + await intent.send_state_event( + self.mxid, EventType.ROOM_MEMBER, content, state_key=intent.mxid + ) + """ + + async def _update_participants(self, source: u.User, participants: list[DisplayUserInfo]) -> bool: + changed = False + # TODO nick_map? + for participant in participants: + puppet = await p.Puppet.get_by_ktid(participant.userId) + await puppet.update_info(source, participant) + if self.is_direct and self.ktid == puppet.ktid and self.encrypted: + changed = await self._update_name(puppet.name) or changed + changed = await self._update_photo_from_puppet(puppet) or changed + if self.mxid: + if puppet.ktid != self.kt_receiver or puppet.is_real_user: + await puppet.intent_for(self).ensure_joined(self.mxid, bot=self.main_intent) + #if puppet.ktid in nick_map: + # await self.sync_per_room_nick(puppet, nick_map[puppet.ktid]) + return changed + + # endregion + # region Matrix room creation + + async def update_matrix_room(self, source: u.User, info: PortalChannelInfo) -> None: + try: + await self._update_matrix_room(source, info) + except Exception: + self.log.exception("Failed to update portal") + + def _get_invite_content(self, double_puppet: p.Puppet | None) -> dict[str, Any]: + invite_content = {} + if double_puppet: + invite_content["fi.mau.will_auto_accept"] = True + if self.is_direct: + invite_content["is_direct"] = True + return invite_content + + async def _update_matrix_room( + self, source: u.User, info: PortalChannelInfo + ) -> None: + puppet = await p.Puppet.get_by_custom_mxid(source.mxid) + await self.main_intent.invite_user( + self.mxid, + source.mxid, + check_cache=True, + extra_content=self._get_invite_content(puppet), + ) + if puppet: + did_join = await puppet.intent.ensure_joined(self.mxid) + if did_join and self.is_direct: + await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) + + await self.update_info(source, info) + + # TODO + #await self._sync_read_receipts(info.read_receipts.nodes) + + """ + async def _sync_read_receipts(self, receipts: list[None]) -> None: + for receipt in receipts: + message = await DBMessage.get_closest_before( + self.ktid, self.kt_receiver, receipt.timestamp + ) + if not message: + continue + puppet = await p.Puppet.get_by_ktid(receipt.actor.id, create=False) + if not puppet: + continue + try: + await puppet.intent_for(self).mark_read(message.mx_room, message.mxid) + except Exception: + self.log.warning( + f"Failed to mark {message.mxid} in {message.mx_room} " + f"as read by {puppet.intent.mxid}", + exc_info=True, + ) + """ + + async def create_matrix_room( + self, source: u.User, info: PortalChannelInfo + ) -> RoomID | None: + if self.mxid: + try: + await self._update_matrix_room(source, info) + except Exception: + self.log.exception("Failed to update portal") + return self.mxid + async with self._create_room_lock: + try: + return await self._create_matrix_room(source, info) + except Exception: + self.log.exception("Failed to create portal") + return None + + @property + def bridge_info_state_key(self) -> str: + return f"net.miscworks.kakaotalk://kakaotalk/{self.ktid}" + + @property + def bridge_info(self) -> dict[str, Any]: + return { + "bridgebot": self.az.bot_mxid, + "creator": self.main_intent.mxid, + "protocol": { + "id": "kakaotalk", + "displayname": "KakaoTalk", + "avatar_url": self.config["appservice.bot_avatar"], + }, + "channel": { + "id": str(self.ktid), + "displayname": self.name, + "avatar_url": self.avatar_url, + }, + } + + async def update_bridge_info(self) -> None: + if not self.mxid: + self.log.debug("Not updating bridge info: no Matrix room created") + return + try: + self.log.debug("Updating bridge info...") + await self.main_intent.send_state_event( + self.mxid, StateBridge, self.bridge_info, self.bridge_info_state_key + ) + # TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec + await self.main_intent.send_state_event( + self.mxid, StateHalfShotBridge, self.bridge_info, self.bridge_info_state_key + ) + except Exception: + self.log.warning("Failed to update bridge info", exc_info=True) + + async def _create_matrix_room( + self, source: u.User, info: PortalChannelInfo + ) -> RoomID | None: + if self.mxid: + await self._update_matrix_room(source, info) + return self.mxid + + self.log.debug(f"Creating Matrix room") + name: str | None = None + initial_state = [ + { + "type": str(StateBridge), + "state_key": self.bridge_info_state_key, + "content": self.bridge_info, + }, + # TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec + { + "type": str(StateHalfShotBridge), + "state_key": self.bridge_info_state_key, + "content": self.bridge_info, + }, + ] + invites = [] + if self.config["bridge.encryption.default"] and self.matrix.e2ee: + self.encrypted = True + initial_state.append( + { + "type": "m.room.encryption", + "content": {"algorithm": "m.megolm.v1.aes-sha2"}, + } + ) + if self.is_direct: + invites.append(self.az.bot_mxid) + + await self.update_info(source=source, info=info) + + if self.encrypted or not self.is_direct: + name = self.name + initial_state.append( + { + "type": str(EventType.ROOM_AVATAR), + "content": {"url": self.avatar_url}, + } + ) + + # We lock backfill lock here so any messages that come between the room being created + # and the initial backfill finishing wouldn't be bridged before the backfill messages. + with self.backfill_lock: + creation_content = {} + if not self.config["bridge.federate_rooms"]: + creation_content["m.federate"] = False + self.mxid = await self.main_intent.create_room( + name=name, + is_direct=self.is_direct, + initial_state=initial_state, + invitees=invites, + creation_content=creation_content, + ) + if not self.mxid: + raise Exception("Failed to create room: no mxid returned") + + if self.encrypted and self.matrix.e2ee and self.is_direct: + try: + await self.az.intent.ensure_joined(self.mxid) + except Exception: + self.log.warning(f"Failed to add bridge bot to new private chat {self.mxid}") + + await self.save() + self.log.debug(f"Matrix room created: {self.mxid}") + self.by_mxid[self.mxid] = self + + puppet = await p.Puppet.get_by_custom_mxid(source.mxid) + await self.main_intent.invite_user( + self.mxid, source.mxid, extra_content=self._get_invite_content(puppet) + ) + if puppet: + try: + if self.is_direct: + await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) + await puppet.intent.join_room_by_id(self.mxid) + except MatrixError: + self.log.debug( + "Failed to join custom puppet into newly created portal", + exc_info=True, + ) + + if not self.is_direct: + await self._update_participants(source, info.channel_info.displayUserList) + + try: + await self.backfill(source, is_initial=True, channel=info.channel_info) + except Exception: + self.log.exception("Failed to backfill new portal") + + # TODO + #await self._sync_read_receipts(info.read_receipts.nodes) + + return self.mxid + + # endregion + # region Matrix event handling + + def require_send_lock(self, user_id: Long) -> asyncio.Lock: + try: + lock = self._send_locks[user_id] + except KeyError: + lock = asyncio.Lock() + self._send_locks[user_id] = lock + return lock + + def optional_send_lock(self, user_id: Long) -> asyncio.Lock | FakeLock: + try: + return self._send_locks[user_id] + except KeyError: + pass + return self._noop_lock + + async def _send_delivery_receipt(self, event_id: EventID) -> None: + if event_id and self.config["bridge.delivery_receipts"]: + try: + await self.az.intent.mark_read(self.mxid, event_id) + except Exception: + self.log.exception(f"Failed to send delivery receipt for {event_id}") + + async def _send_bridge_error(self, msg: str, thing: str = "message") -> None: + await self._send_message( + self.main_intent, + TextMessageEventContent( + msgtype=MessageType.NOTICE, + body=f"\u26a0 Your {thing} may not have been bridged: {msg}", + ), + ) + + def _status_from_exception(self, e: Exception) -> MessageSendCheckpointStatus: + if isinstance(e, NotImplementedError): + return MessageSendCheckpointStatus.UNSUPPORTED + return MessageSendCheckpointStatus.PERM_FAILURE + + async def handle_matrix_message( + self, sender: u.User, message: MessageEventContent, event_id: EventID + ) -> None: + try: + await self._handle_matrix_message(sender, message, event_id) + except Exception as e: + self.log.exception(f"Failed to handle Matrix event {event_id}: {e}") + sender.send_remote_checkpoint( + self._status_from_exception(e), + event_id, + self.mxid, + EventType.ROOM_MESSAGE, + message.msgtype, + error=e, + ) + await self._send_bridge_error(str(e)) + else: + await self._send_delivery_receipt(event_id) + + async def _handle_matrix_message( + self, orig_sender: u.User, message: MessageEventContent, event_id: EventID + ) -> None: + if message.get_edit(): + raise NotImplementedError("Edits are not supported by the KakaoTalk bridge.") + sender, is_relay = await self.get_relay_sender(orig_sender, f"message {event_id}") + if not sender: + raise Exception("not logged in") + elif not sender.has_state: + raise Exception("not connected to KakaoTalk") + elif is_relay: + await self.apply_relay_message_format(orig_sender, message) + if message.msgtype == MessageType.TEXT or message.msgtype == MessageType.NOTICE: + await self._handle_matrix_text(event_id, sender, message) + elif message.msgtype.is_media: + await self._handle_matrix_media(event_id, sender, message, is_relay) + # elif message.msgtype == MessageType.LOCATION: + # await self._handle_matrix_location(sender, message) + else: + raise NotImplementedError(f"Unsupported message type {message.msgtype}") + + async def _handle_matrix_text( + self, event_id: EventID, sender: u.User, message: TextMessageEventContent + ) -> None: + self.log.info("TODO: _handle_matrix_text") + + async def _handle_matrix_media( + self, event_id: EventID, sender: u.User, message: MediaMessageEventContent, is_relay: bool + ) -> None: + self.log.info("TODO: _handle_matrix_media") + + async def _handle_matrix_location( + self, sender: u.User, message: LocationMessageEventContent + ) -> str: + pass + # TODO + # match = geo_uri_regex.fullmatch(message.geo_uri) + # return await self.thread_for(sender).send_pinned_location(float(match.group(1)), + # float(match.group(2))) + + async def handle_matrix_redaction( + self, sender: u.User, event_id: EventID, redaction_event_id: EventID + ) -> None: + try: + await self._handle_matrix_redaction(sender, event_id, redaction_event_id) + except Exception as e: + self.log.exception(f"Failed to handle Matrix event {event_id}: {e}") + sender.send_remote_checkpoint( + self._status_from_exception(e), + event_id, + self.mxid, + EventType.ROOM_REDACTION, + error=e, + ) + await self._send_bridge_error(str(e)) + else: + await self._send_delivery_receipt(event_id) + + async def _handle_matrix_redaction( + self, sender: u.User, event_id: EventID, redaction_event_id: EventID + ) -> None: + self.log.info("TODO: _handle_matrix_redaction") + + async def handle_matrix_reaction( + self, sender: u.User, event_id: EventID, reacting_to: EventID, reaction: str + ) -> None: + self.log.info("TODO: handle_matrix_reaction") + + async def handle_matrix_leave(self, user: u.User) -> None: + if self.is_direct: + self.log.info(f"{user.mxid} left private chat portal with {self.ktid}") + if user.ktid == self.kt_receiver: + self.log.info( + f"{user.mxid} was the recipient of this portal. Cleaning up and deleting..." + ) + await self.cleanup_and_delete() + else: + self.log.debug(f"{user.mxid} left portal to {self.ktid}") + + async def _set_typing(self, users: set[UserID], typing: bool) -> None: + self.log.info("TODO: _set_typing") + + async def handle_matrix_typing(self, users: set[UserID]) -> None: + await asyncio.gather( + self._set_typing(users - self._typing, typing=True), + self._set_typing(self._typing - users, typing=False), + ) + self._typing = users + + async def enable_dm_encryption(self) -> bool: + ok = await super().enable_dm_encryption() + if ok: + try: + puppet = await p.Puppet.get_by_ktid(self.ktid) + await self.main_intent.set_room_name(self.mxid, puppet.name) + except Exception: + self.log.warning(f"Failed to set room name", exc_info=True) + return ok + + # endregion + # region KakaoTalk event handling + + async def _bridge_own_message_pm( + self, source: u.User, sender: p.Puppet, mid: str, invite: bool = True + ) -> bool: + if self.is_direct and sender.ktid == source.ktid and not sender.is_real_user: + if self.invite_own_puppet_to_pm and invite: + await self.main_intent.invite_user(self.mxid, sender.mxid) + elif ( + await self.az.state_store.get_membership(self.mxid, sender.mxid) != Membership.JOIN + ): + self.log.warning( + f"Ignoring own {mid} in private chat because own puppet is not in room." + ) + return False + return True + + async def _add_kakaotalk_reply( + self, content: MessageEventContent, reply_to: None + ) -> None: + self.log.info("TODO") + + async def handle_remote_message( + self, + source: u.User, + sender: p.Puppet, + message: str, + reply_to: None = None, + ) -> None: + try: + await self._handle_remote_message(source, sender, message, reply_to) + except Exception: + self.log.exception( + "Error handling Kakaotalk message " + ) + + async def _handle_remote_message( + self, + source: u.User, + sender: p.Puppet, + message: str, + reply_to: None = None, + ) -> None: + self.log.info("TODO") + + # TODO Many more remote handlers + + # endregion + + async def backfill(self, source: u.User, is_initial: bool, channel: PortalChannelInfo) -> None: + self.log.info("TODO: backfill") + + # region Database getters + + async def postinit(self) -> None: + self.by_ktid[self.ktid_full] = self + if self.mxid: + self.by_mxid[self.mxid] = self + self._main_intent = ( + (await p.Puppet.get_by_ktid(self.ktid)).default_mxid_intent + if self.is_direct + else self.az.intent + ) + + @classmethod + @async_getter_lock + async def get_by_mxid(cls, mxid: RoomID) -> Portal | None: + try: + return cls.by_mxid[mxid] + except KeyError: + pass + + portal = cast(cls, await super().get_by_mxid(mxid)) + if portal: + await portal.postinit() + return portal + + return None + + @classmethod + @async_getter_lock + async def get_by_ktid( + cls, + ktid: Long, + *, + kt_receiver: Long = Long.ZERO, + create: bool = True, + kt_type: ChannelType | None = None, + ) -> Portal | None: + if kt_type: + kt_receiver = kt_receiver if KnownChannelType.is_direct(kt_type) else Long.ZERO + ktid_full = (ktid, kt_receiver) + try: + return cls.by_ktid[ktid_full] + except KeyError: + pass + + portal = cast(cls, await super().get_by_ktid(ktid, kt_receiver)) + if portal: + await portal.postinit() + return portal + + if kt_type and create: + portal = cls(ktid=ktid, kt_receiver=kt_receiver, kt_type=kt_type) + await portal.insert() + await portal.postinit() + return portal + + return None + + @classmethod + async def get_all_by_receiver(cls, kt_receiver: Long) -> AsyncGenerator[Portal, None]: + portals = await super().get_all_by_receiver(kt_receiver) + portal: Portal + for portal in portals: + try: + yield cls.by_ktid[(portal.ktid, portal.kt_receiver)] + except KeyError: + await portal.postinit() + yield portal + + @classmethod + async def all(cls) -> AsyncGenerator[Portal, None]: + portals = await super().all() + portal: Portal + for portal in portals: + try: + yield cls.by_ktid[(portal.ktid, portal.kt_receiver)] + except KeyError: + await portal.postinit() + yield portal + + # endregion diff --git a/matrix_appservice_kakaotalk/puppet.py b/matrix_appservice_kakaotalk/puppet.py new file mode 100644 index 0000000..6b718e4 --- /dev/null +++ b/matrix_appservice_kakaotalk/puppet.py @@ -0,0 +1,280 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast +from datetime import datetime, timedelta +import asyncio + +from yarl import URL + +from mautrix.appservice import IntentAPI +from mautrix.bridge import BasePuppet, async_getter_lock +from mautrix.types import ContentURI, RoomID, SyncToken, UserID +from mautrix.util import magic +from mautrix.util.simple_template import SimpleTemplate + +from . import matrix as m, portal as p, user as u +from .config import Config +from .db import Puppet as DBPuppet + +from .kt.types.bson import Long +from .kt.types.user.channel_user_info import DisplayUserInfo + +if TYPE_CHECKING: + from .__main__ import KakaoTalkBridge + + +class Puppet(DBPuppet, BasePuppet): + mx: m.MatrixHandler + config: Config + hs_domain: str + mxid_template: SimpleTemplate[int] + + by_ktid: dict[Long, Puppet] = {} + by_custom_mxid: dict[UserID, Puppet] = {} + + _last_info_sync: datetime | None + + def __init__( + self, + ktid: Long, + name: str | None = None, + photo_id: str | None = None, + photo_mxc: ContentURI | None = None, + name_set: bool = False, + avatar_set: bool = False, + is_registered: bool = False, + custom_mxid: UserID | None = None, + access_token: str | None = None, + next_batch: SyncToken | None = None, + base_url: URL | None = None, + ) -> None: + super().__init__( + ktid, + name, + photo_id, + photo_mxc, + name_set, + avatar_set, + is_registered, + custom_mxid, + access_token, + next_batch, + base_url, + ) + self._last_info_sync = None + + self.default_mxid = self.get_mxid_from_id(ktid) + self.default_mxid_intent = self.az.intent.user(self.default_mxid) + self.intent = self._fresh_intent() + + self.log = self.log.getChild(str(self.ktid)) + + @property + def should_sync(self) -> bool: + now = datetime.now() + if not self._last_info_sync or now - self._last_info_sync > timedelta(hours=48): + self._last_info_sync = now + return True + return False + + async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool: + portal = await p.Portal.get_by_mxid(room_id) + return portal and portal.ktid != self.ktid + + async def _leave_rooms_with_default_user(self) -> None: + await super()._leave_rooms_with_default_user() + # Make the user join all private chat portals. + await asyncio.gather( + *[ + self.intent.ensure_joined(portal.mxid) + async for portal in p.Portal.get_all_by_receiver(self.ktid) + if portal.mxid + ] + ) + + def intent_for(self, portal: p.Portal) -> IntentAPI: + if portal.ktid == self.ktid or ( + portal.backfill_lock.locked and self.config["bridge.backfill.invite_own_puppet"] + ): + return self.default_mxid_intent + return self.intent + + @classmethod + def init_cls(cls, bridge: KakaoTalkBridge) -> AsyncIterable[Awaitable[None]]: + cls.config = bridge.config + cls.loop = bridge.loop + cls.mx = bridge.matrix + cls.az = bridge.az + cls.hs_domain = cls.config["homeserver.domain"] + cls.mxid_template = SimpleTemplate( + template=cls.config["bridge.username_template"], + keyword="userid", + prefix="@", + suffix=f":{Puppet.hs_domain}", + type=int, + ) + cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"] + cls.homeserver_url_map = { + server: URL(url) + for server, url in cls.config["bridge.double_puppet_server_map"].items() + } + cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"] + cls.login_shared_secret_map = { + server: secret.encode("utf-8") + for server, secret in cls.config["bridge.login_shared_secret_map"].items() + } + cls.login_device_name = "KakaoTalk Bridge" + + return (puppet.try_start() async for puppet in Puppet.get_all_with_custom_mxid()) + + # region User info updating + + async def update_info( + self, + source: u.User, + info: DisplayUserInfo, + update_avatar: bool = True, + ) -> Puppet: + self._last_info_sync = datetime.now() + try: + changed = await self._update_name(info) + if update_avatar: + changed = await self._update_photo(source, info.profileURL) or changed + if changed: + await self.save() + except Exception: + self.log.exception(f"Failed to update info from source {source.ktid}") + return self + + async def _update_name(self, info: DisplayUserInfo) -> bool: + name = info.nickname + if name != self.name or not self.name_set: + self.name = name + try: + await self.default_mxid_intent.set_displayname(self.name) + self.name_set = True + except Exception: + self.log.exception("Failed to set displayname") + self.name_set = False + return True + return False + + @staticmethod + async def reupload_avatar( + source: u.User, + intent: IntentAPI, + url: str, + ktid: int, + ) -> ContentURI: + async with source.client.get(url) as resp: + data = await resp.read() + mime = magic.mimetype(data) + return await intent.upload_media(data, mime_type=mime) + + async def _update_photo(self, source: u.User, photo_id: str) -> bool: + if photo_id != self.photo_id or not self.avatar_set: + self.photo_id = photo_id + if photo_id: + self.photo_mxc = await self.reupload_avatar( + source, + self.default_mxid_intent, + photo_id, + self.ktid, + ) + else: + self.photo_mxc = ContentURI("") + try: + await self.default_mxid_intent.set_avatar_url(self.photo_mxc) + self.avatar_set = True + except Exception: + self.log.exception("Failed to set avatar") + self.avatar_set = False + return True + return False + + # endregion + # region Database getters + + def _add_to_cache(self) -> None: + self.by_ktid[self.ktid] = self + if self.custom_mxid: + self.by_custom_mxid[self.custom_mxid] = self + + @classmethod + @async_getter_lock + async def get_by_ktid(cls, ktid: Long, *, create: bool = True) -> Puppet | None: + try: + return cls.by_ktid[ktid] + except KeyError: + pass + + puppet = cast(cls, await super().get_by_ktid(ktid)) + if puppet: + puppet._add_to_cache() + return puppet + + if create: + puppet = cls(ktid) + await puppet.insert() + puppet._add_to_cache() + return puppet + + return None + + @classmethod + async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Puppet | None: + ktid = cls.get_id_from_mxid(mxid) + if ktid: + return await cls.get_by_ktid(ktid, create=create) + return None + + @classmethod + @async_getter_lock + async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None: + try: + return cls.by_custom_mxid[mxid] + except KeyError: + pass + + puppet = cast(cls, await super().get_by_custom_mxid(mxid)) + if puppet: + puppet._add_to_cache() + return puppet + + return None + + @classmethod + def get_id_from_mxid(cls, mxid: UserID) -> int | None: + return cls.mxid_template.parse(mxid) + + @classmethod + def get_mxid_from_id(cls, ktid: Long) -> UserID: + return UserID(cls.mxid_template.format_full(ktid)) + + @classmethod + async def get_all_with_custom_mxid(cls) -> AsyncGenerator[Puppet, None]: + puppets = await super().get_all_with_custom_mxid() + puppet: cls + for puppet in puppets: + try: + yield cls.by_ktid[puppet.ktid] + except KeyError: + puppet._add_to_cache() + yield puppet + + # endregion diff --git a/matrix_appservice_kakaotalk/rpc/__init__.py b/matrix_appservice_kakaotalk/rpc/__init__.py new file mode 100644 index 0000000..33792bd --- /dev/null +++ b/matrix_appservice_kakaotalk/rpc/__init__.py @@ -0,0 +1 @@ +from .rpc import RPCClient diff --git a/matrix_appservice_kakaotalk/rpc/rpc.py b/matrix_appservice_kakaotalk/rpc/rpc.py new file mode 100644 index 0000000..3820317 --- /dev/null +++ b/matrix_appservice_kakaotalk/rpc/rpc.py @@ -0,0 +1,203 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import Any, Callable, Awaitable, List + +import asyncio +import json +import logging + +from mautrix.types.primitive import JSON + +from ..config import Config +from .types import RPCError + +EventHandler = Callable[[dict[str, Any]], Awaitable[None]] + + +class RPCClient: + config: Config + loop: asyncio.AbstractEventLoop + log: logging.Logger = logging.getLogger("mau.rpc") + + _reader: asyncio.StreamReader | None + _writer: asyncio.StreamWriter | None + _req_id: int + _min_broadcast_id: int + _response_waiters: dict[int, asyncio.Future[JSON]] + _event_handlers: dict[str, List[EventHandler]] + _command_queue: asyncio.Queue + + def __init__(self, config: Config) -> None: + self.config = config + self.loop = asyncio.get_running_loop() + self._req_id = 0 + self._min_broadcast_id = 0 + self._event_handlers = {} + self._response_waiters = {} + self._writer = None + self._reader = None + self._command_queue = asyncio.Queue() + + async def connect(self) -> None: + if self._writer is not None: + return + + if self.config["rpc.connection.type"] == "unix": + while True: + try: + r, w = await asyncio.open_unix_connection(self.config["rpc.connection.path"]) + break + except: + self.log.warn(f'No unix socket available at {self.config["rpc.connection.path"]}, wait for it to exist...') + await asyncio.sleep(10) + elif self.config["rpc.connection.type"] == "tcp": + while True: + try: + r, w = await asyncio.open_connection(self.config["rpc.connection.host"], + self.config["rpc.connection.port"]) + break + except: + self.log.warn(f'No TCP connection open at {self.config["rpc.connection.host"]}:{self.config["rpc.connection.path"]}, wait for it to become available...') + await asyncio.sleep(10) + else: + raise RuntimeError("invalid rpc connection type") + self._reader = r + self._writer = w + self.loop.create_task(self._try_read_loop()) + self.loop.create_task(self._command_loop()) + await self.request("register", peer_id=self.config["appservice.address"]) + + async def disconnect(self) -> None: + assert self._writer is not None + self._writer.write_eof() + await self._writer.drain() + self._writer = None + self._reader = None + + @property + def _next_req_id(self) -> int: + self._req_id += 1 + return self._req_id + + def add_event_handler(self, method: str, handler: EventHandler) -> None: + self._event_handlers.setdefault(method, []).append(handler) + + def remove_event_handler(self, method: str, handler: EventHandler) -> None: + self._event_handlers.setdefault(method, []).remove(handler) + + async def _run_event_handler(self, req_id: int, command: str, req: dict[str, Any]) -> None: + if req_id > self._min_broadcast_id: + self.log.debug(f"Ignoring duplicate broadcast {req_id}") + return + self._min_broadcast_id = req_id + try: + handlers = self._event_handlers[command] + except KeyError: + self.log.warning("No handlers for %s", command) + else: + for handler in handlers: + try: + await handler(req) + except Exception: + self.log.exception("Exception in event handler") + + async def _handle_incoming_line(self, line: str) -> None: + try: + req = json.loads(line) + except json.JSONDecodeError: + self.log.debug(f"Got non-JSON data from server: {line}") + return + try: + req_id = req.pop("id") + command = req.pop("command") + is_sequential = req.pop("is_sequential", False) + except KeyError: + self.log.debug(f"Got invalid request from server: {line}") + return + if req_id < 0: + if not is_sequential: + self.loop.create_task(self._run_event_handler(req_id, command, req)) + else: + self._command_queue.put_nowait((req_id, command, req)) + return + try: + waiter = self._response_waiters[req_id] + except KeyError: + self.log.debug(f"Nobody waiting for response to {req_id}") + return + if command == "response": + waiter.set_result(req.get("response")) + elif command == "error": + waiter.set_exception(RPCError(req.get("error", line))) + else: + self.log.warning(f"Unexpected response command to {req_id}: {command} {req}") + + async def _command_loop(self) -> None: + while True: + req_id, command, req = await self._command_queue.get() + await self._run_event_handler(req_id, command, req) + self._command_queue.task_done() + + async def _try_read_loop(self) -> None: + try: + await self._read_loop() + except Exception: + self.log.exception("Fatal error in read loop") + + async def _read_loop(self) -> None: + while self._reader is not None and not self._reader.at_eof(): + line = b'' + while True: + try: + line += await self._reader.readuntil() + break + except asyncio.IncompleteReadError as e: + line += e.partial + break + except asyncio.LimitOverrunError as e: + self.log.warning(f"Buffer overrun: {e}") + line += await self._reader.read(self._reader._limit) + if not line: + continue + try: + line_str = line.decode("utf-8") + except UnicodeDecodeError: + self.log.exception("Got non-unicode request from server: %s", line) + continue + try: + await self._handle_incoming_line(line_str) + except Exception: + self.log.exception("Failed to handle incoming request %s", line_str) + self.log.debug("Reader disconnected") + self._reader = None + self._writer = None + + async def _raw_request(self, command: str, is_secret: bool = False, **data: JSON) -> asyncio.Future[JSON]: + req_id = self._next_req_id + future = self._response_waiters[req_id] = self.loop.create_future() + req = {"id": req_id, "command": command, **data} + self.log.debug("Request %d: %s %s", req_id, command, data if not is_secret else "") + assert self._writer is not None + self._writer.write(json.dumps(req).encode("utf-8")) + self._writer.write(b"\n") + await self._writer.drain() + return future + + async def request(self, command: str, **data: JSON) -> JSON: + future = await self._raw_request(command, **data) + return await future diff --git a/matrix_appservice_kakaotalk/rpc/types.py b/matrix_appservice_kakaotalk/rpc/types.py new file mode 100644 index 0000000..eecbd3b --- /dev/null +++ b/matrix_appservice_kakaotalk/rpc/types.py @@ -0,0 +1,20 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Types used by and for the Node RPC backend.""" + + +class RPCError(Exception): + pass diff --git a/matrix_appservice_kakaotalk/user.py b/matrix_appservice_kakaotalk/user.py new file mode 100644 index 0000000..b897844 --- /dev/null +++ b/matrix_appservice_kakaotalk/user.py @@ -0,0 +1,604 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast +import asyncio +import time + +from mautrix.bridge import BaseUser, async_getter_lock +from mautrix.types import ( + EventID, + MessageType, + RoomID, + TextMessageEventContent, + UserID, +) +from mautrix.util.bridge_state import BridgeState, BridgeStateEvent +from mautrix.util.opt_prometheus import Gauge, Summary, async_time +from mautrix.util.simple_lock import SimpleLock + +from . import portal as po, puppet as pu +from .config import Config +from .db import User as DBUser + +from .kt.client import Client +from .kt.client.errors import AuthenticationRequired, ResponseError +from .kt.types.api.struct.profile import ProfileStruct +from .kt.types.bson import Long +from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo +from .kt.types.channel.channel_info import NormalChannelData +from .kt.types.chat.chat import Chatlog +from .kt.types.client.client_session import ChannelLoginDataItem +from .kt.types.client.client_session import LoginResult +from .kt.types.oauth import OAuthCredential +from .kt.types.openlink.open_channel_info import OpenChannelData +from .kt.types.openlink.open_channel_info import OpenChannelInfo + +METRIC_SYNC_CHANNELS = Summary("bridge_sync_channels", "calls to _sync_channels") +METRIC_RESYNC = Summary("bridge_on_resync", "calls to on_resync") +METRIC_UNKNOWN_EVENT = Summary("bridge_on_unknown_event", "calls to on_unknown_event") +METRIC_MEMBERS_ADDED = Summary("bridge_on_members_added", "calls to on_members_added") +METRIC_MEMBER_REMOVED = Summary("bridge_on_member_removed", "calls to on_member_removed") +METRIC_TYPING = Summary("bridge_on_typing", "calls to on_typing") +METRIC_PRESENCE = Summary("bridge_on_presence", "calls to on_presence") +METRIC_REACTION = Summary("bridge_on_reaction", "calls to on_reaction") +METRIC_MESSAGE_UNSENT = Summary("bridge_on_unsent", "calls to on_unsent") +METRIC_MESSAGE_SEEN = Summary("bridge_on_message_seen", "calls to on_message_seen") +METRIC_TITLE_CHANGE = Summary("bridge_on_title_change", "calls to on_title_change") +METRIC_AVATAR_CHANGE = Summary("bridge_on_avatar_change", "calls to on_avatar_change") +METRIC_THREAD_CHANGE = Summary("bridge_on_thread_change", "calls to on_thread_change") +METRIC_MESSAGE = Summary("bridge_on_message", "calls to on_message") +METRIC_LOGGED_IN = Gauge("bridge_logged_in", "Users logged into the bridge") +METRIC_CONNECTED = Gauge("bridge_connected", "Bridge users connected to KakaoTalk") + +if TYPE_CHECKING: + from .__main__ import KakaoTalkBridge + +BridgeState.human_readable_errors.update( + { + "kt-reconnection-error": "Failed to reconnect to KakaoTalk", + "kt-connection-error": "KakaoTalk disconnected unexpectedly", + "kt-auth-error": "Authentication error from KakaoTalk: {message}", + "kt-disconnected": None, + "logged-out": "You're not logged into KakaoTalk", + } +) + + +class User(DBUser, BaseUser): + #temp_disconnect_notices: bool = True + shutdown: bool = False + config: Config + + by_mxid: dict[UserID, User] = {} + by_ktid: dict[Long, User] = {} + + client: Client | None + + _notice_room_lock: asyncio.Lock + _notice_send_lock: asyncio.Lock + command_status: dict | None + is_admin: bool + permission_level: str + _is_logged_in: bool | None + #_is_connected: bool | None + #_connection_time: float + _prev_reconnect_fail_refresh: float + _db_instance: DBUser | None + _sync_lock: SimpleLock + _is_refreshing: bool + _logged_in_info: ProfileStruct | None + _logged_in_info_time: float + + def __init__( + self, + mxid: UserID, + ktid: Long | None = None, + uuid: str | None = None, + access_token: str | None = None, + refresh_token: str | None = None, + notice_room: RoomID | None = None, + ) -> None: + super().__init__( + mxid=mxid, + ktid=ktid, + uuid=uuid, + access_token=access_token, + refresh_token=refresh_token, + notice_room=notice_room + ) + BaseUser.__init__(self) + self.notice_room = notice_room + self._notice_room_lock = asyncio.Lock() + self._notice_send_lock = asyncio.Lock() + self.command_status = None + ( + self.relay_whitelisted, + self.is_whitelisted, + self.is_admin, + self.permission_level, + ) = self.config.get_permissions(mxid) + self._is_logged_in = None + #self._is_connected = None + #self._connection_time = time.monotonic() + self._prev_reconnect_fail_refresh = time.monotonic() + self._sync_lock = SimpleLock( + "Waiting for thread sync to finish before handling %s", log=self.log + ) + self._is_refreshing = False + self._logged_in_info = None + self._logged_in_info_time = 0 + + self.client = None + + @classmethod + def init_cls(cls, bridge: KakaoTalkBridge) -> AsyncIterable[Awaitable[bool]]: + cls.bridge = bridge + cls.config = bridge.config + cls.az = bridge.az + cls.loop = bridge.loop + #cls.temp_disconnect_notices = bridge.config["bridge.temporary_disconnect_notices"] + return (user.reload_session(is_startup=True) async for user in cls.all_logged_in()) + + """ + @property + def is_connected(self) -> bool | None: + return self._is_connected + + @is_connected.setter + def is_connected(self, val: bool | None) -> None: + if self._is_connected != val: + self._is_connected = val + self._connection_time = time.monotonic() + + @property + def connection_time(self) -> float: + return self._connection_time + """ + + @property + def has_state(self) -> bool: + return self.uuid and self.ktid and self.access_token and self.refresh_token + + # region Database getters + + def _add_to_cache(self) -> None: + self.by_mxid[self.mxid] = self + if self.ktid: + self.by_ktid[self.ktid] = self + + @classmethod + async def all_logged_in(cls) -> AsyncGenerator["User", None]: + users = await super().all_logged_in() + user: cls + for user in users: + try: + yield cls.by_mxid[user.mxid] + except KeyError: + user._add_to_cache() + yield user + + @classmethod + @async_getter_lock + async def get_by_mxid(cls, mxid: UserID, *, create: bool = True) -> User | None: + if pu.Puppet.get_id_from_mxid(mxid) or mxid == cls.az.bot_mxid: + return None + try: + return cls.by_mxid[mxid] + except KeyError: + pass + + user = cast(cls, await super().get_by_mxid(mxid)) + if user is not None: + user._add_to_cache() + return user + + if create: + cls.log.debug(f"Creating user instance for {mxid}") + user = cls(mxid) + await user.insert() + user._add_to_cache() + return user + + return None + + @classmethod + @async_getter_lock + async def get_by_ktid(cls, ktid: Long) -> User | None: + try: + return cls.by_ktid[ktid] + except KeyError: + pass + + user = cast(cls, await super().get_by_ktid(ktid)) + if user is not None: + user._add_to_cache() + return user + + return None + + async def get_uuid(self, force: bool = False) -> str: + if self.uuid is None or force: + self.uuid = await Client.generate_uuid(await self.get_all_uuids()) + await self.save() + return self.uuid + + # endregion + + @property + def oauth_credential(self) -> OAuthCredential: + return OAuthCredential( + self.ktid, + self.uuid, + self.access_token, + self.refresh_token, + ) + + @oauth_credential.setter + def oauth_credential(self, oauth_credential: OAuthCredential) -> None: + self.ktid = oauth_credential.userId + self.access_token = oauth_credential.accessToken + self.refresh_token = oauth_credential.refreshToken + if self.uuid != oauth_credential.deviceUUID: + self.log.warn(f"UUID mismatch: expected {self.uuid}, got {oauth_credential.deviceUUID}") + self.uuid = oauth_credential.deviceUUID + + async def get_own_info(self) -> ProfileStruct: + if not self._logged_in_info or self._logged_in_info_time + 60 * 60 < time.monotonic(): + self._logged_in_info = await self.client.fetch_logged_in_user() + self._logged_in_info_time = time.monotonic() + return self._logged_in_info + + async def _load_session(self, is_startup: bool) -> bool: + if self._is_logged_in and not is_startup: + return True + elif not self.has_state: + # If we have a user in the DB with no state, we can assume + # KT logged us out and the bridge has restarted + await self.push_bridge_state( + BridgeStateEvent.BAD_CREDENTIALS, + error="logged-out", + ) + return False + client = Client(self, log=self.log.getChild("ktclient")) + user_info = await self.fetch_logged_in_user(client) + if user_info: + self.log.info("Loaded session successfully") + self.client = client + self._logged_in_info = user_info + self._logged_in_info_time = time.monotonic() + self._track_metric(METRIC_LOGGED_IN, True) + self._is_logged_in = True + #self.is_connected = None + self.stop_listen() + asyncio.create_task(self.post_login(is_startup=is_startup)) + return True + return False + + async def _send_reset_notice(self, e: AuthenticationRequired, edit: EventID | None = None) -> None: + await self.send_bridge_notice( + "Got authentication error from KakaoTalk:\n\n" + f"> {e.message}\n\n" + "If you changed your KakaoTalk password, this " + "is normal and you just need to log in again.", + edit=edit, + important=True, + state_event=BridgeStateEvent.BAD_CREDENTIALS, + error_code="kt-auth-error", + error_message=str(e), + ) + await self.logout(remove_ktid=False) + + async def fetch_logged_in_user( + self, client: Client | None = None, action: str = "restore session" + ) -> ProfileStruct: + if not client: + client = self.client + # TODO Retry network connection failures here, or in the client? + try: + return await client.fetch_logged_in_user() + # NOTE Not catching InvalidAccessToken here, as client handles it & tries to refresh the token + except AuthenticationRequired as e: + if action != "restore session": + await self._send_reset_notice(e) + raise + except Exception: + self.log.exception(f"Failed to {action}") + raise + + async def is_logged_in(self, _override: bool = False) -> bool: + if not self.has_state or not self.client: + return False + if self._is_logged_in is None or _override: + try: + self._is_logged_in = bool(await self.get_own_info()) + except Exception: + self.log.exception("Exception checking login status") + self._is_logged_in = False + return self._is_logged_in + + async def reload_session( + self, event_id: EventID | None = None, retries: int = 3, is_startup: bool = False + ) -> None: + try: + await self._load_session(is_startup=is_startup) + except AuthenticationRequired as e: + await self._send_reset_notice(e, edit=event_id) + # TODO Throw a ResponseError on network failures + except ResponseError as e: + will_retry = retries > 0 + retry = "Retrying in 1 minute" if will_retry else "Not retrying" + notice = f"Failed to connect to KakaoTalk: unknown response error {e}. {retry}" + if will_retry: + await self.send_bridge_notice( + notice, + edit=event_id, + state_event=BridgeStateEvent.TRANSIENT_DISCONNECT, + ) + await asyncio.sleep(60) + await self.reload_session(event_id, retries - 1) + else: + await self.send_bridge_notice( + notice, + edit=event_id, + important=True, + state_event=BridgeStateEvent.UNKNOWN_ERROR, + error_code="kt-reconnection-error", + ) + except Exception: + await self.send_bridge_notice( + "Failed to connect to KakaoTalk: unknown error (see logs for more details)", + edit=event_id, + state_event=BridgeStateEvent.UNKNOWN_ERROR, + error_code="kt-reconnection-error", + ) + finally: + self._is_refreshing = False + + async def logout(self, remove_ktid: bool = True) -> bool: + # TODO Remove tokens too? + ok = True + self.stop_listen() + if self.has_state: + # TODO Log out of KakaoTalk if an API exists for it + pass + if remove_ktid: + await self.push_bridge_state(BridgeStateEvent.LOGGED_OUT) + self._track_metric(METRIC_LOGGED_IN, False) + self._is_logged_in = False + #self.is_connected = None + if self.client: + await self.client.stop() + self.client = None + + if remove_ktid: + if self.ktid: + #await UserPortal.delete_all(self.ktid) + del self.by_ktid[self.ktid] + self.ktid = None + self.uuid = None + + await self.save() + return ok + + async def post_login(self, is_startup: bool) -> None: + self.log.info("Running post-login actions") + self._add_to_cache() + + try: + puppet = await pu.Puppet.get_by_ktid(self.ktid) + + if puppet.custom_mxid != self.mxid and puppet.can_auto_login(self.mxid): + self.log.info(f"Automatically enabling custom puppet") + await puppet.switch_mxid(access_token="auto", mxid=self.mxid) + except Exception: + self.log.exception("Failed to automatically enable custom puppet") + + assert self.client + try: + login_result = await self.client.start() + await self._sync_channels(login_result, is_startup) + # TODO connect listeners, even if channel sync fails (except if it's an auth failure) + except AuthenticationRequired as e: + await self.send_bridge_notice( + f"Got authentication error from KakaoTalk:\n\n> {e.message}\n\n", + important=True, + state_event=BridgeStateEvent.BAD_CREDENTIALS, + error_code="kt-auth-error", + error_message=str(e), + ) + await self.logout(remove_ktid=False) + except Exception as e: + self.log.exception("Failed to start client") + await self.push_bridge_state(BridgeStateEvent.UNKNOWN_ERROR, message=str(e)) + + async def get_direct_chats(self) -> dict[UserID, list[RoomID]]: + return { + pu.Puppet.get_mxid_from_id(portal.ktid): [portal.mxid] + async for portal in po.Portal.get_all_by_receiver(self.ktid) + if portal.mxid + } + + @async_time(METRIC_SYNC_CHANNELS) + async def _sync_channels(self, login_result: LoginResult, is_startup: bool) -> None: + # TODO Look for a way to sync all channels without (re-)logging in + sync_count = self.config["bridge.initial_chat_sync"] + if sync_count <= 0 or not self.config["bridge.sync_on_startup"] and is_startup: + self.log.debug(f"Skipping channel syncing{' on startup' if sync_count > 0 else ''}") + return + if not login_result.channelList: + self.log.debug("No channels to sync") + return + # TODO What about removed channels? Don't early-return then + + sync_count = min(sync_count, len(login_result.channelList)) + await self.push_bridge_state(BridgeStateEvent.BACKFILLING) + self.log.debug(f"Syncing {sync_count} of {login_result.channelList} channels...") + for channel_item in login_result.channelList[:sync_count]: + # TODO try-except here, above, below? + await self._sync_channel(channel_item) + + async def _sync_channel(self, channel_item: ChannelLoginDataItem) -> None: + channel_data = channel_item.channel + self.log.debug(f"Syncing channel {channel_data.channelId} (last updated at {channel_item.lastUpdate})") + channel_info = channel_data.info + if isinstance(channel_data, NormalChannelData): + channel_data: NormalChannelData + channel_info: NormalChannelInfo + self.log.debug(f"Join time: {channel_info.joinTime}") + elif isinstance(channel_data, OpenChannelData): + channel_data: OpenChannelData + self.log.debug(f"channel_data link ID: {channel_data.linkId}") + channel_info: OpenChannelInfo + self.log.debug(f"channel_info link ID: {channel_info.linkId}") + self.log.debug(f"openToken: {channel_info.openToken}") + self.log.debug(f"Is direct channel: {channel_info.directChannel}") + self.log.debug(f"Has OpenLink: {channel_info.openLink is not None}") + else: + self.log.error(f"Unexpected channel type: {type(channel_data)}") + + channel_info: ChannelInfo + self.log.debug(f"channel_info channel ID: {channel_info.channelId}") + self.log.debug(f"Channel data/info IDs match: {channel_data.channelId == channel_info.channelId}") + self.log.debug(f"Channel type: {channel_info.type}") + self.log.debug(f"Active user count: {channel_info.activeUserCount}") + self.log.debug(f"New chat count: {channel_info.newChatCount}") + self.log.debug(f"New chat count invalid: {channel_info.newChatCountInvalid}") + self.log.debug(f"Last chat log ID: {channel_info.lastChatLogId}") + self.log.debug(f"Last seen log ID: {channel_info.lastSeenLogId}") + self.log.debug(f"Has last chat log: {channel_info.lastChatLog is not None}") + self.log.debug(f"metaMap: {channel_info.metaMap}") + self.log.debug(f"User count: {len(channel_info.displayUserList)}") + self.log.debug(f"Has push alert: {channel_info.pushAlert}") + for display_user_info in channel_info.displayUserList: + self.log.debug(f"Member: {display_user_info.nickname} - {display_user_info.profileURL} - {display_user_info.userId}") + + portal = await po.Portal.get_by_ktid( + channel_info.channelId, + kt_receiver=self.ktid, + kt_type=channel_info.type + ) + portal_info = await self.client.get_portal_channel_info(channel_info) + if not portal.mxid: + await portal.create_matrix_room(self, portal_info) + else: + await portal.update_matrix_room(self, portal_info) + await portal.backfill(self, is_initial=False, channel=channel_info) + + async def get_notice_room(self) -> RoomID: + if not self.notice_room: + async with self._notice_room_lock: + # If someone already created the room while this call was waiting, + # don't make a new room + if self.notice_room: + return self.notice_room + creation_content = {} + if not self.config["bridge.federate_rooms"]: + creation_content["m.federate"] = False + self.notice_room = await self.az.intent.create_room( + is_direct=True, + invitees=[self.mxid], + topic="KakaoTalk bridge notices", + creation_content=creation_content, + ) + await self.save() + return self.notice_room + + async def send_bridge_notice( + self, + text: str, + edit: EventID | None = None, + state_event: BridgeStateEvent | None = None, + important: bool = False, + error_code: str | None = None, + error_message: str | None = None, + ) -> EventID | None: + if state_event: + await self.push_bridge_state( + state_event, + error=error_code, + message=error_message if error_code else text, + ) + if self.config["bridge.disable_bridge_notices"]: + return None + event_id = None + try: + self.log.debug("Sending bridge notice: %s", text) + content = TextMessageEventContent( + body=text, + msgtype=(MessageType.TEXT if important else MessageType.NOTICE), + ) + if edit: + content.set_edit(edit) + # This is locked to prevent notices going out in the wrong order + async with self._notice_send_lock: + event_id = await self.az.intent.send_message(await self.get_notice_room(), content) + except Exception: + self.log.warning("Failed to send bridge notice", exc_info=True) + return edit or event_id + + async def fill_bridge_state(self, state: BridgeState) -> None: + await super().fill_bridge_state(state) + if self.ktid: + state.remote_id = str(self.ktid) + puppet = await pu.Puppet.get_by_ktid(self.ktid) + state.remote_name = puppet.name + + async def get_bridge_states(self) -> list[BridgeState]: + self.log.info("TODO: get_bridge_states") + return [] + """ + if not self.state: + return [] + state = BridgeState(state_event=BridgeStateEvent.UNKNOWN_ERROR) + if self.is_connected: + state.state_event = BridgeStateEvent.CONNECTED + elif self._is_refreshing or self.mqtt: + state.state_event = BridgeStateEvent.TRANSIENT_DISCONNECT + return [state] + """ + + async def get_puppet(self) -> pu.Puppet | None: + if not self.ktid: + return None + return await pu.Puppet.get_by_ktid(self.ktid) + + # region KakaoTalk event handling + + def stop_listen(self) -> None: + self.log.info("TODO: stop_listen") + + async def on_logged_in(self, oauth_credential: OAuthCredential) -> None: + self.log.debug(f"Successfully logged in as {oauth_credential.uuid}") + self.oauth_credential = oauth_credential + self.client = Client(self, log=self.log.getChild("ktclient")) + await self.save() + try: + self._logged_in_info = await self.client.fetch_logged_in_user(post_login=True) + self._logged_in_info_time = time.monotonic() + except Exception: + self.log.exception("Failed to fetch post-login info") + self.stop_listen() + asyncio.create_task(self.post_login(is_startup=True)) + + @async_time(METRIC_MESSAGE) + async def on_message(self, evt: Chatlog, channel_id: Long) -> None: + self.log.info("TODO: on_message") + + # TODO Many more handlers + + # endregion diff --git a/matrix_appservice_kakaotalk/util/__init__.py b/matrix_appservice_kakaotalk/util/__init__.py new file mode 100644 index 0000000..85565a7 --- /dev/null +++ b/matrix_appservice_kakaotalk/util/__init__.py @@ -0,0 +1 @@ +from .color_log import ColorFormatter diff --git a/matrix_appservice_kakaotalk/util/color_log.py b/matrix_appservice_kakaotalk/util/color_log.py new file mode 100644 index 0000000..70543cd --- /dev/null +++ b/matrix_appservice_kakaotalk/util/color_log.py @@ -0,0 +1,25 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from mautrix.util.logging.color import PREFIX, RESET, ColorFormatter as BaseColorFormatter + +FBCHAT_COLOR = PREFIX + "35;1m" # magenta + + +class ColorFormatter(BaseColorFormatter): + def _color_name(self, module: str) -> str: + if module.startswith("maufbapi"): + return FBCHAT_COLOR + module + RESET + return super()._color_name(module) diff --git a/matrix_appservice_kakaotalk/util/interval.py b/matrix_appservice_kakaotalk/util/interval.py new file mode 100644 index 0000000..d43a98e --- /dev/null +++ b/matrix_appservice_kakaotalk/util/interval.py @@ -0,0 +1,7 @@ +import random + + +def get_interval(value): + if isinstance(value, list): + return random.randint(*value) + return value diff --git a/matrix_appservice_kakaotalk/version.py b/matrix_appservice_kakaotalk/version.py new file mode 100644 index 0000000..0b22680 --- /dev/null +++ b/matrix_appservice_kakaotalk/version.py @@ -0,0 +1 @@ +from .get_version import git_revision, git_tag, linkified_version, version diff --git a/matrix_appservice_kakaotalk/web/__init__.py b/matrix_appservice_kakaotalk/web/__init__.py new file mode 100644 index 0000000..57c6f1f --- /dev/null +++ b/matrix_appservice_kakaotalk/web/__init__.py @@ -0,0 +1 @@ +from .public import PublicBridgeWebsite diff --git a/matrix_appservice_kakaotalk/web/public.py b/matrix_appservice_kakaotalk/web/public.py new file mode 100644 index 0000000..a910dff --- /dev/null +++ b/matrix_appservice_kakaotalk/web/public.py @@ -0,0 +1,380 @@ +# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +import asyncio +import json +import logging +import random +import string +import time + +from aiohttp import web +import pkg_resources + +from mautrix.types import UserID +from mautrix.util.signed_token import verify_token + +from .. import puppet as pu, user as u + + +class InvalidTokenError(Exception): + pass + + +class PublicBridgeWebsite: + log: logging.Logger = logging.getLogger("mau.web.public") + app: web.Application + secret_key: str + shared_secret: str + ready_wait: asyncio.Future | None + + def __init__(self, shared_secret: str, loop: asyncio.AbstractEventLoop) -> None: + self.app = web.Application() + self.ready_wait = loop.create_future() + self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64)) + self.shared_secret = shared_secret + for path in ( + "whoami", + "login", + "login/prepare", + "login/2fa", + "login/check_approved", + "login/approved", + "logout", + "disconnect", + "reconnect", + "refresh", + ): + self.app.router.add_options(f"/api/{path}", self.login_options) + self.app.router.add_get("/api/whoami", self.status) + self.app.router.add_post("/api/login/prepare", self.login_prepare) + self.app.router.add_post("/api/login", self.login) + self.app.router.add_post("/api/login/2fa", self.login_2fa) + self.app.router.add_get("/api/login/check_approved", self.login_check_approved) + self.app.router.add_post("/api/login/approved", self.login_approved) + self.app.router.add_post("/api/logout", self.logout) + self.app.router.add_post("/api/disconnect", self.disconnect) + self.app.router.add_post("/api/reconnect", self.reconnect) + self.app.router.add_post("/api/refresh", self.refresh) + self.app.router.add_static( + "/", pkg_resources.resource_filename("matrix_appservice_kakaotalk.web", "static/") + ) + + def verify_token(self, token: str) -> UserID: + token = verify_token(self.secret_key, token) + if token: + if token.get("expiry", 0) < int(time.time()): + raise InvalidTokenError("Access token has expired") + return UserID(token.get("mxid")) + raise InvalidTokenError("Access token is invalid") + + @property + def _acao_headers(self) -> dict[str, str]: + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + } + + @property + def _headers(self) -> dict[str, str]: + return { + **self._acao_headers, + "Content-Type": "application/json", + } + + async def login_options(self, _: web.Request) -> web.Response: + return web.Response(status=200, headers=self._headers) + + async def check_token(self, request: web.Request) -> u.User | None: + if self.ready_wait: + await self.ready_wait + self.ready_wait = None + try: + token = request.headers["Authorization"] + token = token[len("Bearer ") :] + except KeyError: + raise web.HTTPBadRequest( + text='{"error": "Missing Authorization header"}', headers=self._headers + ) + except IndexError: + raise web.HTTPBadRequest( + text='{"error": "Malformed Authorization header"}', + headers=self._headers, + ) + if self.shared_secret and token == self.shared_secret: + try: + user_id = request.query["user_id"] + except KeyError: + raise web.HTTPBadRequest( + text='{"error": "Missing user_id query param"}', + headers=self._headers, + ) + else: + try: + user_id = self.verify_token(token) + except InvalidTokenError as e: + raise web.HTTPForbidden( + text=json.dumps( + {"error": f"{e}, please request a new one from the bridge bot"} + ), + headers=self._headers, + ) + + user = await u.User.get_by_mxid(user_id) + return user + + async def status(self, request: web.Request) -> web.Response: + user = await self.check_token(request) + data = { + "permissions": user.permission_level, + "mxid": user.mxid, + "kakaotalk": None, + } + if user.client: + try: + info = await user.get_own_info() + except Exception: + # TODO do something? + self.log.warning( + "Exception while getting self from status endpoint", exc_info=True + ) + else: + data["kakaotalk"] = info.serialize() + data["kakaotalk"]["connected"] = user.is_connected + data["kakaotalk"][ + "device_displayname" + ] = f"{user.state.device.manufacturer} {user.state.device.name}" + return web.json_response(data, headers=self._acao_headers) + + async def login_prepare(self, request: web.Request) -> web.Response: + self.log.info("TODO") + """ + user = await self.check_token(request) + state = AndroidState() + state.generate(user.mxid) + api = AndroidAPI(state, log=user.log.getChild("login-api")) + user.command_status = { + "action": "Login", + "state": state, + "api": api, + } + try: + await api.mobile_config_sessionless() + except Exception as e: + self.log.exception( + f"Failed to get mobile_config_sessionless to prepare login for {user.mxid}" + ) + return web.json_response({"error": str(e)}, headers=self._acao_headers, status=500) + return web.json_response( + { + "status": "login", + "password_encryption_key_id": state.session.password_encryption_key_id, + "password_encryption_pubkey": state.session.password_encryption_pubkey, + }, + headers=self._acao_headers, + ) + """ + + async def login(self, request: web.Request) -> web.Response: + self.log.info("TODO") + """ + user = await self.check_token(request) + + try: + data = await request.json() + except json.JSONDecodeError: + raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers) + + try: + email = data["email"] + except KeyError: + raise web.HTTPBadRequest(text='{"error": "Missing email"}', headers=self._headers) + try: + password = data["password"] + encrypted_password = None + except KeyError: + try: + encrypted_password = data["encrypted_password"] + password = None + except KeyError: + raise web.HTTPBadRequest( + text='{"error": "Missing password"}', headers=self._headers + ) + + if encrypted_password: + if not user.command_status or user.command_status["action"] != "Login": + raise web.HTTPBadRequest( + text='{"error": "No login in progress"}', headers=self._headers + ) + state: AndroidState = user.command_status["state"] + api: AndroidAPI = user.command_status["api"] + else: + state = AndroidState() + state.generate(user.mxid) + api = AndroidAPI(state, log=user.log.getChild("login-api")) + await api.mobile_config_sessionless() + + try: + self.log.debug(f"Logging in as {email} for {user.mxid}") + resp = await api.login(email, password=password, encrypted_password=encrypted_password) + self.log.debug(f"Got successful login response with UID {resp.uid} for {user.mxid}") + await user.on_logged_in(state) + return web.json_response({"status": "logged-in"}, headers=self._acao_headers) + except TwoFactorRequired as e: + self.log.debug( + f"Got 2-factor auth required login error with UID {e.uid} for {user.mxid}" + ) + user.command_status = { + "action": "Login", + "state": state, + "api": api, + } + return web.json_response( + { + "status": "two-factor", + "error": e.data, + }, + headers=self._acao_headers, + ) + except OAuthException as e: + self.log.debug(f"Got OAuthException {e} for {user.mxid}") + return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401) + """ + + async def login_2fa(self, request: web.Request) -> web.Response: + self.log.info("TODO") + """ + user = await self.check_token(request) + + if not user.command_status or user.command_status["action"] != "Login": + raise web.HTTPBadRequest( + text='{"error": "No login in progress"}', headers=self._headers + ) + + try: + data = await request.json() + except json.JSONDecodeError: + raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers) + + try: + email = data["email"] + code = data["code"] + except KeyError as e: + raise web.HTTPBadRequest( + text=json.dumps({"error": f"Missing key {e}"}), headers=self._headers + ) + + state: AndroidState = user.command_status["state"] + api: AndroidAPI = user.command_status["api"] + try: + self.log.debug(f"Sending 2-factor auth code for {user.mxid}") + resp = await api.login_2fa(email, code) + self.log.debug( + f"Got successful login response with UID {resp.uid} for {user.mxid}" + " after 2fa login" + ) + await user.on_logged_in(state) + return web.json_response({"status": "logged-in"}, headers=self._acao_headers) + except IncorrectPassword: + self.log.debug(f"Got incorrect 2fa code error for {user.mxid}") + return web.json_response( + { + "error": "Incorrect two-factor authentication code", + "status": "incorrect-code", + }, + headers=self._acao_headers, + status=401, + ) + except OAuthException as e: + self.log.debug(f"Got OAuthException {e} for {user.mxid} in 2fa stage") + return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401) + """ + + async def login_approved(self, request: web.Request) -> web.Response: + self.log.info("TODO") + """ + user = await self.check_token(request) + + if not user.command_status or user.command_status["action"] != "Login": + raise web.HTTPBadRequest( + text='{"error": "No login in progress"}', headers=self._headers + ) + + state: AndroidState = user.command_status["state"] + api: AndroidAPI = user.command_status["api"] + try: + self.log.debug(f"Trying to log in after approval for {user.mxid}") + resp = await api.login_approved() + self.log.debug( + f"Got successful login response with UID {resp.uid} for {user.mxid}" + " after approval login" + ) + await user.on_logged_in(state) + return web.json_response({"status": "logged-in"}, headers=self._acao_headers) + except OAuthException as e: + self.log.debug(f"Got OAuthException {e} for {user.mxid} in checkpoint login stage") + return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401) + """ + + async def login_check_approved(self, request: web.Request) -> web.Response: + self.log.info("TODO") + """ + user = await self.check_token(request) + + if not user.command_status or user.command_status["action"] != "Login": + raise web.HTTPBadRequest( + text='{"error": "No login in progress"}', headers=self._headers + ) + + api: AndroidAPI = user.command_status["api"] + approved = await api.check_approved_machine() + return web.json_response({"approved": approved}, headers=self._acao_headers) + """ + + async def logout(self, request: web.Request) -> web.Response: + user = await self.check_token(request) + + puppet = await pu.Puppet.get_by_ktid(user.ktid) + await user.logout() + if puppet.is_real_user: + await puppet.switch_mxid(None, None) + return web.json_response({}, headers=self._acao_headers) + + async def disconnect(self, request: web.Request) -> web.Response: + user = await self.check_token(request) + if not user.is_connected: + raise web.HTTPBadRequest( + text='{"error": "User is not connected"}', headers=self._headers + ) + user.mqtt.disconnect() + await user.listen_task + return web.json_response({}, headers=self._acao_headers) + + async def reconnect(self, request: web.Request) -> web.Response: + user = await self.check_token(request) + if user.is_connected: + raise web.HTTPConflict( + text='{"error": "User is already connected"}', headers=self._headers + ) + user.start_listen() + return web.json_response({}, headers=self._acao_headers) + + async def refresh(self, request: web.Request) -> web.Response: + user = await self.check_token(request) + await user.refresh() + return web.json_response({}, headers=self._acao_headers) diff --git a/matrix_appservice_kakaotalk/web/static/lib/asn1hex-1.1.min.js b/matrix_appservice_kakaotalk/web/static/lib/asn1hex-1.1.min.js new file mode 100644 index 0000000..ced8b9e --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/lib/asn1hex-1.1.min.js @@ -0,0 +1,7 @@ +// asn1hex-1.2.8.js (c) 2012-2020 Kenji Urushima | kjur.github.com/jsrsasign/license + +import BigInteger from "./jsbn.min.js" + +var ASN1HEX=new function(){};ASN1HEX.getLblen=function(t,n){if("8"!=t.substr(n+2,1))return 1;var r=parseInt(t.substr(n+3,1));return 0==r?-1:0=e)break}return g},ASN1HEX.getNthChildIdx=function(t,n,r){return ASN1HEX.getChildIdx(t,n)[r]},ASN1HEX.getIdxbyList=function(t,n,r,e){var i,u,g=ASN1HEX;return 0==r.length?void 0!==e&&t.substr(n,2)!==e?-1:n:(i=r.shift())>=(u=g.getChildIdx(t,n)).length?-1:g.getIdxbyList(t,u[i],r,e)},ASN1HEX.getIdxbyListEx=function(t,n,r,e){var i,u,g=ASN1HEX;if(0==r.length)return void 0!==e&&t.substr(n,2)!==e?-1:n;i=r.shift(),u=g.getChildIdx(t,n);for(var s=0,o=0;o=t.length?null:i.getTLV(t,u)},ASN1HEX.getTLVbyListEx=function(t,n,r,e){var i=ASN1HEX,u=i.getIdxbyListEx(t,n,r,e);return-1==u?null:i.getTLV(t,u)},ASN1HEX.getVbyList=function(t,n,r,e,i){var u,g,s=ASN1HEX;return-1==(u=s.getIdxbyList(t,n,r,e))?null:u>=t.length?null:(g=s.getV(t,u),!0===i&&(g=g.substr(2)),g)},ASN1HEX.getVbyListEx=function(t,n,r,e,i){var u,g,s=ASN1HEX;return-1==(u=s.getIdxbyListEx(t,n,r,e))?null:(g=s.getV(t,u),"03"==t.substr(u,2)&&!1!==i&&(g=g.substr(2)),g)},ASN1HEX.getInt=function(t,n,r){null==r&&(r=-1);try{var e=t.substr(n,2);if("02"!=e&&"03"!=e)return r;var i=ASN1HEX.getV(t,n);return"02"==e?parseInt(i,16):bitstrtoint(i)}catch(t){return r}},ASN1HEX.getOID=function(t,n,r){null==r&&(r=null);try{if("06"!=t.substr(n,2))return r;var e=ASN1HEX.getV(t,n);return hextooid(e)}catch(t){return r}},ASN1HEX.getOIDName=function(t,n,r){null==r&&(r=null);try{var e=ASN1HEX.getOID(t,n,r);if(e==r)return r;var i=KJUR.asn1.x509.OID.oid2name(e);return""==i?e:i}catch(t){return r}},ASN1HEX.getString=function(t,n,r){null==r&&(r=null);try{var e=ASN1HEX.getV(t,n);return hextorstr(e)}catch(t){return r}},ASN1HEX.hextooidstr=function(t){var n=function(t,n){return t.length>=n?t:new Array(n-t.length+1).join("0")+t},r=[],e=t.substr(0,2),i=parseInt(e,16);r[0]=new String(Math.floor(i/40)),r[1]=new String(i%40);for(var u=t.substr(2),g=[],s=0;s0&&(l=l+"."+o.join(".")),l},ASN1HEX.dump=function(t,n,r,e){var i=ASN1HEX,u=i.getV,g=i.dump,s=i.getChildIdx,o=t;t instanceof KJUR.asn1.ASN1Object&&(o=t.getEncodedHex());var a=function(t,n){return t.length<=2*n?t:t.substr(0,n)+"..(total "+t.length/2+"bytes).."+t.substr(t.length-n,n)};void 0===n&&(n={ommit_long_octet:32}),void 0===r&&(r=0),void 0===e&&(e="");var l,f=n.ommit_long_octet;if("01"==(l=o.substr(r,2)))return"00"==(E=u(o,r))?e+"BOOLEAN FALSE\n":e+"BOOLEAN TRUE\n";if("02"==l)return e+"INTEGER "+a(E=u(o,r),f)+"\n";if("03"==l){var E=u(o,r);if(i.isASN1HEX(E.substr(2))){var h=e+"BITSTRING, encapsulates\n";return h+=g(E.substr(2),n,0,e+" ")}return e+"BITSTRING "+a(E,f)+"\n"}if("04"==l){E=u(o,r);if(i.isASN1HEX(E)){h=e+"OCTETSTRING, encapsulates\n";return h+=g(E,n,0,e+" ")}return e+"OCTETSTRING "+a(E,f)+"\n"}if("05"==l)return e+"NULL\n";if("06"==l){var S=u(o,r),N=KJUR.asn1.ASN1Util.oidHexToInt(S),A=KJUR.asn1.x509.OID.oid2name(N),b=N.replace(/\./g," ");return""!=A?e+"ObjectIdentifier "+A+" ("+b+")\n":e+"ObjectIdentifier ("+b+")\n"}if("0a"==l)return e+"ENUMERATED "+parseInt(u(o,r))+"\n";if("0c"==l)return e+"UTF8String '"+hextoutf8(u(o,r))+"'\n";if("13"==l)return e+"PrintableString '"+hextoutf8(u(o,r))+"'\n";if("14"==l)return e+"TeletexString '"+hextoutf8(u(o,r))+"'\n";if("16"==l)return e+"IA5String '"+hextoutf8(u(o,r))+"'\n";if("17"==l)return e+"UTCTime "+hextoutf8(u(o,r))+"\n";if("18"==l)return e+"GeneralizedTime "+hextoutf8(u(o,r))+"\n";if("1a"==l)return e+"VisualString '"+hextoutf8(u(o,r))+"'\n";if("1e"==l)return e+"BMPString '"+hextoutf8(u(o,r))+"'\n";if("30"==l){if("3000"==o.substr(r,4))return e+"SEQUENCE {}\n";h=e+"SEQUENCE\n";var c=n;if((2==(v=s(o,r)).length||3==v.length)&&"06"==o.substr(v[0],2)&&"04"==o.substr(v[v.length-1],2)){A=i.oidname(u(o,v[0]));var x=JSON.parse(JSON.stringify(n));x.x509ExtName=A,c=x}for(var H=0;H31)&&(128==(192&r)&&(31&r)==e))}catch(t){return!1}},ASN1HEX.isASN1HEX=function(t){var n=ASN1HEX;if(t.length%2==1)return!1;var r=n.getVblen(t,0),e=t.substr(0,2),i=n.getL(t,0);return t.length-e.length-i.length==2*r},ASN1HEX.checkStrictDER=function(t,n,r,e,i){var u=ASN1HEX;if(void 0===r){if("string"!=typeof t)throw new Error("not hex string");if(t=t.toLowerCase(),!KJUR.lang.String.isHex(t))throw new Error("not hex string");r=t.length,i=(e=t.length/2)<128?1:Math.ceil(e.toString(16))+1}if(u.getL(t,n).length>2*i)throw new Error("L of TLV too long: idx="+n);var g=u.getVblen(t,n);if(g>e)throw new Error("value of L too long than hex: idx="+n);var s=u.getTLV(t,n),o=s.length-2-u.getL(t,n).length;if(o!==2*g)throw new Error("V string length and L's value not the same:"+o+"/"+2*g);if(0===n&&t.length!=s.length)throw new Error("total length and TLV length unmatch:"+t.length+"!="+s.length);var a=t.substr(n,2);if("02"===a){var l=u.getVidx(t,n);if("00"==t.substr(l,2)&&t.charCodeAt(l+2)<56)throw new Error("not least zeros for DER INTEGER")}if(32&parseInt(a,16)){for(var f=u.getVblen(t,n),E=0,h=u.getChildIdx(t,n),S=0;S=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e=""},a=0;a"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0])}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]} diff --git a/matrix_appservice_kakaotalk/web/static/lib/jsbn.min.js b/matrix_appservice_kakaotalk/web/static/lib/jsbn.min.js new file mode 100644 index 0000000..776f93c --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/lib/jsbn.min.js @@ -0,0 +1,9 @@ +// From http://www-cs-students.stanford.edu/~tjw/jsbn/jsbn.js +// Also includes bnIntValue from http://www-cs-students.stanford.edu/~tjw/jsbn/jsbn2.js + +// Copyright (c) 2005 Tom Wu +// http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE + +var dbits,canary=0xdeadbeefcafe,j_lm=15715070==(16777215&canary);function BigInteger(t,i,r){null!=t&&("number"==typeof t?this.fromNumber(t,i,r):null==i&&"string"!=typeof t?this.fromString(t,256):this.fromString(t,i))}function nbi(){return new BigInteger(null)}function am1(t,i,r,o,n,e){for(;--e>=0;){var s=i*this[t++]+r[o]+n;n=Math.floor(s/67108864),r[o++]=67108863&s}return n}function am2(t,i,r,o,n,e){for(var s=32767&i,h=i>>15;--e>=0;){var p=32767&this[t],a=this[t++]>>15,f=h*p+a*s;n=((p=s*p+((32767&f)<<15)+r[o]+(1073741823&n))>>>30)+(f>>>15)+h*a+(n>>>30),r[o++]=1073741823&p}return n}function am3(t,i,r,o,n,e){for(var s=16383&i,h=i>>14;--e>=0;){var p=16383&this[t],a=this[t++]>>14,f=h*p+a*s;n=((p=s*p+((16383&f)<<14)+r[o]+n)>>28)+(f>>14)+h*a,r[o++]=268435455&p}return n}j_lm&&"Microsoft Internet Explorer"==navigator.appName?(BigInteger.prototype.am=am2,dbits=30):j_lm&&"Netscape"!=navigator.appName?(BigInteger.prototype.am=am1,dbits=26):(BigInteger.prototype.am=am3,dbits=28),BigInteger.prototype.DB=dbits,BigInteger.prototype.DM=(1<=0;--i)t[i]=this[i];t.t=this.t,t.s=this.s}function bnpFromInt(t){this.t=1,this.s=t<0?-1:0,t>0?this[0]=t:t<-1?this[0]=t+this.DV:this.t=0}function nbv(t){var i=nbi();return i.fromInt(t),i}function bnpFromString(t,i){var r;if(16==i)r=4;else if(8==i)r=3;else if(256==i)r=8;else if(2==i)r=1;else if(32==i)r=5;else{if(4!=i)return void this.fromRadix(t,i);r=2}this.t=0,this.s=0;for(var o=t.length,n=!1,e=0;--o>=0;){var s=8==r?255&t[o]:intAt(t,o);s<0?"-"==t.charAt(o)&&(n=!0):(n=!1,0==e?this[this.t++]=s:e+r>this.DB?(this[this.t-1]|=(s&(1<>this.DB-e):this[this.t-1]|=s<=this.DB&&(e-=this.DB))}8==r&&0!=(128&t[0])&&(this.s=-1,e>0&&(this[this.t-1]|=(1<0&&this[this.t-1]==t;)--this.t}function bnToString(t){if(this.s<0)return"-"+this.negate().toString(t);var i;if(16==t)i=4;else if(8==t)i=3;else if(2==t)i=1;else if(32==t)i=5;else{if(4!=t)return this.toRadix(t);i=2}var r,o=(1<0)for(h>h)>0&&(n=!0,e=int2char(r));s>=0;)h>(h+=this.DB-i)):(r=this[s]>>(h-=i)&o,h<=0&&(h+=this.DB,--s)),r>0&&(n=!0),n&&(e+=int2char(r));return n?e:"0"}function bnNegate(){var t=nbi();return BigInteger.ZERO.subTo(this,t),t}function bnAbs(){return this.s<0?this.negate():this}function bnCompareTo(t){var i=this.s-t.s;if(0!=i)return i;var r=this.t;if(0!=(i=r-t.t))return this.s<0?-i:i;for(;--r>=0;)if(0!=(i=this[r]-t[r]))return i;return 0}function nbits(t){var i,r=1;return 0!=(i=t>>>16)&&(t=i,r+=16),0!=(i=t>>8)&&(t=i,r+=8),0!=(i=t>>4)&&(t=i,r+=4),0!=(i=t>>2)&&(t=i,r+=2),0!=(i=t>>1)&&(t=i,r+=1),r}function bnBitLength(){return this.t<=0?0:this.DB*(this.t-1)+nbits(this[this.t-1]^this.s&this.DM)}function bnpDLShiftTo(t,i){var r;for(r=this.t-1;r>=0;--r)i[r+t]=this[r];for(r=t-1;r>=0;--r)i[r]=0;i.t=this.t+t,i.s=this.s}function bnpDRShiftTo(t,i){for(var r=t;r=0;--r)i[r+s+1]=this[r]>>n|h,h=(this[r]&e)<=0;--r)i[r]=0;i[s]=h,i.t=this.t+s+1,i.s=this.s,i.clamp()}function bnpRShiftTo(t,i){i.s=this.s;var r=Math.floor(t/this.DB);if(r>=this.t)i.t=0;else{var o=t%this.DB,n=this.DB-o,e=(1<>o;for(var s=r+1;s>o;o>0&&(i[this.t-r-1]|=(this.s&e)<>=this.DB;if(t.t>=this.DB;o+=this.s}else{for(o+=this.s;r>=this.DB;o-=t.s}i.s=o<0?-1:0,o<-1?i[r++]=this.DV+o:o>0&&(i[r++]=o),i.t=r,i.clamp()}function bnpMultiplyTo(t,i){var r=this.abs(),o=t.abs(),n=r.t;for(i.t=n+o.t;--n>=0;)i[n]=0;for(n=0;n=0;)t[r]=0;for(r=0;r=i.DV&&(t[r+i.t]-=i.DV,t[r+i.t+1]=1)}t.t>0&&(t[t.t-1]+=i.am(r,i[r],t,2*r,0,1)),t.s=0,t.clamp()}function bnpDivRemTo(t,i,r){var o=t.abs();if(!(o.t<=0)){var n=this.abs();if(n.t0?(o.lShiftTo(p,e),n.lShiftTo(p,r)):(o.copyTo(e),n.copyTo(r));var a=e.t,f=e[a-1];if(0!=f){var u=f*(1<1?e[a-2]>>this.F2:0),g=this.FV/u,m=(1<=0&&(r[r.t++]=1,r.subTo(l,r)),BigInteger.ONE.dlShiftTo(a,l),l.subTo(e,e);e.t=0;){var B=r[--v]==f?this.DM:Math.floor(r[v]*g+(r[v-1]+c)*m);if((r[v]+=e.am(0,B,r,b,0,a))0&&r.rShiftTo(p,r),s<0&&BigInteger.ZERO.subTo(r,r)}}}function bnMod(t){var i=nbi();return this.abs().divRemTo(t,null,i),this.s<0&&i.compareTo(BigInteger.ZERO)>0&&t.subTo(i,i),i}function Classic(t){this.m=t}function cConvert(t){return t.s<0||t.compareTo(this.m)>=0?t.mod(this.m):t}function cRevert(t){return t}function cReduce(t){t.divRemTo(this.m,null,t)}function cMulTo(t,i,r){t.multiplyTo(i,r),this.reduce(r)}function cSqrTo(t,i){t.squareTo(i),this.reduce(i)}function bnpInvDigit(){if(this.t<1)return 0;var t=this[0];if(0==(1&t))return 0;var i=3&t;return(i=(i=(i=(i=i*(2-(15&t)*i)&15)*(2-(255&t)*i)&255)*(2-((65535&t)*i&65535))&65535)*(2-t*i%this.DV)%this.DV)>0?this.DV-i:-i}function Montgomery(t){this.m=t,this.mp=t.invDigit(),this.mpl=32767&this.mp,this.mph=this.mp>>15,this.um=(1<0&&this.m.subTo(i,i),i}function montRevert(t){var i=nbi();return t.copyTo(i),this.reduce(i),i}function montReduce(t){for(;t.t<=this.mt2;)t[t.t++]=0;for(var i=0;i>15)*this.mpl&this.um)<<15)&t.DM;for(t[r=i+this.m.t]+=this.m.am(0,o,t,i,0,this.m.t);t[r]>=t.DV;)t[r]-=t.DV,t[++r]++}t.clamp(),t.drShiftTo(this.m.t,t),t.compareTo(this.m)>=0&&t.subTo(this.m,t)}function montSqrTo(t,i){t.squareTo(i),this.reduce(i)}function montMulTo(t,i,r){t.multiplyTo(i,r),this.reduce(r)}function bnpIsEven(){return 0==(this.t>0?1&this[0]:this.s)}function bnpExp(t,i){if(t>4294967295||t<1)return BigInteger.ONE;var r=nbi(),o=nbi(),n=i.convert(this),e=nbits(t)-1;for(n.copyTo(r);--e>=0;)if(i.sqrTo(r,o),(t&1<0)i.mulTo(o,n,r);else{var s=r;r=o,o=s}return i.revert(r)}function bnModPowInt(t,i){var r;return r=t<256||i.isEven()?new Classic(i):new Montgomery(i),this.exp(t,r)}function bnIntValue(){if(this.s<0){if(1==this.t)return this[0]-this.DV;if(0==this.t)return-1}else{if(1==this.t)return this[0];if(0==this.t)return 0}return(this[1]&(1<<32-this.DB)-1)<code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:.1rem solid #f4f5f6;margin:3rem 0}input:not([type]),input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=url],input[type=week],select,textarea{-webkit-appearance:none;background-color:transparent;border:.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1rem .7rem;width:100%}input:not([type]):focus,input[type=color]:focus,input[type=date]:focus,input[type=datetime-local]:focus,input[type=datetime]:focus,input[type=email]:focus,input[type=month]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=url]:focus,input[type=week]:focus,select:focus,textarea:focus{border-color:#9b4dca;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}select[multiple]{background:0 0;height:auto}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type=checkbox],input[type=radio]{display:inline}.label-inline{display:inline-block;font-weight:400;margin-left:.5rem}.container{margin:0 auto;max-width:112rem;padding:0 2rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-40{margin-left:40%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-60{margin-left:60%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{align-self:center}@media (min-width:40rem){.row{flex-direction:row;margin-left:-1rem;width:calc(100% + 2rem)}.row .column{margin-bottom:inherit;padding:0 1rem}}a{color:#9b4dca;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;display:block;overflow-x:auto;text-align:left;width:100%}td,th{border-bottom:.1rem solid #e1e1e1;padding:1.2rem 1.5rem}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}@media (min-width:40rem){table{display:table;overflow-x:initial}}b,strong{font-weight:700}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} diff --git a/matrix_appservice_kakaotalk/web/static/lib/normalize-8.0.1.min.css b/matrix_appservice_kakaotalk/web/static/lib/normalize-8.0.1.min.css new file mode 100644 index 0000000..b52daa7 --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/lib/normalize-8.0.1.min.css @@ -0,0 +1,2 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ +html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none} diff --git a/matrix_appservice_kakaotalk/web/static/lib/preact-10.5.12.min.js b/matrix_appservice_kakaotalk/web/static/lib/preact-10.5.12.min.js new file mode 100644 index 0000000..b4ff741 --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/lib/preact-10.5.12.min.js @@ -0,0 +1 @@ +var n,l,u,i,t,r,o={},f=[],e=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function c(e,n){for(var t in n)e[t]=n[t];return e}function s(e){var n=e.parentNode;n&&n.removeChild(e)}function a(e,n,t){var _,l,o,r=arguments,u={};for(o in n)"key"==o?_=n[o]:"ref"==o?l=n[o]:u[o]=n[o];if(arguments.length>3)for(t=[t],o=3;o0?v(m.type,m.props,m.key,null,m.__v):m)){if(m.__=t,m.__b=t.__b+1,null===(h=P[p])||h&&m.key==h.key&&m.type===h.type)P[p]=void 0;else for(a=0;a3)for(t=[t],o=3;o>8&255,rng_pool[rng_pptr++]^=t>>16&255,rng_pool[rng_pptr++]^=t>>24&255,rng_pptr>=rng_psize&&(rng_pptr-=rng_psize)}function rng_seed_time(){rng_seed_int((new Date).getTime())}if(null==rng_pool){var t;if(rng_pool=new Array,rng_pptr=0,window.crypto&&window.crypto.getRandomValues){var ua=new Uint8Array(32);for(window.crypto.getRandomValues(ua),t=0;t<32;++t)rng_pool[rng_pptr++]=ua[t]}if("Netscape"==navigator.appName&&navigator.appVersion<"5"&&window.crypto){var z=window.crypto.random(32);for(t=0;t>>8,rng_pool[rng_pptr++]=255&t;rng_pptr=0,rng_seed_time()}function rng_get_byte(){if(null==rng_state){for(rng_seed_time(),(rng_state=prng_newstate()).init(rng_pool),rng_pptr=0;rng_pptr=0&&t>0;)e[--t]=n[r--];e[--t]=0;for(var l=new SecureRandom,i=new Array;t>2;){for(i[0]=0;0==i[0];)l.nextBytes(i);e[--t]=i[0]}return e[--t]=2,e[--t]=0,new BigInteger(e)}function RSAKey(){this.n=null,this.e=0,this.d=null,this.p=null,this.q=null,this.dmp1=null,this.dmq1=null,this.coeff=null}function RSASetPublic(n,t){null!=n&&null!=t&&n.length>0&&t.length>0?(this.n=parseBigInt(n,16),this.e=parseInt(t,16)):alert("Invalid RSA public key")}function RSADoPublic(n){return n.modPowInt(this.e,this.n)}function RSAEncrypt(n){var t=pkcs1pad2(n,this.n.bitLength()+7>>3);if(null==t)return null;var e=this.doPublic(t);if(null==e)return null;var r=e.toString(16);return 0==(1&r.length)?r:"0"+r}RSAKey.prototype.doPublic=RSADoPublic,RSAKey.prototype.setPublic=RSASetPublic,RSAKey.prototype.encrypt=RSAEncrypt; + +export default RSAKey diff --git a/matrix_appservice_kakaotalk/web/static/lib/spinner.css b/matrix_appservice_kakaotalk/web/static/lib/spinner.css new file mode 100644 index 0000000..ca0e335 --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/lib/spinner.css @@ -0,0 +1,80 @@ +.loader { + color: #9b4dca; + font-size: 90px; + text-indent: -9999em; + overflow: hidden; + width: 1em; + height: 1em; + border-radius: 50%; + margin: 72px auto; + position: relative; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load6 1.7s infinite ease, round 1.7s infinite ease; + animation: load6 1.7s infinite ease, round 1.7s infinite ease; +} +@-webkit-keyframes load6 { + 0% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } + 5%, + 95% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } + 10%, + 59% { + box-shadow: 0 -0.83em 0 -0.4em, -0.087em -0.825em 0 -0.42em, -0.173em -0.812em 0 -0.44em, -0.256em -0.789em 0 -0.46em, -0.297em -0.775em 0 -0.477em; + } + 20% { + box-shadow: 0 -0.83em 0 -0.4em, -0.338em -0.758em 0 -0.42em, -0.555em -0.617em 0 -0.44em, -0.671em -0.488em 0 -0.46em, -0.749em -0.34em 0 -0.477em; + } + 38% { + box-shadow: 0 -0.83em 0 -0.4em, -0.377em -0.74em 0 -0.42em, -0.645em -0.522em 0 -0.44em, -0.775em -0.297em 0 -0.46em, -0.82em -0.09em 0 -0.477em; + } + 100% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } +} +@keyframes load6 { + 0% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } + 5%, + 95% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } + 10%, + 59% { + box-shadow: 0 -0.83em 0 -0.4em, -0.087em -0.825em 0 -0.42em, -0.173em -0.812em 0 -0.44em, -0.256em -0.789em 0 -0.46em, -0.297em -0.775em 0 -0.477em; + } + 20% { + box-shadow: 0 -0.83em 0 -0.4em, -0.338em -0.758em 0 -0.42em, -0.555em -0.617em 0 -0.44em, -0.671em -0.488em 0 -0.46em, -0.749em -0.34em 0 -0.477em; + } + 38% { + box-shadow: 0 -0.83em 0 -0.4em, -0.377em -0.74em 0 -0.42em, -0.645em -0.522em 0 -0.44em, -0.775em -0.297em 0 -0.46em, -0.82em -0.09em 0 -0.477em; + } + 100% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } +} +@-webkit-keyframes round { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes round { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/matrix_appservice_kakaotalk/web/static/login.html b/matrix_appservice_kakaotalk/web/static/login.html new file mode 100644 index 0000000..0a9b902 --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/login.html @@ -0,0 +1,44 @@ + + + + + + + matrix-appservice-kakaotalk login + + + + + + + + + + + + + + + + + + + + + diff --git a/matrix_appservice_kakaotalk/web/static/login/api.js b/matrix_appservice_kakaotalk/web/static/login/api.js new file mode 100644 index 0000000..ee9ad19 --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/login/api.js @@ -0,0 +1,62 @@ +// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +// Copyright (C) 2021 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import encryptPassword from "./crypto.js" + +const apiToken = location.hash.slice(1) +const headers = { Authorization: `Bearer ${apiToken}` } +const jsonHeaders = { ...headers, "Content-Type": "application/json" } +const fetchParams = { headers } + +export async function whoami() { + const resp = await fetch("api/whoami", fetchParams) + return await resp.json() +} + +export async function prepareLogin() { + const resp = await fetch("api/login/prepare", { ...fetchParams, method: "POST" }) + return await resp.json() +} + +export async function login(pubkey, keyID, email, password) { + const resp = await fetch("api/login", { + method: "POST", + body: JSON.stringify({ + email, + encrypted_password: await encryptPassword(pubkey, keyID, password), + }), + headers: jsonHeaders, + }) + return await resp.json() +} + +export async function login2FA(email, code) { + const resp = await fetch("api/login/2fa", { + method: "POST", + body: JSON.stringify({ email, code }), + headers: jsonHeaders, + }) + return await resp.json() +} + +export async function loginApproved() { + const resp = await fetch("api/login/approved", { method: "POST", headers }) + return await resp.json() +} + +export async function wasLoginApproved() { + const resp = await fetch("api/login/check_approved", fetchParams) + return (await resp.json()).approved +} diff --git a/matrix_appservice_kakaotalk/web/static/login/app.js b/matrix_appservice_kakaotalk/web/static/login/app.js new file mode 100644 index 0000000..69afb55 --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/login/app.js @@ -0,0 +1,200 @@ +// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +// Copyright (C) 2021 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { h, Component, render } from "../lib/preact-10.5.12.min.js" +import htm from "../lib/htm-3.0.4.min.js" +import * as api from "./api.js" + +const html = htm.bind(h) + +class App extends Component { + constructor(props) { + super(props) + this.approveCheckInterval = null + this.state = { + loading: true, + submitting: false, + error: null, + mxid: null, + facebook: null, + status: "pre-login", + pubkey: null, + keyID: null, + email: "", + password: "", + twoFactorCode: "", + twoFactorInfo: {}, + } + } + + async componentDidMount() { + const { error, mxid, facebook } = await api.whoami() + if (error) { + this.setState({ error, loading: false }) + } else { + this.setState({ mxid, facebook, loading: false }) + } + } + + checkLoginApproved = async () => { + if (!await api.wasLoginApproved()) { + return + } + clearInterval(this.approveCheckInterval) + this.approveCheckInterval = null + const resp = await api.loginApproved() + if (resp.status === "logged-in") { + this.setState({ status: resp.status }) + } + } + + submitNoDefault = evt => { + evt.preventDefault() + this.submit() + } + + async submit() { + if (this.approveCheckInterval) { + clearInterval(this.approveCheckInterval) + this.approveCheckInterval = null + } + this.setState({ submitting: true }) + let resp + switch (this.state.status) { + case "pre-login": + resp = await api.prepareLogin() + break + case "login": + resp = await api.login(this.state.pubkey, this.state.keyID, + this.state.email, this.state.password) + break + case "two-factor": + resp = await api.login2FA(this.state.email, this.state.twoFactorCode) + break + } + const stateUpdate = { submitting: false } + if (typeof resp.error === "string") { + stateUpdate.error = resp.error + } else { + stateUpdate.status = resp.status + } + if (resp.password_encryption_key_id) { + stateUpdate.pubkey = resp.password_encryption_pubkey + stateUpdate.keyID = resp.password_encryption_key_id + } + if (resp.status === "two-factor") { + this.approveCheckInterval = setInterval(this.checkLoginApproved, 5000) + stateUpdate.twoFactorInfo = resp.error + } else if (resp.status === "logged-in") { + api.whoami().then(({ facebook }) => this.setState({ facebook })) + } + this.setState(stateUpdate) + } + + fieldChange = evt => { + this.setState({ [evt.target.id]: evt.target.value }) + } + + renderFields() { + switch (this.state.status) { + case "pre-login": + return null + case "login": + return html` + + + + + ` + case "two-factor": + return html` +

${this.state.twoFactorInfo.error_user_msg}

+ + + + + ` + } + } + + submitButtonText() { + switch (this.state.status) { + case "pre-login": + return "Start" + case "login": + case "two-factor": + return "Sign in" + } + } + + renderContent() { + if (this.state.loading) { + return html` +
Loading...
+ ` + } else if (this.state.status === "logged-in") { + if (this.state.facebook) { + return html` + Successfully logged in as ${this.state.facebook.name}. The bridge will appear + as ${this.state.facebook.device_displayname} in Facebook security settings. + ` + } + return html` + Successfully logged in + ` + } else if (this.state.facebook) { + return html` + You're already logged in as ${this.state.facebook.name}. The bridge appears + as ${this.state.facebook.device_displayname} in Facebook security settings. + ` + } + return html` + ${this.state.error && html` +
${this.state.error}
+ `} +
+
+ + + ${this.renderFields()} + +
+
+ ` + } + + render() { + return html` +
+

matrix-appservice-kakaotalk login

+ ${this.renderContent()} +
+ ` + } +} + + +render(html` + <${App}/> +`, document.body) diff --git a/matrix_appservice_kakaotalk/web/static/login/crypto.js b/matrix_appservice_kakaotalk/web/static/login/crypto.js new file mode 100644 index 0000000..6b0262d --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/login/crypto.js @@ -0,0 +1,112 @@ +// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +// Copyright (C) 2021 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// We have to use this pure-js RSA implementation because SubtleCrypto dropped PKCS#1 v1.5 support. +import RSAKey from "../lib/rsa.min.js" +import ASN1HEX from "../lib/asn1hex-1.1.min.js" + +function pemToHex(pem) { + // Strip pem header + pem = pem.replace("-----BEGIN PUBLIC KEY-----", "") + pem = pem.replace("-----END PUBLIC KEY-----", "") + + // Convert base64 to hex + const raw = atob(pem) + let result = "" + for (let i = 0; i < raw.length; i++) { + const hex = raw.charCodeAt(i).toString(16) + result += (hex.length === 2 ? hex : "0" + hex) + } + return result.toLowerCase() +} + +function getKey(pem) { + const keyHex = pemToHex(pem) + if (ASN1HEX.isASN1HEX(keyHex) === false) { + throw new Error("key is not ASN.1 hex string") + } else if (ASN1HEX.getVbyList(keyHex, 0, [0, 0], "06") !== "2a864886f70d010101") { + throw new Error("not PKCS8 RSA key") + } else if (ASN1HEX.getTLVbyListEx(keyHex, 0, [0, 0]) !== "06092a864886f70d010101") { + throw new Error("not PKCS8 RSA public key") + } + + const p5hex = ASN1HEX.getTLVbyListEx(keyHex, 0, [1, 0]) + if (ASN1HEX.isASN1HEX(p5hex) === false) { + throw new Error("keyHex is not ASN.1 hex string") + } + + const aIdx = ASN1HEX.getChildIdx(p5hex, 0) + if (aIdx.length !== 2 || p5hex.substr(aIdx[0], 2) !== "02" || p5hex.substr(aIdx[1], 2) !== "02") { + throw new Error("wrong hex for PKCS#5 public key") + } + + const hN = ASN1HEX.getV(p5hex, aIdx[0]) + const hE = ASN1HEX.getV(p5hex, aIdx[1]) + const key = new RSAKey() + key.setPublic(hN, hE) + return key +} + +// encryptPassword encrypts a login password using AES-256-GCM, then encrypts the AES key +// for Facebook's RSA-2048 key using PKCS#1 v1.5 padding. +// +// See https://github.com/mautrix/facebook/blob/v0.3.0/maufbapi/http/login.py#L164-L192 +// for the Python implementation of the same encryption protocol. +async function encryptPassword(pubkey, keyID, password) { + // Key and IV for AES encryption + const aesKey = await crypto.subtle.generateKey({ + name: "AES-GCM", + length: 256, + }, true, ["encrypt", "decrypt"]) + const aesIV = crypto.getRandomValues(new Uint8Array(12)) + // Get the actual bytes of the AES key + const aesKeyBytes = await crypto.subtle.exportKey("raw", aesKey) + + // Encrypt AES key with Facebook's RSA public key. + const rsaKey = getKey(pubkey) + const encryptedAESKeyHex = rsaKey.encrypt(new Uint8Array(aesKeyBytes)) + const encryptedAESKey = new Uint8Array(encryptedAESKeyHex.match(/[0-9A-Fa-f]{2}/g).map(h => parseInt(h, 16))) + + const encoder = new TextEncoder() + const time = Math.floor(Date.now() / 1000) + // Encrypt the password. The result includes the ciphertext and AES MAC auth tag. + const encryptedPasswordBuffer = await crypto.subtle.encrypt({ + name: "AES-GCM", + iv: aesIV, + // Add the current time to the additional authenticated data (AAD) section + additionalData: encoder.encode(time.toString()), + tagLength: 128, + }, aesKey, encoder.encode(password)) + // SubtleCrypto returns the auth tag and ciphertext in the wrong order, + // so we have to flip them around. + const authTag = new Uint8Array(encryptedPasswordBuffer.slice(-16)) + const encryptedPassword = new Uint8Array(encryptedPasswordBuffer.slice(0, -16)) + + const payload = new Uint8Array(2 + aesIV.byteLength + 2 + encryptedAESKey.byteLength + authTag.byteLength + encryptedPassword.byteLength) + // 1 is presumably the version + payload[0] = 1 + payload[1] = keyID + payload.set(aesIV, 2) + // Length of the encrypted AES key as a little-endian 16-bit int + payload[aesIV.byteLength + 2] = encryptedAESKey.byteLength & (1 << 8) + payload[aesIV.byteLength + 3] = encryptedAESKey.byteLength >> 8 + payload.set(encryptedAESKey, 4 + aesIV.byteLength) + payload.set(authTag, 4 + aesIV.byteLength + encryptedAESKey.byteLength) + payload.set(encryptedPassword, 4 + aesIV.byteLength + encryptedAESKey.byteLength + authTag.byteLength) + return `#PWD_MSGR:1:${time}:${btoa(String.fromCharCode(...payload))}` +} + +export default encryptPassword diff --git a/matrix_appservice_kakaotalk/web/static/login/index.css b/matrix_appservice_kakaotalk/web/static/login/index.css new file mode 100644 index 0000000..efef17d --- /dev/null +++ b/matrix_appservice_kakaotalk/web/static/login/index.css @@ -0,0 +1,10 @@ +.error { + background-color: darkred !important; + border-color: darkred !important; + opacity: 1 !important; +} + +main { + max-width: 50rem; + margin: 2rem auto 0; +} diff --git a/node/.gitignore b/node/.gitignore new file mode 100644 index 0000000..387d417 --- /dev/null +++ b/node/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/config*.json +/*.sock diff --git a/node/example-config.json b/node/example-config.json new file mode 100644 index 0000000..e81822c --- /dev/null +++ b/node/example-config.json @@ -0,0 +1,6 @@ +{ + "listen": { + "type": "unix", + "path": "/var/run/matrix-appservice-kakaotalk/rpc.sock" + } +} diff --git a/node/package-lock.json b/node/package-lock.json new file mode 100644 index 0000000..d417e28 --- /dev/null +++ b/node/package-lock.json @@ -0,0 +1,4411 @@ +{ + "name": "matrix-appservice-kakaotalk-node", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "matrix-appservice-kakaotalk-node", + "version": "0.0.1", + "license": "AGPL-3.0-or-later", + "dependencies": { + "arg": "^4.1.3", + "node-kakao": "4.5.0", + "systemd-daemon": "^1.1.2" + }, + "devDependencies": { + "@types/node": "^17.0.12", + "babel-eslint": "^10.1.0", + "eslint": "^7.7.0", + "eslint-plugin-import": "^2.22.0" + }, + "engines": { + "node": ">=16.13.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.16.8.tgz", + "integrity": "sha512-1ojZwE9+lOXzcWdWmO6TbUzDfqLD39CmEhN8+2cX9XkDo5yW1OpgfejfliysR2AWLpMamTiOiAp/mtroaymhpw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.8", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz", + "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==", + "dev": true, + "dependencies": { + "@babel/helper-get-function-arity": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-get-function-arity": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz", + "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.16.10", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", + "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.16.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.16.12.tgz", + "integrity": "sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.16.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.16.10.tgz", + "integrity": "sha512-yzuaYXoRJBGMlBhsMJoUW7G1UmSb/eXr/JHYM/MsOJgavJibLwASijW7oXBdw3NQ6T0bW7Ty5P/VarOs9cHmqw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.16.8", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.16.10", + "@babel/types": "^7.16.8", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.8.tgz", + "integrity": "sha512-smN2DQc5s4M7fntyjGtyIPbRJv6wW4rU/94fmYJ7PKQuZkC0qGMHXJbg6sNGt12JmVr4k5YaptI/XtiLJBnmIg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", + "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^13.9.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.12.0.tgz", + "integrity": "sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", + "dev": true + }, + "node_modules/@types/node": { + "version": "17.0.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.12.tgz", + "integrity": "sha512-4YpbAsnJXWYK/fpTVFlMIcUIho2AYCi4wg5aNPrG1ng7fn/1/RZfCIpRCiBX+12RVa34RluilnvCqD+g3KiSiA==", + "dev": true + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-includes": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz", + "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1", + "get-intrinsic": "^1.1.1", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz", + "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "node_modules/axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "dependencies": { + "follow-redirects": "^1.14.4" + } + }, + "node_modules/babel-eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", + "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", + "deprecated": "babel-eslint is now @babel/eslint-parser. This package will no longer receive updates.", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.0", + "@babel/traverse": "^7.7.0", + "@babel/types": "^7.7.0", + "eslint-visitor-keys": "^1.0.0", + "resolve": "^1.12.0" + }, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "eslint": ">= 4.12.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bson": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-4.6.1.tgz", + "integrity": "sha512-I1LQ7Hz5zgwR4QquilLNZwbhPw0Apx7i7X9kGMBTsqPdml/03Q9NBtD9nt/19ahjlphktQImrnderxqpzeVDjw==", + "dependencies": { + "buffer": "^5.6.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "node_modules/core-js": { + "version": "3.20.3", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.20.3.tgz", + "integrity": "sha512-vVl8j8ph6tRS3B8qir40H7yw7voy17xL0piAjlbBUsH7WIfzoedL/ZOr1OV9FyZQLWXsayOJyV4tnRyXR85/ag==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "dependencies": { + "object-keys": "^1.0.12" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "dependencies": { + "ansi-colors": "^4.1.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/es-abstract": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", + "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "get-symbol-description": "^1.0.0", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "internal-slot": "^1.0.3", + "is-callable": "^1.2.4", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.1", + "is-string": "^1.0.7", + "is-weakref": "^1.0.1", + "object-inspect": "^1.11.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint": { + "version": "7.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", + "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "7.12.11", + "@eslint/eslintrc": "^0.4.3", + "@humanwhocodes/config-array": "^0.5.0", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.1", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.1.2", + "globals": "^13.6.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^6.0.9", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", + "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "resolve": "^1.20.0" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.2.tgz", + "integrity": "sha512-zquepFnWCY2ISMFwD/DqzaM++H+7PDzOpUvotJWm/y1BAFt5R4oeULgdrTejKqLkz7MA/tgstsUMNYc7wNdTrg==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "find-up": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.25.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz", + "integrity": "sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.4", + "array.prototype.flat": "^1.2.5", + "debug": "^2.6.9", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-module-utils": "^2.7.2", + "has": "^1.0.3", + "is-core-module": "^2.8.0", + "is-glob": "^4.0.3", + "minimatch": "^3.0.4", + "object.values": "^1.1.5", + "resolve": "^1.20.0", + "tsconfig-paths": "^3.12.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint/node_modules/@babel/code-frame": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", + "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.12.0.tgz", + "integrity": "sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/espree": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", + "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "dev": true, + "dependencies": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.3.1", + "eslint-visitor-keys": "^1.3.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true + }, + "node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "dependencies": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.4.tgz", + "integrity": "sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.14.7", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz", + "integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "node_modules/get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-bigints": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", + "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-wasm": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.9.0.tgz", + "integrity": "sha512-7SW7ejyfnRxuOc7ptQHSf4LDoZaWOivfzqw+5rpcQku0nHfmicPKE51ra9BiRLAmT8+gGLestr1XroUkqdjL6w==" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", + "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.0", + "has": "^1.0.3", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", + "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", + "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz", + "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz", + "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "node_modules/json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", + "dev": true + }, + "node_modules/lossless-json": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-1.0.5.tgz", + "integrity": "sha512-RicKUuLwZVNZ6ZdJHgIZnSeA05p8qWc5NW0uR96mpPIjN9WDLUg9+kj1esQU1GkPn9iLZVKatSQK5gyiaFHgJA==" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mime-db": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz", + "integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.34", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz", + "integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==", + "dependencies": { + "mime-db": "1.51.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nan": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", + "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", + "optional": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "node_modules/node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/node-kakao": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/node-kakao/-/node-kakao-4.5.0.tgz", + "integrity": "sha512-1ci/aQ8ifjhRD7xHUmIuHVkfoxLxgszcMzKY3xM8ixRomSnM0E1JkLgchjtx+j6Uqa4aFxOM/DYxhE14dholYg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dependencies": { + "axios": "^0.24.0", + "bson": "^4.4.1", + "eventemitter3": "^4.0.7", + "form-data": "^4.0.0", + "hash-wasm": "^4.8.0", + "lossless-json": "^1.0.3", + "node-forge": "^0.10.0", + "promise-socket": "^7.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", + "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", + "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-duplex": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/promise-duplex/-/promise-duplex-6.0.0.tgz", + "integrity": "sha512-ZL7rquzjTFzInDBeWYcsT+qddolNvzigahk6MI6qLSbQvlyRRCJkU3JztgaVunzvkH28smRa2Qu/cY9RXtSkgA==", + "dependencies": { + "core-js": "^3.6.5", + "promise-readable": "^6.0.0", + "promise-writable": "^6.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/promise-readable": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/promise-readable/-/promise-readable-6.0.0.tgz", + "integrity": "sha512-5NxtmUswijvX5cAM0zPSy6yiCXH/eKBpiiBq6JfAUrmngMquMbzcBhF2qA+ocs4rYYKdvAfv3cOvZxADLtL1CA==", + "dependencies": { + "core-js": "^3.6.5" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/promise-socket": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/promise-socket/-/promise-socket-7.0.0.tgz", + "integrity": "sha512-Oic9BrxmcHOPEnzKp2Js+ehFyvsbd0WxsE5khweCTHuRvdzbXjHUZmSDT6F9TW8SIkAJ0lCzoHjMYnb0WQJPiw==", + "dependencies": { + "promise-duplex": "^6.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/promise-writable": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/promise-writable/-/promise-writable-6.0.0.tgz", + "integrity": "sha512-b81zre/itgJFS7dwWzIdKNVVqvLiUxYRS/wolUB0H1YY/tAaS146XGKa4Q/5wCbsnXLyn0MCeV6f8HHe4iUHLg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/systemd-daemon": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/systemd-daemon/-/systemd-daemon-1.1.2.tgz", + "integrity": "sha512-1s3JH5W78WYQI6iAQdsgoz9LMO5Sj5OtanjeNopJ15iX2q6QupRvkG5SQPJIj+YN3IgUMqPbtzfWxweCVKe28g==", + "optionalDependencies": { + "unix-dgram": "^2.0.2" + } + }, + "node_modules/table": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz", + "integrity": "sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.9.0.tgz", + "integrity": "sha512-qOKJyNj/h+OWx7s5DePL6Zu1KeM9jPZhwBqs+7DzP6bGOvqzVCSf0xueYmVuaC/oQ/VtS2zLMLHdQFbkka+XDQ==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.12.0.tgz", + "integrity": "sha512-e5adrnOYT6zqVnWqZu7i/BQ3BnhzvGbjEjejFXO20lKIKpwTaupkCPgEfv4GZK1IBciJUEhYs3J3p75FdaTFVg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.1", + "minimist": "^1.2.0", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", + "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unix-dgram": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/unix-dgram/-/unix-dgram-2.0.4.tgz", + "integrity": "sha512-7tpK6x7ls7J7pDrrAU63h93R0dVhRbPwiRRCawR10cl+2e1VOvF3bHlVJc6WI1dl/8qk5He673QU+Ogv7bPNaw==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "bindings": "^1.3.0", + "nan": "^2.13.2" + }, + "engines": { + "node": ">=0.10.48" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.16.7" + } + }, + "@babel/generator": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.16.8.tgz", + "integrity": "sha512-1ojZwE9+lOXzcWdWmO6TbUzDfqLD39CmEhN8+2cX9XkDo5yW1OpgfejfliysR2AWLpMamTiOiAp/mtroaymhpw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.8", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + } + }, + "@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-function-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz", + "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz", + "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true + }, + "@babel/highlight": { + "version": "7.16.10", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", + "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.16.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.16.12.tgz", + "integrity": "sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A==", + "dev": true + }, + "@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/traverse": { + "version": "7.16.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.16.10.tgz", + "integrity": "sha512-yzuaYXoRJBGMlBhsMJoUW7G1UmSb/eXr/JHYM/MsOJgavJibLwASijW7oXBdw3NQ6T0bW7Ty5P/VarOs9cHmqw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.16.8", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.16.10", + "@babel/types": "^7.16.8", + "debug": "^4.1.0", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.8.tgz", + "integrity": "sha512-smN2DQc5s4M7fntyjGtyIPbRJv6wW4rU/94fmYJ7PKQuZkC0qGMHXJbg6sNGt12JmVr4k5YaptI/XtiLJBnmIg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@eslint/eslintrc": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", + "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^13.9.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "globals": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.12.0.tgz", + "integrity": "sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + } + } + }, + "@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + } + }, + "@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", + "dev": true + }, + "@types/node": { + "version": "17.0.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.12.tgz", + "integrity": "sha512-4YpbAsnJXWYK/fpTVFlMIcUIho2AYCi4wg5aNPrG1ng7fn/1/RZfCIpRCiBX+12RVa34RluilnvCqD+g3KiSiA==", + "dev": true + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "array-includes": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz", + "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1", + "get-intrinsic": "^1.1.1", + "is-string": "^1.0.7" + } + }, + "array.prototype.flat": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz", + "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0" + } + }, + "astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "requires": { + "follow-redirects": "^1.14.4" + } + }, + "babel-eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", + "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.0", + "@babel/traverse": "^7.7.0", + "@babel/types": "^7.7.0", + "eslint-visitor-keys": "^1.0.0", + "resolve": "^1.12.0" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "bson": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-4.6.1.tgz", + "integrity": "sha512-I1LQ7Hz5zgwR4QquilLNZwbhPw0Apx7i7X9kGMBTsqPdml/03Q9NBtD9nt/19ahjlphktQImrnderxqpzeVDjw==", + "requires": { + "buffer": "^5.6.0" + } + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "core-js": { + "version": "3.20.3", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.20.3.tgz", + "integrity": "sha512-vVl8j8ph6tRS3B8qir40H7yw7voy17xL0piAjlbBUsH7WIfzoedL/ZOr1OV9FyZQLWXsayOJyV4tnRyXR85/ag==" + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, + "es-abstract": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", + "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "get-symbol-description": "^1.0.0", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "internal-slot": "^1.0.3", + "is-callable": "^1.2.4", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.1", + "is-string": "^1.0.7", + "is-weakref": "^1.0.1", + "object-inspect": "^1.11.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint": { + "version": "7.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", + "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", + "dev": true, + "requires": { + "@babel/code-frame": "7.12.11", + "@eslint/eslintrc": "^0.4.3", + "@humanwhocodes/config-array": "^0.5.0", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.1", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.1.2", + "globals": "^13.6.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^6.0.9", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", + "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true + }, + "globals": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.12.0.tgz", + "integrity": "sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "eslint-import-resolver-node": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", + "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "dev": true, + "requires": { + "debug": "^3.2.7", + "resolve": "^1.20.0" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "eslint-module-utils": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.2.tgz", + "integrity": "sha512-zquepFnWCY2ISMFwD/DqzaM++H+7PDzOpUvotJWm/y1BAFt5R4oeULgdrTejKqLkz7MA/tgstsUMNYc7wNdTrg==", + "dev": true, + "requires": { + "debug": "^3.2.7", + "find-up": "^2.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "eslint-plugin-import": { + "version": "2.25.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz", + "integrity": "sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==", + "dev": true, + "requires": { + "array-includes": "^3.1.4", + "array.prototype.flat": "^1.2.5", + "debug": "^2.6.9", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-module-utils": "^2.7.2", + "has": "^1.0.3", + "is-core-module": "^2.8.0", + "is-glob": "^4.0.3", + "minimatch": "^3.0.4", + "object.values": "^1.1.5", + "resolve": "^1.20.0", + "tsconfig-paths": "^3.12.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + }, + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + }, + "espree": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", + "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "dev": true, + "requires": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.3.1", + "eslint-visitor-keys": "^1.3.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.4.tgz", + "integrity": "sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==", + "dev": true + }, + "follow-redirects": { + "version": "1.14.7", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz", + "integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + } + }, + "glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-bigints": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", + "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "hash-wasm": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.9.0.tgz", + "integrity": "sha512-7SW7ejyfnRxuOc7ptQHSf4LDoZaWOivfzqw+5rpcQku0nHfmicPKE51ra9BiRLAmT8+gGLestr1XroUkqdjL6w==" + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "internal-slot": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", + "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.0", + "has": "^1.0.3", + "side-channel": "^1.0.4" + } + }, + "is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "requires": { + "has-bigints": "^1.0.1" + } + }, + "is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-callable": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", + "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==", + "dev": true + }, + "is-core-module": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", + "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true + }, + "is-number-object": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz", + "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-shared-array-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz", + "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==", + "dev": true + }, + "is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", + "dev": true + }, + "lossless-json": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-1.0.5.tgz", + "integrity": "sha512-RicKUuLwZVNZ6ZdJHgIZnSeA05p8qWc5NW0uR96mpPIjN9WDLUg9+kj1esQU1GkPn9iLZVKatSQK5gyiaFHgJA==" + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "mime-db": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz", + "integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==" + }, + "mime-types": { + "version": "2.1.34", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz", + "integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==", + "requires": { + "mime-db": "1.51.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "nan": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", + "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", + "optional": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" + }, + "node-kakao": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/node-kakao/-/node-kakao-4.5.0.tgz", + "integrity": "sha512-1ci/aQ8ifjhRD7xHUmIuHVkfoxLxgszcMzKY3xM8ixRomSnM0E1JkLgchjtx+j6Uqa4aFxOM/DYxhE14dholYg==", + "requires": { + "axios": "^0.24.0", + "bson": "^4.4.1", + "eventemitter3": "^4.0.7", + "form-data": "^4.0.0", + "hash-wasm": "^4.8.0", + "lossless-json": "^1.0.3", + "node-forge": "^0.10.0", + "promise-socket": "^7.0.0" + } + }, + "object-inspect": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", + "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "object.values": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", + "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "promise-duplex": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/promise-duplex/-/promise-duplex-6.0.0.tgz", + "integrity": "sha512-ZL7rquzjTFzInDBeWYcsT+qddolNvzigahk6MI6qLSbQvlyRRCJkU3JztgaVunzvkH28smRa2Qu/cY9RXtSkgA==", + "requires": { + "core-js": "^3.6.5", + "promise-readable": "^6.0.0", + "promise-writable": "^6.0.0" + } + }, + "promise-readable": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/promise-readable/-/promise-readable-6.0.0.tgz", + "integrity": "sha512-5NxtmUswijvX5cAM0zPSy6yiCXH/eKBpiiBq6JfAUrmngMquMbzcBhF2qA+ocs4rYYKdvAfv3cOvZxADLtL1CA==", + "requires": { + "core-js": "^3.6.5" + } + }, + "promise-socket": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/promise-socket/-/promise-socket-7.0.0.tgz", + "integrity": "sha512-Oic9BrxmcHOPEnzKp2Js+ehFyvsbd0WxsE5khweCTHuRvdzbXjHUZmSDT6F9TW8SIkAJ0lCzoHjMYnb0WQJPiw==", + "requires": { + "promise-duplex": "^6.0.0", + "tslib": "^2.0.1" + } + }, + "promise-writable": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/promise-writable/-/promise-writable-6.0.0.tgz", + "integrity": "sha512-b81zre/itgJFS7dwWzIdKNVVqvLiUxYRS/wolUB0H1YY/tAaS146XGKa4Q/5wCbsnXLyn0MCeV6f8HHe4iUHLg==" + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, + "resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "requires": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "systemd-daemon": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/systemd-daemon/-/systemd-daemon-1.1.2.tgz", + "integrity": "sha512-1s3JH5W78WYQI6iAQdsgoz9LMO5Sj5OtanjeNopJ15iX2q6QupRvkG5SQPJIj+YN3IgUMqPbtzfWxweCVKe28g==", + "requires": { + "unix-dgram": "^2.0.2" + } + }, + "table": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz", + "integrity": "sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==", + "dev": true, + "requires": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "dependencies": { + "ajv": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.9.0.tgz", + "integrity": "sha512-qOKJyNj/h+OWx7s5DePL6Zu1KeM9jPZhwBqs+7DzP6bGOvqzVCSf0xueYmVuaC/oQ/VtS2zLMLHdQFbkka+XDQ==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + } + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "tsconfig-paths": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.12.0.tgz", + "integrity": "sha512-e5adrnOYT6zqVnWqZu7i/BQ3BnhzvGbjEjejFXO20lKIKpwTaupkCPgEfv4GZK1IBciJUEhYs3J3p75FdaTFVg==", + "dev": true, + "requires": { + "@types/json5": "^0.0.29", + "json5": "^1.0.1", + "minimist": "^1.2.0", + "strip-bom": "^3.0.0" + } + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + }, + "unbox-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", + "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + } + }, + "unix-dgram": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/unix-dgram/-/unix-dgram-2.0.4.tgz", + "integrity": "sha512-7tpK6x7ls7J7pDrrAU63h93R0dVhRbPwiRRCawR10cl+2e1VOvF3bHlVJc6WI1dl/8qk5He673QU+Ogv7bPNaw==", + "optional": true, + "requires": { + "bindings": "^1.3.0", + "nan": "^2.13.2" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } +} diff --git a/node/package.json b/node/package.json new file mode 100644 index 0000000..fb0b8e0 --- /dev/null +++ b/node/package.json @@ -0,0 +1,31 @@ +{ + "name": "matrix-appservice-kakaotalk-node", + "version": "0.0.1", + "description": "Node backend for matrix-appservice-kakaotalk", + "repository": { + "type": "git", + "url": "git+https://src.miscworks.net/fair/matrix-appservice-kakaotalk.git" + }, + "engines": { + "node": ">=16.13.0" + }, + "type": "module", + "main": "src/main.js", + "author": "Andrew Ferrazzutti ", + "license": "AGPL-3.0-or-later", + "homepage": "https://src.miscworks.net/fair/matrix-appservice-kakaotalk", + "scripts": { + "start": "node ./src/main.js" + }, + "dependencies": { + "arg": "^4.1.3", + "node-kakao": "4.5.0", + "systemd-daemon": "^1.1.2" + }, + "devDependencies": { + "@types/node": "^17.0.12", + "babel-eslint": "^10.1.0", + "eslint": "^7.7.0", + "eslint-plugin-import": "^2.22.0" + } +} diff --git a/node/src/client.js b/node/src/client.js new file mode 100644 index 0000000..509ea1e --- /dev/null +++ b/node/src/client.js @@ -0,0 +1,460 @@ +// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +// Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { Long } from "bson" +import { emitLines, promisify } from "./util.js" +import { + AuthApiClient, + OAuthApiClient, + ServiceApiClient, + TalkClient, + KnownAuthStatusCode, + util, +} from "node-kakao" +/** @typedef {import("node-kakao").OAuthCredential} OAuthCredential */ +/** @typedef {import("./clientmanager.js").default} ClientManager} */ + + +class UserClient { + + #talkClient = new TalkClient() + get talkClient() { return this.#talkClient } + + /** @type {ServiceApiClient} */ + #serviceClient = null + get serviceClient() { return this.#serviceClient } + + /** + * @param {string} mxid The ID of the associated Matrix user + * @param {OAuthCredential} credential The tokens that API calls may use + */ + constructor(mxid, credential) { + this.mxid = mxid + this.credential = credential + } + + static async create(mxid, credential) { + const userClient = new UserClient(mxid, credential) + userClient.#serviceClient = await ServiceApiClient.create(this.credential) + return userClient + } + + close() { + this.#talkClient.close() + } + + /** + * TODO Maybe use a "write" method instead + * @param {string} command + */ + getCmd(command) { + return `${command}:${this.mxid}` + } +} + +export default class PeerClient { + + /** + * @param {ClientManager} manager + * @param {import("net").Socket} socket + * @param {number} connID + * @param {Map} userClients + */ + constructor(manager, socket, connID) { + this.manager = manager + this.socket = socket + this.connID = connID + this.stopped = false + this.notificationID = 0 + this.maxCommandID = 0 + this.peerID = null + + this.userClients = new Map() + } + + + log(...text) { + if (this.peerID) { + console.log(`[API/${this.peerID}/${this.connID}]`, ...text) + } else { + console.log(`[API/${this.connID}]`, ...text) + } + } + + error(...text) { + if (this.peerID) { + console.error(`[API/${this.peerID}/${this.connID}]`, ...text) + } else { + console.error(`[API/${this.connID}]`, ...text) + } + } + + start() { + this.log("Received connection", this.connID) + emitLines(this.socket) + this.socket.on("line", line => this.handleLine(line) + .catch(err => this.log("Error handling line:", err))) + this.socket.on("end", this.handleEnd) + + setTimeout(() => { + if (!this.peerID && !this.stopped) { + this.log("Didn't receive register request within 3 seconds, terminating") + this.stop("Register request timeout") + } + }, 3000) + } + + async stop(error = null) { + if (this.stopped) { + return + } + this.stopped = true + try { + await this.#write({ id: --this.notificationID, command: "quit", error }) + await promisify(cb => this.socket.end(cb)) + } catch (err) { + this.error("Failed to end connection:", err) + this.socket.destroy(err) + } + } + + handleEnd = async () => { + // TODO Persist clients across bridge disconnections. + // But then have to queue received events until bridge acks them! + this.log("Closing all API clients for", this.peerID) + for (const userClient of this.userClients.values()) { + userClient.close() + } + this.userClients.clear() + + this.stopped = true + if (this.peerID && this.manager.clients.get(this.peerID) === this) { + this.manager.clients.delete(this.peerID) + } + this.log(`Connection closed (peer: ${this.peerID})`) + } + + /** + * Write JSON data to the socket. + * + * @param {object} data - The data to write. + * @returns {Promise} + */ + #write(data) { + return promisify(cb => this.socket.write(JSON.stringify(data) + "\n", cb)) + } + + + /** + * @param {Object} req + * @param {string} req.passcode + * @param {string} req.uuid + * @param {Object} req.form + */ + registerDevice = async (req) => { + const authClient = await this.#createAuthClient(req.uuid) + return await authClient.registerDevice(req.form, req.passcode, true) + } + + /** + * Log in. If this fails due to not having a device, also request a device passcode. + * @param {Object} req + * @param {string} req.uuid + * @param {Object} req.form + * @returns The response of the login attempt, including obtained + * credentials for subsequent token-based login. If a required device passcode + * request failed, its status is stored here. + */ + handleLogin = async (req) => { + const authClient = await this.#createAuthClient(req.uuid) + const loginRes = await authClient.login(req.form, true) + if (loginRes.status === KnownAuthStatusCode.DEVICE_NOT_REGISTERED) { + const passcodeRes = await authClient.requestPasscode(req.form) + if (!passcodeRes.success) { + loginRes.status = passcodeRes.status + } + } + return loginRes + } + + // TODO Wrapper for per-user commands + + /** + * Checked lookup of a UserClient for a given mxid. + * @param {string} mxid + */ + #getUser(mxid) { + /** @type {UserClient} */ + const userClient = this.userClients.get(mxid) + if (userClient === undefined) { + throw new Error(`Could not find user ${mxid}`) + } + return userClient + } + + /** + * @param {Object} req + * @param {OAuthCredential} req.oauth_credential + */ + handleRenew = async (req) => { + const oAuthClient = await OAuthApiClient.create() + return await oAuthClient.renew(req.oauth_credential) + } + + /** + * TODO Consider caching per-user + * @param {string} uuid + */ + async #createAuthClient(uuid) { + return await AuthApiClient.create("KakaoTalk Bridge", uuid) + } + + /** + * @param {Object} req + * @param {string} req.mxid + * @param {OAuthCredential} req.oauth_credential + */ + handleStart = async (req) => { + // TODO Don't re-login if possible. But must still return a LoginResult! + { + const oldUserClient = this.userClients.get(req.mxid) + if (oldUserClient !== undefined) { + oldUserClient.close() + this.userClients.delete(req.mxid) + } + } + + const userClient = await UserClient.create(req.mxid, req.oauth_credential) + const res = await userClient.talkClient.login(req.oauth_credential) + if (!res.success) return res + + // Attach listeners in something like start_listen + /* + userClient.talkClient.on("chat", (data, channel) => { + this.log(`Found message in channel ${channel.channelId}`) + return this.#write({ + id: --this.notificationID, + command: userClient.getCmd("chat"), + //is_sequential: true, // TODO make sequential per user! + chatlog: data.chat(), + channelId: channel.channelId, + }) + }) + + /* + userClient.talkClient.on("chat_read", (chat, channel, reader) => { + this.log(`chat_read in channel ${channel.channelId}`) + //chat.logId + }) + */ + + this.userClients.set(req.mxid, userClient) + return res + } + + /** + * @param {Object} req + * @param {string} req.mxid + * @param {OAuthCredential} req.oauth_credential + */ + getOwnProfile = async (req) => { + const serviceClient = + this.userClients.get(req.mxid)?.serviceClient || + await ServiceApiClient.create(req.oauth_credential) + return await serviceClient.requestMyProfile() + } + + /** + * @param {Object} req + * @param {string} req.mxid + * @param {OAuthCredential} req.oauth_credential + * @param {Long} req.user_id + */ + getProfile = async (req) => { + const serviceClient = + this.userClients.get(mxid)?.serviceClient || + await ServiceApiClient.create(req.oauth_credential) + return await serviceClient.requestProfile(user_id) + } + + /** + * @param {Object} req + * @param {string} req.mxid + * @param {Long} req.channel_id + */ + getPortalChannelInfo = (req) => { + const userClient = this.#getUser(req.mxid) + const talkChannel = userClient.talkClient.channelList.get(req.channel_id) + + /* TODO Decide if this is needed. If it is, make function async! + const res = await talkChannel.updateAll() + if (!res.success) return res + */ + + return this.#makeCommandResult({ + name: talkChannel.getDisplayName(), + //participants: Array.from(talkChannel.getAllUserInfo()), + // TODO Image + }) + } + + #makeCommandResult(result) { + return { + success: true, + status: 0, + result: result + } + } + + /** + * @param {Object} req + * @param {string} req.mxid + */ + handleStop = async (req) => { + this.#getUser(req.mxid).close() + } + + handleUnknownCommand = () => { + throw new Error("Unknown command") + } + + /** + * @param {Object} req + * @param {string} req.peer_id + */ + handleRegister = async (req) => { + this.peerID = req.peer_id + this.log(`Registered socket ${this.connID} -> ${this.peerID}`) + if (this.manager.clients.has(this.peerID)) { + const oldClient = this.manager.clients.get(this.peerID) + this.log(`Terminating previous socket ${oldClient.connID} for ${this.peerID}`) + await oldClient.stop("Socket replaced by new connection") + } + this.manager.clients.set(this.peerID, this) + return { client_exists: this.authClient !== null } + } + + async handleLine(line) { + if (this.stopped) { + this.log("Ignoring line, client is stopped") + return + } + let req + try { + req = JSON.parse(line) + } catch (err) { + this.log("Non-JSON request:", line) + return + } + if (!req.command || !req.id) { + this.log("Invalid request:", line) + return + } + if (req.id <= this.maxCommandID) { + this.log("Ignoring old request", req.id) + return + } + if (req.command != "is_connected") { + this.log("Received request", req.id, "with command", req.command) + } + this.maxCommandID = req.id + let handler + if (!this.peerID) { + if (req.command !== "register") { + this.log("First request wasn't a register request, terminating") + await this.stop("Invalid first request") + return + } else if (!req.peer_id) { + this.log("Register request didn't contain ID, terminating") + await this.stop("Invalid register request") + return + } + handler = this.handleRegister + } else { + handler = { + // TODO Subclass / object for KakaoTalk-specific handlers? + start: this.handleStart, + stop: this.handleStop, + disconnect: () => this.stop(), + login: this.handleLogin, + renew: this.handleRenew, + generate_uuid: util.randomAndroidSubDeviceUUID, + register_device: this.registerDevice, + get_own_profile: this.getOwnProfile, + get_portal_channel_info: this.getPortalChannelInfo, + get_profile: this.getProfile, + /* + send: req => this.puppet.sendMessage(req.chat_id, req.text), + send_file: req => this.puppet.sendFile(req.chat_id, req.file_path), + set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids, req.own_msg_ids, req.rct_ids), + forget_chat: req => this.puppet.forgetChat(req.chat_id), + pause: () => this.puppet.stopObserving(), + resume: () => this.puppet.startObserving(), + get_contacts: () => this.puppet.getContacts(), + get_chats: () => this.puppet.getRecentChats(), + get_chat: req => this.puppet.getChatInfo(req.chat_id, req.force_view), + get_messages: req => this.puppet.getMessages(req.chat_id), + read_image: req => this.puppet.readImage(req.image_url), + */ + //is_connected: async () => ({ is_connected: !await this.puppet.isDisconnected() }), + }[req.command] || this.handleUnknownCommand + } + const resp = { id: req.id } + delete req.id + delete req.command + req = typeify(req) + resp.command = "response" + try { + resp.response = await handler(req) + } catch (err) { + if (err.isAxiosError) { + resp.response = { + success: false, + status: err.response.status, + } + } else { + resp.command = "error" + resp.error = err.toString() + this.log(`Error handling request ${resp.id} ${err}`) + } + } + await this.#write(resp) + } +} + +/** + * Recursively scan an object to check if any of its sub-objects + * should be converted into instances of a specified class. + * @param obj The object to be scanned & updated. + * @returns The converted object. + */ +function typeify(obj) { + if (!(obj instanceof Object)) { + return obj + } + const converterFunc = TYPE_MAP.get(obj.__type__) + if (converterFunc !== undefined) { + return converterFunc(obj) + } + for (const key in obj) { + obj[key] = typeify(obj[key]) + } + return obj +} + +// TODO Add more if needed +const TYPE_MAP = new Map([ + ["Long", (obj) => new Long(obj.low, obj.high, obj.unsigned)], +]) diff --git a/node/src/clientmanager.js b/node/src/clientmanager.js new file mode 100644 index 0000000..c993d26 --- /dev/null +++ b/node/src/clientmanager.js @@ -0,0 +1,94 @@ +// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +// Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import net from "net" +import fs from "fs" +import path from "path" + +import PeerClient from "./client.js" +import { promisify } from "./util.js" + +export default class ClientManager { + constructor(listenConfig) { + this.listenConfig = listenConfig + this.server = net.createServer(this.acceptConnection) + this.connections = [] + this.clients = new Map() + this.connIDSequence = 0 + this.stopped = false + } + + log(...text) { + console.log("[API]", ...text) + } + + acceptConnection = sock => { + if (this.stopped) { + sock.end() + sock.destroy() + } else { + const connID = this.connIDSequence++ + this.connections[connID] = sock + new PeerClient(this, sock, connID).start() + } + } + + async startUnix(socketPath) { + try { + await fs.promises.access(path.dirname(socketPath)) + } catch (err) { + await fs.promises.mkdir(path.dirname(socketPath), 0o700) + } + try { + await fs.promises.unlink(socketPath) + } catch (err) {} + await promisify(cb => this.server.listen(socketPath, cb)) + await fs.promises.chmod(socketPath, 0o700) + this.log("Now listening at", socketPath) + } + + async startTCP(port, host) { + await promisify(cb => this.server.listen(port, host, cb)) + this.log(`Now listening at ${host || ""}:${port}`) + } + + async start() { + this.log("Starting server") + + if (this.listenConfig.type === "unix") { + await this.startUnix(this.listenConfig.path) + } else if (this.listenConfig.type === "tcp") { + await this.startTCP(this.listenConfig.port, this.listenConfig.host) + } + } + + async stop() { + this.stopped = true + for (const client of this.clients.values()) { + await client.stop("Server is shutting down") + } + for (const socket of this.connections) { + socket.end() + socket.destroy() + } + this.log("Stopping server") + await promisify(cb => this.server.close(cb)) + if (this.listenConfig.type === "unix") { + try { + await fs.promises.unlink(this.listenConfig.path) + } catch (err) {} + } + } +} diff --git a/node/src/main.js b/node/src/main.js new file mode 100644 index 0000000..6fcc49a --- /dev/null +++ b/node/src/main.js @@ -0,0 +1,53 @@ +// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +// Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import process from "process" +import fs from "fs" +import sd from "systemd-daemon" + +import arg from "arg" + +import ClientManager from "./clientmanager.js" + +const args = arg({ + "--config": String, + "-c": "--config", +}) + +const configPath = args["--config"] || "config.json" + +console.log("[Main] Reading config from", configPath) +const config = JSON.parse(fs.readFileSync(configPath).toString()) + +const api = new ClientManager(config.listen) + +function stop() { + api.stop().then(() => { + console.log("[Main] Everything stopped") + process.exit(0) + }, err => { + console.error("[Main] Error stopping:", err) + process.exit(3) + }) +} + +api.start().then(() => { + process.once("SIGINT", stop) + process.once("SIGTERM", stop) + sd.notify("READY=1") +}, err => { + console.error("[Main] Error starting:", err) + process.exit(2) +}) diff --git a/node/src/taskqueue.js b/node/src/taskqueue.js new file mode 100644 index 0000000..69ff1fa --- /dev/null +++ b/node/src/taskqueue.js @@ -0,0 +1,97 @@ +// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +// Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +export default class TaskQueue { + constructor(id) { + this.id = id + this._tasks = [] + this.running = false + this._wakeup = null + } + + log(...text) { + console.log(`[TaskQueue/${this.id}]`, ...text) + } + + error(...text) { + console.error(`[TaskQueue/${this.id}]`, ...text) + } + + async _run() { + this.log("Started processing tasks") + while (this.running) { + if (this._tasks.length === 0) { + this.log("Sleeping until a new task is received") + await new Promise(resolve => this._wakeup = () => { + resolve() + this._wakeup = null + }) + if (!this.running) { + break + } + this.log("Continuing processing tasks") + } + const { task, resolve, reject } = this._tasks.shift() + await task().then(resolve, reject) + } + this.log("Stopped processing tasks") + } + + /** + * @callback Task + * @return {Promise} + */ + + /** + * Push a task to the queue. + * + * @param {Task} task - The task to run + * @return {Promise} - A promise that resolves to the return value of the task + */ + push(task) { + if (!this.running) { + throw Error("task queue is not running") + } + if (this._wakeup !== null) { + this._wakeup() + } + return new Promise((resolve, reject) => this._tasks.push({ task, resolve, reject })) + } + + /** + * Start handling tasks + */ + start() { + if (this.running) { + return + } + this.running = true + this._run().catch(err => this.error("Fatal error processing tasks:", err)) + } + + /** + * Stop handling tasks. + */ + stop() { + if (!this.running) { + return + } + this.running = false + if (this._wakeup !== null) { + this._wakeup() + } + } +} diff --git a/node/src/util.js b/node/src/util.js new file mode 100644 index 0000000..be301ad --- /dev/null +++ b/node/src/util.js @@ -0,0 +1,43 @@ +// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge. +// Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +export function promisify(func) { + return new Promise((resolve, reject) => { + try { + func(err => err ? reject(err) : resolve()) + } catch (err) { + reject(err) + } + }) +} + +export function sleep(timeout) { + return new Promise(resolve => setTimeout(resolve, timeout)) +} + +export function emitLines(stream) { + let buffer = "" + stream.on("data", data => { + buffer += data + let n = buffer.indexOf("\n") + while (~n) { + stream.emit("line", buffer.substring(0, n)) + buffer = buffer.substring(n + 1) + n = buffer.indexOf("\n") + } + }) + stream.on("end", () => buffer && stream.emit("line", buffer)) +} diff --git a/optional-requirements.txt b/optional-requirements.txt new file mode 100644 index 0000000..8c73218 --- /dev/null +++ b/optional-requirements.txt @@ -0,0 +1,22 @@ +# Format: #/name defines a new extras_require group called name +# Uncommented lines after the group definition insert things into that group. + +#/animated_stickers +pillow>=4,<10 + +#/e2be +python-olm>=3,<4 +unpaddedbase64>=1,<3 + +#/metrics +prometheus_client>=0.6,<0.14 + +#/proxy +pysocks +aiohttp-socks + +#/weblogin +setuptools + +#/sqlite +aiosqlite>=0.16,<0.18 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8ea98d6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +aiohttp>=3,<4 +asyncpg>=0.20,<0.26 +bson>=0.5,<0.6 +commonmark>=0.8,<0.10 +mautrix==0.15.0rc4 +pycryptodome>=3,<4 +python-magic>=0.4,<0.5 +ruamel.yaml>=0.15.94,<0.18 +yarl>=1,<2 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..77de715 --- /dev/null +++ b/setup.py @@ -0,0 +1,71 @@ +import setuptools + +from matrix_appservice_kakaotalk.get_version import git_tag, git_revision, version, linkified_version + +try: + long_desc = open("README.md").read() +except IOError: + long_desc = "Failed to read README.md" + +with open("requirements.txt") as reqs: + install_requires = reqs.read().splitlines() + +with open("optional-requirements.txt") as reqs: + extras_require = {} + current = [] + for line in reqs.read().splitlines(): + if line.startswith("#/"): + extras_require[line[2:]] = current = [] + elif not line or line.startswith("#"): + continue + else: + current.append(line) + +extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps}) + +with open("matrix_appservice_kakaotalk/version.py", "w") as version_file: + version_file.write(f"""# Generated in setup.py + +git_tag = {git_tag!r} +git_revision = {git_revision!r} +version = {version!r} +linkified_version = {linkified_version!r} +""") + +setuptools.setup( + name="matrix-appservice-kakaotalk", + version=version, + url="https://src.miscworks.net/fair/matrix-appservice-kakaotalk", + + author="Andrew Ferrazzutti", + author_email="fair@miscworks.net", + + description="A Matrix-KakaoTalk puppeting bridge.", + long_description=long_desc, + long_description_content_type="text/markdown", + + packages=setuptools.find_packages(), + + install_requires=install_requires, + extras_require=extras_require, + python_requires="~=3.7", + + classifiers=[ + "Development Status :: 1 - Planning", + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", + "Topic :: Communications :: Chat", + "Framework :: AsyncIO", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + ], + package_data={ + "matrix_appservice_kakaotalk": ["example-config.yaml"], + "matrix_appservice_kakaotalk.web": ["static/*", "static/**/*"], + }, + data_files=[ + (".", ["matrix_appservice_kakaotalk/example-config.yaml"]), + ], +)