Initial commit
This commit is contained in:
commit
b57c74eeb6
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
661
LICENSE
Normal 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/>.
|
2
matrix_appservice_kakaotalk/__init__.py
Normal file
2
matrix_appservice_kakaotalk/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
__version__ = "0.0.1"
|
||||
__author__ = "Andrew Ferrazzutti <fair@miscworks.net>"
|
129
matrix_appservice_kakaotalk/__main__.py
Normal file
129
matrix_appservice_kakaotalk/__main__.py
Normal 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()
|
2
matrix_appservice_kakaotalk/commands/__init__.py
Normal file
2
matrix_appservice_kakaotalk/commands/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .auth import SECTION_AUTH#, enter_2fa_code
|
||||
from .conn import SECTION_CONNECTION
|
165
matrix_appservice_kakaotalk/commands/auth.py
Normal file
165
matrix_appservice_kakaotalk/commands/auth.py
Normal 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")
|
101
matrix_appservice_kakaotalk/commands/conn.py
Normal file
101
matrix_appservice_kakaotalk/commands/conn.py
Normal 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)
|
||||
"""
|
26
matrix_appservice_kakaotalk/commands/handler.py
Normal file
26
matrix_appservice_kakaotalk/commands/handler.py
Normal 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, "")
|
12
matrix_appservice_kakaotalk/commands/typehint.py
Normal file
12
matrix_appservice_kakaotalk/commands/typehint.py
Normal 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"
|
163
matrix_appservice_kakaotalk/config.py
Normal file
163
matrix_appservice_kakaotalk/config.py
Normal 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("*")
|
25
matrix_appservice_kakaotalk/db/__init__.py
Normal file
25
matrix_appservice_kakaotalk/db/__init__.py
Normal 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",
|
||||
]
|
160
matrix_appservice_kakaotalk/db/message.py
Normal file
160
matrix_appservice_kakaotalk/db/message.py
Normal 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)
|
133
matrix_appservice_kakaotalk/db/portal.py
Normal file
133
matrix_appservice_kakaotalk/db/portal.py
Normal 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)
|
135
matrix_appservice_kakaotalk/db/puppet.py
Normal file
135
matrix_appservice_kakaotalk/db/puppet.py
Normal 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)
|
91
matrix_appservice_kakaotalk/db/reaction.py
Normal file
91
matrix_appservice_kakaotalk/db/reaction.py
Normal 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)
|
5
matrix_appservice_kakaotalk/db/upgrade/__init__.py
Normal file
5
matrix_appservice_kakaotalk/db/upgrade/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from mautrix.util.async_db import UpgradeTable
|
||||
|
||||
upgrade_table = UpgradeTable()
|
||||
|
||||
from . import v01_initial_revision
|
@ -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)
|
||||
)"""
|
||||
)
|
97
matrix_appservice_kakaotalk/db/user.py
Normal file
97
matrix_appservice_kakaotalk/db/user.py
Normal 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
|
||||
)
|
322
matrix_appservice_kakaotalk/example-config.yaml
Normal file
322
matrix_appservice_kakaotalk/example-config.yaml
Normal 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]
|
2
matrix_appservice_kakaotalk/formatter/__init__.py
Normal file
2
matrix_appservice_kakaotalk/formatter/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .from_kakaotalk import kakaotalk_to_matrix
|
||||
from .from_matrix import matrix_to_kakaotalk
|
170
matrix_appservice_kakaotalk/formatter/from_kakaotalk.py
Normal file
170
matrix_appservice_kakaotalk/formatter/from_kakaotalk.py
Normal 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("> "):
|
||||
line = line[len("> ") :]
|
||||
output.append("<blockquote>")
|
||||
blockquote = True
|
||||
elif blockquote:
|
||||
if line.startswith(">"):
|
||||
line = line[len(">") :]
|
||||
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
|
120
matrix_appservice_kakaotalk/formatter/from_matrix.py
Normal file
120
matrix_appservice_kakaotalk/formatter/from_matrix.py
Normal 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)
|
49
matrix_appservice_kakaotalk/get_version.py
Normal file
49
matrix_appservice_kakaotalk/get_version.py
Normal 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
|
16
matrix_appservice_kakaotalk/kt/__init__.py
Normal file
16
matrix_appservice_kakaotalk/kt/__init__.py
Normal 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."""
|
18
matrix_appservice_kakaotalk/kt/client/__init__.py
Normal file
18
matrix_appservice_kakaotalk/kt/client/__init__.py
Normal 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
|
289
matrix_appservice_kakaotalk/kt/client/client.py
Normal file
289
matrix_appservice_kakaotalk/kt/client/client.py
Normal 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)
|
125
matrix_appservice_kakaotalk/kt/client/error_helper.py
Normal file
125
matrix_appservice_kakaotalk/kt/client/error_helper.py
Normal 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,
|
||||
}
|
160
matrix_appservice_kakaotalk/kt/client/errors.py
Normal file
160
matrix_appservice_kakaotalk/kt/client/errors.py
Normal 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",
|
||||
}
|
35
matrix_appservice_kakaotalk/kt/client/types.py
Normal file
35
matrix_appservice_kakaotalk/kt/client/types.py
Normal 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
|
29
matrix_appservice_kakaotalk/kt/types/__init__.py
Normal file
29
matrix_appservice_kakaotalk/kt/types/__init__.py
Normal 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
|
||||
"""
|
18
matrix_appservice_kakaotalk/kt/types/api/__init__.py
Normal file
18
matrix_appservice_kakaotalk/kt/types/api/__init__.py
Normal 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 *
|
||||
"""
|
101
matrix_appservice_kakaotalk/kt/types/api/auth_api_client.py
Normal file
101
matrix_appservice_kakaotalk/kt/types/api/auth_api_client.py
Normal 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",
|
||||
]
|
18
matrix_appservice_kakaotalk/kt/types/api/struct/__init__.py
Normal file
18
matrix_appservice_kakaotalk/kt/types/api/struct/__init__.py
Normal 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 *
|
||||
"""
|
123
matrix_appservice_kakaotalk/kt/types/api/struct/profile.py
Normal file
123
matrix_appservice_kakaotalk/kt/types/api/struct/profile.py
Normal 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",
|
||||
]
|
34
matrix_appservice_kakaotalk/kt/types/attachment/__init__.py
Normal file
34
matrix_appservice_kakaotalk/kt/types/attachment/__init__.py
Normal 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
|
27
matrix_appservice_kakaotalk/kt/types/attachment/mention.py
Normal file
27
matrix_appservice_kakaotalk/kt/types/attachment/mention.py
Normal 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
|
60
matrix_appservice_kakaotalk/kt/types/bson.py
Normal file
60
matrix_appservice_kakaotalk/kt/types/bson.py
Normal 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)
|
21
matrix_appservice_kakaotalk/kt/types/channel/__init__.py
Normal file
21
matrix_appservice_kakaotalk/kt/types/channel/__init__.py
Normal 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 *
|
||||
"""
|
30
matrix_appservice_kakaotalk/kt/types/channel/channel.py
Normal file
30
matrix_appservice_kakaotalk/kt/types/channel/channel.py
Normal 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",
|
||||
]
|
93
matrix_appservice_kakaotalk/kt/types/channel/channel_info.py
Normal file
93
matrix_appservice_kakaotalk/kt/types/channel/channel_info.py
Normal 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",
|
||||
]
|
39
matrix_appservice_kakaotalk/kt/types/channel/channel_type.py
Normal file
39
matrix_appservice_kakaotalk/kt/types/channel/channel_type.py
Normal 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",
|
||||
]
|
175
matrix_appservice_kakaotalk/kt/types/channel/meta.py
Normal file
175
matrix_appservice_kakaotalk/kt/types/channel/meta.py
Normal 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",
|
||||
]
|
19
matrix_appservice_kakaotalk/kt/types/chat/__init__.py
Normal file
19
matrix_appservice_kakaotalk/kt/types/chat/__init__.py
Normal 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 *
|
||||
"""
|
90
matrix_appservice_kakaotalk/kt/types/chat/chat.py
Normal file
90
matrix_appservice_kakaotalk/kt/types/chat/chat.py
Normal 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",
|
||||
]
|
67
matrix_appservice_kakaotalk/kt/types/chat/chat_type.py
Normal file
67
matrix_appservice_kakaotalk/kt/types/chat/chat_type.py
Normal 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",
|
||||
]
|
@ -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",
|
||||
]
|
41
matrix_appservice_kakaotalk/kt/types/oauth.py
Normal file
41
matrix_appservice_kakaotalk/kt/types/oauth.py
Normal 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",
|
||||
]
|
146
matrix_appservice_kakaotalk/kt/types/openlink/__init__.py
Normal file
146
matrix_appservice_kakaotalk/kt/types/openlink/__init__.py
Normal 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",
|
||||
]
|
@ -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",
|
||||
]
|
@ -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",
|
||||
]
|
@ -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",
|
||||
]
|
@ -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",
|
||||
]
|
114
matrix_appservice_kakaotalk/kt/types/request.py
Normal file
114
matrix_appservice_kakaotalk/kt/types/request.py
Normal 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",
|
||||
]
|
20
matrix_appservice_kakaotalk/kt/types/user/__init__.py
Normal file
20
matrix_appservice_kakaotalk/kt/types/user/__init__.py
Normal 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 *
|
||||
"""
|
43
matrix_appservice_kakaotalk/kt/types/user/channel_user.py
Normal file
43
matrix_appservice_kakaotalk/kt/types/user/channel_user.py
Normal 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",
|
||||
]
|
@ -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",
|
||||
]
|
29
matrix_appservice_kakaotalk/kt/types/user/user_type.py
Normal file
29
matrix_appservice_kakaotalk/kt/types/user/user_type.py
Normal 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",
|
||||
]
|
265
matrix_appservice_kakaotalk/matrix.py
Normal file
265
matrix_appservice_kakaotalk/matrix.py
Normal 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)
|
861
matrix_appservice_kakaotalk/portal.py
Normal file
861
matrix_appservice_kakaotalk/portal.py
Normal 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
|
280
matrix_appservice_kakaotalk/puppet.py
Normal file
280
matrix_appservice_kakaotalk/puppet.py
Normal 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
|
1
matrix_appservice_kakaotalk/rpc/__init__.py
Normal file
1
matrix_appservice_kakaotalk/rpc/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .rpc import RPCClient
|
203
matrix_appservice_kakaotalk/rpc/rpc.py
Normal file
203
matrix_appservice_kakaotalk/rpc/rpc.py
Normal 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
|
20
matrix_appservice_kakaotalk/rpc/types.py
Normal file
20
matrix_appservice_kakaotalk/rpc/types.py
Normal 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
|
604
matrix_appservice_kakaotalk/user.py
Normal file
604
matrix_appservice_kakaotalk/user.py
Normal 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
|
1
matrix_appservice_kakaotalk/util/__init__.py
Normal file
1
matrix_appservice_kakaotalk/util/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .color_log import ColorFormatter
|
25
matrix_appservice_kakaotalk/util/color_log.py
Normal file
25
matrix_appservice_kakaotalk/util/color_log.py
Normal 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)
|
7
matrix_appservice_kakaotalk/util/interval.py
Normal file
7
matrix_appservice_kakaotalk/util/interval.py
Normal file
@ -0,0 +1,7 @@
|
||||
import random
|
||||
|
||||
|
||||
def get_interval(value):
|
||||
if isinstance(value, list):
|
||||
return random.randint(*value)
|
||||
return value
|
1
matrix_appservice_kakaotalk/version.py
Normal file
1
matrix_appservice_kakaotalk/version.py
Normal file
@ -0,0 +1 @@
|
||||
from .get_version import git_revision, git_tag, linkified_version, version
|
1
matrix_appservice_kakaotalk/web/__init__.py
Normal file
1
matrix_appservice_kakaotalk/web/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .public import PublicBridgeWebsite
|
380
matrix_appservice_kakaotalk/web/public.py
Normal file
380
matrix_appservice_kakaotalk/web/public.py
Normal 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"
|
||||