Initial commit

This commit is contained in:
Andrew Ferrazzutti 2022-02-25 02:22:50 -05:00
commit b57c74eeb6
97 changed files with 13474 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -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

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU 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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,2 @@
__version__ = "0.0.1"
__author__ = "Andrew Ferrazzutti <fair@miscworks.net>"

View File

@ -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 <https://www.gnu.org/licenses/>.
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()

View File

@ -0,0 +1,2 @@
from .auth import SECTION_AUTH#, enter_2fa_code
from .conn import SECTION_CONNECTION

View File

@ -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 <https://www.gnu.org/licenses/>.
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 <email>` 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")

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
"""

View File

@ -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 <https://www.gnu.org/licenses/>.
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, "")

View File

@ -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"

View File

@ -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 <https://www.gnu.org/licenses/>.
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("*")

View File

@ -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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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)

View File

@ -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 <https://www.gnu.org/licenses/>.
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)

View File

@ -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 <https://www.gnu.org/licenses/>.
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)

View File

@ -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 <https://www.gnu.org/licenses/>.
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)

View File

@ -0,0 +1,5 @@
from mautrix.util.async_db import UpgradeTable
upgrade_table = UpgradeTable()
from . import v01_initial_revision

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
)"""
)

View File

@ -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 <https://www.gnu.org/licenses/>.
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
)

View File

@ -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: '<b>$sender_displayname</b>: $message'
m.notice: '<b>$sender_displayname<b>: $message'
m.emote: '* <b>$sender_displayname<b> $message'
m.file: '<b>$sender_displayname</b> sent a file'
m.image: '<b>$sender_displayname</b> sent an image'
m.audio: '<b>$sender_displayname</b> sent an audio file'
m.video: '<b>$sender_displayname</b> sent a video'
m.location: '<b>$sender_displayname</b> 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]

View File

@ -0,0 +1,2 @@
from .from_kakaotalk import kakaotalk_to_matrix
from .from_matrix import matrix_to_kakaotalk

View File

@ -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 <https://www.gnu.org/licenses/>.
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}</{tag}>{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("&gt; "):
line = line[len("&gt; ") :]
output.append("<blockquote>")
blockquote = True
elif blockquote:
if line.startswith("&gt;"):
line = line[len("&gt;") :]
if line.startswith(" "):
line = line[1:]
else:
output.append("</blockquote>")
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("</code></pre>")
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'<pre><code class="language-{cb_lang}">')
else:
output.append("<pre><code>")
if cb_content:
output.append(cb_content)
output.append("</code></pre>")
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("<br/>")
_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'<a href="https://matrix.to/#/{mxid}">{match.group(2)}</a>'
html = MENTION_REGEX.sub(_mention_replacer, html)
if html != escape(content.body).replace("\n", "<br/>\n"):
content.format = Format.HTML
content.formatted_body = html
return content

View File

@ -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 <https://www.gnu.org/licenses/>.
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)

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""Utilities for interacting with KakaoTalk."""

View File

@ -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 <https://www.gnu.org/licenses/>.
"""Wrappers around the KakaoTalk API."""
from .client import Client

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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)

View File

@ -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 <https://www.gnu.org/licenses/>.
"""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,
}

View File

@ -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 <https://www.gnu.org/licenses/>.
"""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",
}

View File

@ -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 <https://www.gnu.org/licenses/>.
"""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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""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
"""

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
from .auth_api_client import *
"""

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
from .profile import *
"""

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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)

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
from .channel_info import *
from .channel_type import *
from .channel import *
from .meta import *
"""

View File

@ -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 <https://www.gnu.org/licenses/>.
from attr import dataclass
from mautrix.types import SerializableAttrs
from ..bson import Long
@dataclass
class Channel(SerializableAttrs):
channelId: Long
__all__ = [
"Channel",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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<ChannelMetaType, SetChannelMeta>
@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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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<LiveTalkInfoOffMetaContent>"""
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
from .chat_type import *
from .chat import *
"""

View File

@ -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 <https://www.gnu.org/licenses/>.
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<T extends ChatType> = Chat & ChatTypeComponent<T>"""
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<T extends ChatType> = Chatlog & TypedChat<T>"""
pass
@dataclass(kw_only=True)
class ChatOptions(SerializableAttrs):
shout: bool | None = None
__all__ = [
"ChatTypeComponent",
"Chat",
"TypedChat",
"ChatLogged",
"ChatLoggedType",
"ChatLogLinked",
"ChatWritten",
"Chatlog",
"TypedChatlog",
"ChatOptions",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
from attr import dataclass
from ..channel.channel import Channel
from . import OpenLinkComponent
@dataclass
class OpenChannel(Channel, OpenLinkComponent):
pass
__all__ = [
"OpenChannel",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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<T>. 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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
from .channel_user_info import *
from .channel_user import *
from .user_type import *
"""

View File

@ -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 <https://www.gnu.org/licenses/>.
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<OpenLinkComponent>"""
linkId: Long | None = None
@dataclass
class OpenChannelUser(ChannelUser, PartialOpenLinkComponent):
pass
__all__ = [
"ChannelUser",
"PartialOpenLinkComponent",
"OpenChannelUser",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
from enum import IntEnum
class UserType(IntEnum):
UNDEFINED = -999999
NOT_FRIEND = -100
DEACTIVATED = 9
FRIEND = 100
OPEN_PROFILE = 1000
__all__ = [
"UserType",
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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"<a href='https://matrix.to/#/{portal.mxid}'>Link to room</a>"
),
)
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)

View File

@ -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 <https://www.gnu.org/licenses/>.
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 <TODO: ID>"
)
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -0,0 +1 @@
from .rpc import RPCClient

View File

@ -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 <https://www.gnu.org/licenses/>.
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 "<REDACTED>")
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""Types used by and for the Node RPC backend."""
class RPCError(Exception):
pass

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -0,0 +1 @@
from .color_log import ColorFormatter

View File

@ -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 <https://www.gnu.org/licenses/>.
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)

View File

@ -0,0 +1,7 @@
import random
def get_interval(value):
if isinstance(value, list):
return random.randint(*value)
return value

View File

@ -0,0 +1 @@
from .get_version import git_revision, git_tag, linkified_version, version

View File

@ -0,0 +1 @@
from .public import PublicBridgeWebsite

View File

@ -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 <https://www.gnu.org/licenses/>.
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"