diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..23985d8
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,21 @@
+FROM golang:1-alpine3.14 AS builder
+
+RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
+
+COPY . /build
+WORKDIR /build
+RUN go build -o /usr/bin/matrix-skype
+
+FROM alpine:3.14
+
+ENV UID=1337 \
+ GID=1337
+
+RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl
+
+COPY --from=builder /usr/bin/matrix-skype /usr/bin/matrix-skype
+COPY --from=builder /build/example-config.yaml /opt/matrix-skype/example-config.yaml
+COPY --from=builder /build/docker-run.sh /docker-run.sh
+VOLUME /data
+
+CMD ["/docker-run.sh"]
diff --git a/Dockerfile.ci b/Dockerfile.ci
new file mode 100644
index 0000000..8445754
--- /dev/null
+++ b/Dockerfile.ci
@@ -0,0 +1,15 @@
+FROM alpine:latest
+
+ENV UID=1337 \
+ GID=1337
+
+RUN echo "@edge_community http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories
+RUN apk add --no-cache su-exec ca-certificates olm@edge_community
+
+ARG EXECUTABLE=./matrix-skype
+COPY $EXECUTABLE /usr/bin/matrix-skype
+COPY ./example-config.yaml /opt/matrix-skype/example-config.yaml
+COPY ./docker-run.sh /docker-run.sh
+VOLUME /data
+
+CMD ["/docker-run.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
index e69de29..c5f58aa 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,31 @@
+# matrix-skype
+
+matrix-skype is a library for bridging matrix and skype, about matrix, please refer to [matrix.org](http://matrix.org/).
+
+## functions are available
+`The following functions are available in both directions without special instructions)`
+
+* create private conversation
+* create group conversation
+* private conversation
+* group conversation
+* kick/invite/leave/join(group)
+* generate invitation link(group)
+* quote message(Circular references may have some bugs)
+* mention someone(message)
+* media message
+* picture message
+* group avatar/name change
+* user name/avatar change
+* Typing status
+
+The skype api lib of matrix-skype is [go-skypeapi](https://github.com/kelaresg/go-skypeapi).
+[matrix room(#goskypebridge:matrix.org)](https://app.element.io/#/room/#goskypebridge:matrix.org)
+
+This matrix-skype bridge is based on [mautrix-whatsapp](https://github.com/tulir/mautrix-whatsapp),so the installation and usage methods are very similar to mautrix-whatsapp
+
+> # mautrix-whatsapp
+> A Matrix-WhatsApp puppeting bridge based on the [Rhymen/go-whatsapp](https://github.com/Rhymen/go-whatsapp)
+> implementation of the [sigalor/whatsapp-web-reveng](https://github.com/sigalor/whatsapp-web-reveng) project.
+
+> ### [Wiki](https://github.com/tulir/mautrix-whatsapp/wiki)
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..2409c5b
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@"
diff --git a/commands-special.go b/commands-special.go
new file mode 100644
index 0000000..ef76e2f
--- /dev/null
+++ b/commands-special.go
@@ -0,0 +1,96 @@
+// 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 .
+
+package main
+
+import (
+ skype "github.com/kelaresg/go-skypeapi"
+ "strings"
+ "time"
+)
+
+func (handler *CommandHandler) CommandSpecialMux(ce *CommandEvent) {
+ switch ce.Command {
+ case "special-create":
+ if !ce.User.HasSession() {
+ ce.Reply("You are not logged in. Use the `login` command to log into Skype.")
+ return
+ }
+ switch ce.Command {
+ case "special-create":
+ handler.CommandSpecialCreate(ce)
+ }
+ default:
+ ce.Reply("Unknown Command")
+ }
+}
+
+func (handler *CommandHandler) CommandSpecialHelp(ce *CommandEvent) {
+ cmdPrefix := ""
+ if ce.User.ManagementRoom != ce.RoomID || ce.User.IsRelaybot {
+ cmdPrefix = handler.bridge.Config.Bridge.CommandPrefix + " "
+ }
+
+ ce.Reply("* " + strings.Join([]string{
+ cmdPrefix + cmdSpecialCreateHelp,
+ }, "\n* "))
+}
+
+const cmdSpecialCreateHelp = `special-create <_topic_> <_member user id_>,... - Create a group.`
+
+func (handler *CommandHandler) CommandSpecialCreate(ce *CommandEvent) {
+ if len(ce.Args) < 2 {
+ ce.Reply("**Usage:** `special-create ,...`")
+ return
+ }
+
+ user := ce.User
+ topic := ce.Args[0]
+ members := skype.Members{}
+
+ // The user who created the group must be in the members and have "Admin" rights
+ userId := ce.User.Conn.UserProfile.Username
+ member2 := skype.Member{
+ Id: "8:" + userId,
+ Role: "Admin",
+ }
+
+ members.Members = append(members.Members, member2)
+ members.Properties = skype.Properties{
+ HistoryDisclosed: "true",
+ Topic: topic,
+ }
+
+ handler.log.Debugln("Create Group", topic, "with", members)
+ err := user.Conn.HandleGroupCreate(members)
+ inputArr := strings.Split(ce.Args[1], ",")
+ members = skype.Members{}
+ for _, memberId := range inputArr {
+ members.Members = append(members.Members, skype.Member{
+ Id: memberId,
+ Role: "Admin",
+ })
+ }
+ conversationId, ok := <-user.Conn.CreateChan
+ if ok {
+ err = user.Conn.AddMember(members, conversationId)
+ }
+ if err != nil {
+ ce.Reply("Please confirm that parameters is correct.")
+ } else {
+ ce.Reply("Syncing group list...")
+ time.Sleep(time.Duration(3) * time.Second)
+ ce.Reply("Syncing group list completed")
+ }
+}
+
diff --git a/commands.go b/commands.go
new file mode 100644
index 0000000..5ab2e97
--- /dev/null
+++ b/commands.go
@@ -0,0 +1,1256 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ skype "github.com/kelaresg/go-skypeapi"
+ "github.com/kelaresg/matrix-skype/database"
+ skypeExt "github.com/kelaresg/matrix-skype/skype-ext"
+ "math"
+ "maunium.net/go/mautrix/patch"
+
+ "sort"
+ "strconv"
+ "strings"
+
+ "maunium.net/go/maulogger/v2"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/appservice"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/format"
+ "maunium.net/go/mautrix/id"
+)
+
+type CommandHandler struct {
+ bridge *Bridge
+ log maulogger.Logger
+}
+
+// NewCommandHandler creates a CommandHandler
+func NewCommandHandler(bridge *Bridge) *CommandHandler {
+ return &CommandHandler{
+ bridge: bridge,
+ log: bridge.Log.Sub("Command handler"),
+ }
+}
+
+// CommandEvent stores all data which might be used to handle commands
+type CommandEvent struct {
+ Bot *appservice.IntentAPI
+ Bridge *Bridge
+ Portal *Portal
+ Handler *CommandHandler
+ RoomID id.RoomID
+ User *User
+ Command string
+ Args []string
+}
+
+// Reply sends a reply to command as notice
+func (ce *CommandEvent) Reply(msg string, args ...interface{}) {
+ content := format.RenderMarkdown(fmt.Sprintf(msg, args...), true, false)
+ content.MsgType = event.MsgNotice
+ intent := ce.Bot
+ if ce.Portal != nil && ce.Portal.IsPrivateChat() {
+ intent = ce.Portal.MainIntent()
+ }
+ _, err := intent.SendMessageEvent(ce.RoomID, event.EventMessage, content)
+ if err != nil {
+ ce.Handler.log.Warnfln("Failed to reply to command from %s: %v", ce.User.MXID, err)
+ }
+}
+
+// Handle handles messages to the bridge
+func (handler *CommandHandler) Handle(roomID id.RoomID, user *User, message string) {
+ args := strings.Fields(message)
+ ce := &CommandEvent{
+ Bot: handler.bridge.Bot,
+ Bridge: handler.bridge,
+ Handler: handler,
+ RoomID: roomID,
+ User: user,
+ Command: strings.ToLower(args[0]),
+ Args: args[1:],
+ }
+ if ce.Command == "login" {
+ message = ""
+ }
+ handler.log.Debugfln("%s sent '%s' in %s", user.MXID, message, roomID)
+ if roomID == handler.bridge.Config.Bridge.Relaybot.ManagementRoom {
+ handler.CommandRelaybot(ce)
+ } else {
+ handler.CommandMux(ce)
+ }
+}
+
+func (handler *CommandHandler) CommandMux(ce *CommandEvent) {
+ switch ce.Command {
+ case "relaybot":
+ handler.CommandRelaybot(ce)
+ case "login":
+ handler.CommandLogin(ce)
+ //case "logout-matrix":
+ // handler.CommandLogoutMatrix(ce)
+ case "help":
+ handler.CommandHelp(ce)
+ //case "version":
+ // handler.CommandVersion(ce)
+ //case "reconnect":
+ // handler.CommandReconnect(ce)
+ //case "disconnect":
+ // handler.CommandDisconnect(ce)
+ case "ping":
+ handler.CommandPing(ce)
+ //case "delete-connection":
+ // handler.CommandDeleteConnection(ce)
+ //case "delete-session":
+ // handler.CommandDeleteSession(ce)
+ //case "delete-portal":
+ // handler.CommandDeletePortal(ce)
+ //case "delete-all-portals":
+ // handler.CommandDeleteAllPortals(ce)
+ //case "dev-test":
+ // handler.CommandDevTest(ce)
+ //case "set-pl":
+ // handler.CommandSetPowerLevel(ce)
+ case "logout":
+ handler.CommandLogout(ce)
+ case "login-matrix", "sync", "list", "open", "pm", "invite", "kick", "leave", "join", "create", "share":
+ if !ce.User.HasSession() {
+ ce.Reply("You're not logged in. Use the `login` command to log into Skype.")
+ return
+ }
+
+ switch ce.Command {
+ //case "login-matrix":
+ // handler.CommandLoginMatrix(ce)
+ case "sync":
+ handler.CommandSync(ce)
+ case "list":
+ handler.CommandList(ce)
+ case "open":
+ handler.CommandOpen(ce)
+ case "pm":
+ handler.CommandPM(ce)
+ case "invite":
+ handler.CommandInvite(ce)
+ case "kick":
+ handler.CommandKick(ce)
+ case "leave":
+ handler.CommandLeave(ce)
+ case "join":
+ handler.CommandJoin(ce)
+ case "share":
+ handler.CommandShare(ce)
+ case "create":
+ handler.CommandCreate(ce)
+ }
+ default:
+ handler.CommandSpecialMux(ce)
+ }
+}
+
+func (handler *CommandHandler) CommandRelaybot(ce *CommandEvent) {
+ if handler.bridge.Relaybot == nil {
+ ce.Reply("The relaybot is disabled")
+ } else if !ce.User.Admin {
+ ce.Reply("Only admins can manage the relaybot")
+ } else {
+ if ce.Command == "relaybot" {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage:** `relaybot `")
+ return
+ }
+ ce.Command = strings.ToLower(ce.Args[0])
+ ce.Args = ce.Args[1:]
+ }
+ ce.User = handler.bridge.Relaybot
+ handler.CommandMux(ce)
+ }
+}
+
+func (handler *CommandHandler) CommandDevTest(_ *CommandEvent) {
+
+}
+
+const cmdVersionHelp = `version - View the bridge version`
+
+func (handler *CommandHandler) CommandVersion(ce *CommandEvent) {
+ version := fmt.Sprintf("v%s.unknown", Version)
+ if Tag == Version {
+ version = fmt.Sprintf("[v%s](%s/releases/v%s) (%s)", Version, URL, Tag, BuildTime)
+ } else if len(Commit) > 8 {
+ version = fmt.Sprintf("v%s.[%s](%s/commit/%s) (%s)", Version, Commit[:8], URL, Commit, BuildTime)
+ }
+ ce.Reply(fmt.Sprintf("[%s](%s) %s", Name, URL, version))
+}
+
+const cmdSetPowerLevelHelp = `set-pl [user ID] - Change the power level in a portal room. Only for bridge admins.`
+
+func (handler *CommandHandler) CommandSetPowerLevel(ce *CommandEvent) {
+ portal := ce.Bridge.GetPortalByMXID(ce.RoomID)
+ if portal == nil {
+ ce.Reply("Not a portal room")
+ return
+ }
+ var level int
+ var userID id.UserID
+ var err error
+ if len(ce.Args) == 1 {
+ level, err = strconv.Atoi(ce.Args[0])
+ if err != nil {
+ ce.Reply("Invalid power level \"%s\"", ce.Args[0])
+ return
+ }
+ userID = ce.User.MXID
+ } else if len(ce.Args) == 2 {
+ userID = id.UserID(ce.Args[0])
+ _, _, err := userID.Parse()
+ if err != nil {
+ ce.Reply("Invalid user ID \"%s\"", ce.Args[0])
+ return
+ }
+ level, err = strconv.Atoi(ce.Args[1])
+ if err != nil {
+ ce.Reply("Invalid power level \"%s\"", ce.Args[1])
+ return
+ }
+ } else {
+ ce.Reply("**Usage:** `set-pl [user] `")
+ return
+ }
+ intent := portal.MainIntent()
+ _, err = intent.SetPowerLevel(ce.RoomID, userID, level)
+ if err != nil {
+ ce.Reply("Failed to set power levels: %v", err)
+ }
+}
+
+const cmdLoginHelp = `login - login <_username_> <_password_>`
+
+// CommandLogin handles login command
+func (handler *CommandHandler) CommandLogin(ce *CommandEvent) {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage:** `login username password`")
+ return
+ }
+ leavePortals(ce)
+ if !ce.User.Connect(true) {
+ ce.User.log.Debugln("Connect() returned false, assuming error was logged elsewhere and canceling login.")
+ return
+ }
+ err := ce.User.Login(ce, ce.Args[0], ce.Args[1])
+ if err == nil {
+ syncAll(ce.User, true)
+ }
+}
+
+const cmdLogoutHelp = `logout - Logout from Skype`
+
+func (handler *CommandHandler) CommandLogout(ce *CommandEvent) {
+ if ce.User.Conn == nil {
+ ce.Reply("You're not logged into Skype.")
+ return
+ }
+ //_ = ce.User.Conn.GetConversations("", ce.User.bridge.Config.Bridge.InitialChatSync)
+ ce.User.Conn.LoggedIn = false
+ username := ""
+ password := ""
+ if ce.User.Conn.LoginInfo != nil {
+ username = ce.User.Conn.LoginInfo.Username
+ password = ce.User.Conn.LoginInfo.Password
+ }
+ ce.User.Conn.LoginInfo = &skype.Session{
+ SkypeToken: "",
+ SkypeExpires: "",
+ RegistrationToken: "",
+ RegistrationTokenStr: "",
+ RegistrationExpires: "",
+ LocationHost: "",
+ EndpointId: "",
+ Username: username,
+ Password: password,
+ }
+
+ ce.User.Conn.Store = &skype.Store{
+ Contacts: make(map[string]skype.Contact),
+ Chats: make(map[string]skype.Conversation),
+ }
+ ce.Reply("Logged out successfully.")
+ if ce.User.Conn.Refresh != nil {
+ ce.User.Conn.Refresh <- -1
+ } else {
+ leavePortals(ce)
+ }
+}
+
+func leavePortals(ce *CommandEvent) {
+ portals := ce.User.GetPortals()
+ //newPortals := ce.User.GetPortalsNew()
+ //allPortals := newPortals[0:]
+ //for _, portal := range portals {
+ // var newPortalsHas bool
+ // for _, newPortal := range newPortals {
+ // if portal.Key == newPortal.Key {
+ // newPortalsHas = true
+ // }
+ // }
+ // if !newPortalsHas {
+ // allPortals = append(allPortals, portal)
+ // }
+ //}
+
+ leave := func(portal *Portal) {
+ if len(portal.MXID) > 0 {
+ _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{
+ Reason: "Logout",
+ UserID: ce.User.MXID,
+ })
+ }
+ }
+ for _, portal := range portals {
+ leave(portal)
+ }
+}
+
+const cmdDeleteSessionHelp = `delete-session - Delete session information and disconnect from WhatsApp without sending a logout request`
+
+//func (handler *CommandHandler) CommandDeleteSession(ce *CommandEvent) {
+// if ce.User.Session == nil && ce.User.Conn == nil {
+// ce.Reply("Nothing to purge: no session information stored and no active connection.")
+// return
+// }
+// ce.User.SetSession(nil)
+// if ce.User.Conn != nil {
+// _, _ = ce.User.Conn.Disconnect()
+// ce.User.Conn.RemoveHandlers()
+// ce.User.Conn = nil
+// }
+// ce.Reply("Session information purged")
+//}
+
+const cmdReconnectHelp = `reconnect - Reconnect to WhatsApp`
+
+//func (handler *CommandHandler) CommandReconnect(ce *CommandEvent) {
+// if ce.User.Conn == nil {
+// if ce.User.Session == nil {
+// ce.Reply("No existing connection and no session. Did you mean `login`?")
+// } else {
+// ce.Reply("No existing connection, creating one...")
+// ce.User.Connect(false)
+// }
+// return
+// }
+//
+// wasConnected := true
+// sess, err := ce.User.Conn.Disconnect()
+// if err == whatsapp.ErrNotConnected {
+// wasConnected = false
+// } else if err != nil {
+// ce.User.log.Warnln("Error while disconnecting:", err)
+// } else if len(sess.Wid) > 0 {
+// ce.User.SetSession(&sess)
+// }
+//
+// err = ce.User.Conn.Restore()
+// if err == whatsapp.ErrInvalidSession {
+// if ce.User.Session != nil {
+// ce.User.log.Debugln("Got invalid session error when reconnecting, but user has session. Retrying using RestoreWithSession()...")
+// var sess whatsapp.Session
+// sess, err = ce.User.Conn.RestoreWithSession(*ce.User.Session)
+// if err == nil {
+// ce.User.SetSession(&sess)
+// }
+// } else {
+// ce.Reply("You are not logged in.")
+// return
+// }
+// } else if err == whatsapp.ErrLoginInProgress {
+// ce.Reply("A login or reconnection is already in progress.")
+// return
+// } else if err == whatsapp.ErrAlreadyLoggedIn {
+// ce.Reply("You were already connected.")
+// return
+// }
+// if err != nil {
+// ce.User.log.Warnln("Error while reconnecting:", err)
+// if err.Error() == "restore session connection timed out" {
+// ce.Reply("Reconnection timed out. Is WhatsApp on your phone reachable?")
+// } else {
+// ce.Reply("Unknown error while reconnecting: %v", err)
+// }
+// ce.User.log.Debugln("Disconnecting due to failed session restore in reconnect command...")
+// sess, err := ce.User.Conn.Disconnect()
+// if err != nil {
+// ce.User.log.Errorln("Failed to disconnect after failed session restore in reconnect command:", err)
+// } else if len(sess.Wid) > 0 {
+// ce.User.SetSession(&sess)
+// }
+// return
+// }
+// ce.User.ConnectionErrors = 0
+//
+// var msg string
+// if wasConnected {
+// msg = "Reconnected successfully."
+// } else {
+// msg = "Connected successfully."
+// }
+// ce.Reply(msg)
+// ce.User.PostLogin()
+//}
+
+const cmdDeleteConnectionHelp = `delete-connection - Disconnect ignoring errors and delete internal connection state.`
+
+//func (handler *CommandHandler) CommandDeleteConnection(ce *CommandEvent) {
+// if ce.User.Conn == nil {
+// ce.Reply("You don't have a WhatsApp connection.")
+// return
+// }
+// sess, err := ce.User.Conn.Disconnect()
+// if err == nil && len(sess.Wid) > 0 {
+// ce.User.SetSession(&sess)
+// }
+// ce.User.Conn.RemoveHandlers()
+// ce.User.Conn = nil
+// ce.Reply("Successfully disconnected. Use the `reconnect` command to reconnect.")
+//}
+
+const cmdDisconnectHelp = `disconnect - Disconnect from WhatsApp (without logging out)`
+
+//func (handler *CommandHandler) CommandDisconnect(ce *CommandEvent) {
+// if ce.User.Conn == nil {
+// ce.Reply("You don't have a WhatsApp connection.")
+// return
+// }
+// sess, err := ce.User.Conn.Disconnect()
+// if err == whatsapp.ErrNotConnected {
+// ce.Reply("You were not connected.")
+// return
+// } else if err != nil {
+// ce.User.log.Warnln("Error while disconnecting:", err)
+// ce.Reply("Unknown error while disconnecting: %v", err)
+// return
+// } else if len(sess.Wid) > 0 {
+// ce.User.SetSession(&sess)
+// }
+// ce.Reply("Successfully disconnected. Use the `reconnect` command to reconnect.")
+//}
+
+const cmdPingHelp = `ping - Check your connection to Skype.`
+
+func (handler *CommandHandler) CommandPing(ce *CommandEvent) {
+ if ce.User.Session == nil || ce.User.Session.SkypeToken == "" {
+ if ce.User.IsLoginInProgress() {
+ ce.Reply("You're not logged into Skype, but there's a login in progress.")
+ } else {
+ ce.Reply("You're not logged into Skype.")
+ }
+ } else if ce.User.Conn.LoggedIn == false {
+ ce.Reply("You're not logged into Skype.")
+ } else {
+ username := ce.User.Conn.UserProfile.FirstName
+ if len(ce.User.Conn.UserProfile.LastName) > 0 {
+ username = username + ce.User.Conn.UserProfile.LastName
+ }
+ if username == "" {
+ username = ce.User.Conn.UserProfile.Username
+ }
+
+ orgId := ""
+ if patch.ThirdPartyIdEncrypt {
+ orgId = patch.Enc(strings.TrimSuffix(ce.User.JID, skypeExt.NewUserSuffix))
+ } else {
+ orgId = strings.TrimSuffix(ce.User.JID, skypeExt.NewUserSuffix)
+ }
+ ce.Reply("You're logged in as @" + username + ", orgid is " + orgId)
+ }
+}
+
+const cmdHelpHelp = `help - Prints this help`
+
+// CommandHelp handles help command
+func (handler *CommandHandler) CommandHelp(ce *CommandEvent) {
+ cmdPrefix := ""
+ if ce.User.ManagementRoom != ce.RoomID || ce.User.IsRelaybot {
+ cmdPrefix = handler.bridge.Config.Bridge.CommandPrefix + " "
+ }
+
+ ce.Reply("* " + strings.Join([]string{
+ cmdPrefix + cmdHelpHelp,
+ cmdPrefix + cmdLoginHelp,
+ cmdPrefix + cmdLogoutHelp,
+ //cmdPrefix + cmdDeleteSessionHelp,
+ //cmdPrefix + cmdReconnectHelp,
+ //cmdPrefix + cmdDisconnectHelp,
+ //cmdPrefix + cmdDeleteConnectionHelp,
+ cmdPrefix + cmdPingHelp,
+ //cmdPrefix + cmdLoginMatrixHelp,
+ //cmdPrefix + cmdLogoutMatrixHelp,
+ cmdPrefix + cmdSyncHelp,
+ cmdPrefix + cmdListHelp,
+ cmdPrefix + cmdOpenHelp,
+ cmdPrefix + cmdPMHelp,
+ //cmdPrefix + cmdSetPowerLevelHelp,
+ //cmdPrefix + cmdDeletePortalHelp,
+ //cmdPrefix + cmdDeleteAllPortalsHelp,
+ cmdPrefix + cmdCreateHelp,
+ cmdPrefix + cmdInviteHelp,
+ cmdPrefix + cmdKickHelp,
+ cmdPrefix + cmdLeaveHelp,
+ cmdPrefix + cmdJoinHelp,
+ cmdPrefix + cmdShareHelp,
+
+ }, "\n* "))
+}
+
+const cmdSyncHelp = `sync - Synchronize contacts and optionally create portals for group chats.`
+
+func (handler *CommandHandler) CommandSync(ce *CommandEvent) {
+ user := ce.User
+ create := len(ce.Args) > 0 && ce.Args[0] == "--create-all"
+ ce.Reply("Updating contact and chat list...")
+ handler.log.Debugln("Importing contacts of", user.MXID)
+
+ ce.Reply("Syncing contacts...")
+ err := user.Conn.Conn.ContactList(ce.User.Conn.UserProfile.Username)
+ if err != nil {
+ user.log.Errorln("Error get contacts:", err)
+ ce.Reply("Failed to contacts chat list (see logs for details)")
+ }
+ ce.Reply("Syncing conversations...")
+ err = ce.User.Conn.GetConversations("", user.bridge.Config.Bridge.InitialChatSync)
+ if err != nil {
+ user.log.Errorln("Error get conversations:", err)
+ ce.Reply("Failed to conversations list (see logs for details)")
+ }
+ handler.log.Debugln("Importing chats of", user.MXID)
+ syncAll(user, create)
+
+ ce.Reply("Sync complete.")
+}
+
+func syncAll(user *User, create bool) {
+ //ce.Reply("Syncing contacts...")
+ user.syncPuppets(nil)
+ //ce.Reply("Syncing chats...")
+ user.syncPortals(nil, create)
+ //sync information from non-contacts in the conversation,
+ syncNonContactInfo(user)
+}
+
+func syncNonContactInfo(user *User) {
+ nonContacts := map[string]skype.Contact{}
+ for personId, contact := range user.Conn.Store.Contacts {
+ if contact.PersonId == "" {
+ nonContacts[personId] = contact
+ }
+ }
+ user.syncPuppets(nonContacts)
+}
+
+const cmdDeletePortalHelp = `delete-portal - Delete the current portal. If the portal is used by other people, this is limited to bridge admins.`
+
+func (handler *CommandHandler) CommandDeletePortal(ce *CommandEvent) {
+ portal := ce.Bridge.GetPortalByMXID(ce.RoomID)
+ if portal == nil {
+ ce.Reply("You must be in a portal room to use that command")
+ return
+ }
+
+ if !ce.User.Admin {
+ users := portal.GetUserIDs()
+ if len(users) > 1 || (len(users) == 1 && users[0] != ce.User.MXID) {
+ ce.Reply("Only bridge admins can delete portals with other Matrix users")
+ return
+ }
+ }
+
+ portal.log.Infoln(ce.User.MXID, "requested deletion of portal.")
+ portal.Delete()
+ portal.Cleanup(false)
+}
+
+const cmdDeleteAllPortalsHelp = `delete-all-portals - Delete all your portals that aren't used by any other user.'`
+
+func (handler *CommandHandler) CommandDeleteAllPortals(ce *CommandEvent) {
+ portals := ce.User.GetPortals()
+ portalsToDelete := make([]*Portal, 0, len(portals))
+ for _, portal := range portals {
+ users := portal.GetUserIDs()
+ if len(users) == 1 && users[0] == ce.User.MXID {
+ portalsToDelete = append(portalsToDelete, portal)
+ }
+ }
+ leave := func(portal *Portal) {
+ if len(portal.MXID) > 0 {
+ _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{
+ Reason: "Deleting portal",
+ UserID: ce.User.MXID,
+ })
+ }
+ }
+ customPuppet := handler.bridge.GetPuppetByCustomMXID(ce.User.MXID)
+ if customPuppet != nil && customPuppet.CustomIntent() != nil {
+ intent := customPuppet.CustomIntent()
+ leave = func(portal *Portal) {
+ if len(portal.MXID) > 0 {
+ _, _ = intent.LeaveRoom(portal.MXID)
+ _, _ = intent.ForgetRoom(portal.MXID)
+ }
+ }
+ }
+ ce.Reply("Found %d portals with no other users, deleting...", len(portalsToDelete))
+ for _, portal := range portalsToDelete {
+ portal.Delete()
+ leave(portal)
+ }
+ ce.Reply("Finished deleting portal info. Now cleaning up rooms in background. " +
+ "You may already continue using the bridge. Use `sync` to recreate portals.")
+
+ go func() {
+ for _, portal := range portalsToDelete {
+ portal.Cleanup(false)
+ }
+ ce.Reply("Finished background cleanup of deleted portal rooms.")
+ }()
+}
+
+const cmdListHelp = `list <_contacts|groups_> [page] [items per page] - Get a list of all contacts and groups.`
+
+func formatLists(isContacts bool, contacts map[string]skype.Contact, conversations map[string]skype.Conversation) (result []string) {
+ if isContacts {
+ for _, contact := range contacts {
+ result = append(result, fmt.Sprintf("* %s / %s - `%s`", contact.DisplayName, contact.DisplayNameSource, strings.Replace(contact.PersonId, skypeExt.NewUserSuffix, "", 1)))
+ }
+ sort.Sort(sort.StringSlice(result))
+ } else {
+ for _, conversation := range conversations {
+ if strings.HasPrefix(fmt.Sprint(conversation.Id), "19:") {
+ result = append(result, fmt.Sprintf("* %s - `%s`", conversation.ThreadProperties.Topic, conversation.Id))
+ }
+ }
+ sort.Sort(sort.StringSlice(result))
+ }
+ return
+}
+
+func (handler *CommandHandler) CommandList(ce *CommandEvent) {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage:** `list [page] [items per page]`")
+ return
+ }
+ mode := strings.ToLower(ce.Args[0])
+ if mode[0] != 'g' && mode[0] != 'c' {
+ ce.Reply("**Usage:** `list [page] [items per page]`")
+ return
+ }
+
+ var err error
+ page := 1
+ max := 100
+
+ isContact := mode[0] == 'c'
+ typeName := "Groups"
+ if isContact {
+ typeName = "Contacts"
+ err = ce.User.Conn.ContactList(ce.User.Conn.UserProfile.Username)
+ if err != nil {
+ ce.Reply("Get Contacts error")
+ return
+ }
+ } else {
+ err = ce.User.Conn.GetConversations("", handler.bridge.Config.Bridge.InitialChatSync)
+ if err != nil {
+ ce.Reply("Get conversations error")
+ return
+ }
+ }
+
+ result := formatLists(isContact, ce.User.Conn.Store.Contacts, ce.User.Conn.Store.Chats)
+ if len(result) == 0 {
+ ce.Reply("No %s found", strings.ToLower(typeName))
+ return
+ }
+ pages := int(math.Ceil(float64(len(result)) / float64(max)))
+ if (page-1)*max >= len(result) {
+ if pages == 1 {
+ ce.Reply("There is only 1 page of %s", strings.ToLower(typeName))
+ } else {
+ ce.Reply("There are only %d pages of %s", pages, strings.ToLower(typeName))
+ }
+ return
+ }
+ lastIndex := page * max
+ if lastIndex > len(result) {
+ lastIndex = len(result)
+ }
+ result = result[(page-1)*max : lastIndex]
+ ce.Reply("### %s (page %d of %d)\n\n%s", typeName, page, pages, strings.Join(result, "\n"))
+}
+
+const cmdOpenHelp = `open <_group ID_> - Open a group chat portal.`
+
+func (handler *CommandHandler) CommandOpen(ce *CommandEvent) {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage:** `open `")
+ return
+ }
+
+ user := ce.User
+ jid := ce.Args[0]
+
+ if strings.HasSuffix(jid, skypeExt.NewUserSuffix) {
+ ce.Reply("That looks like a user ID. Did you mean `pm %s`?", jid[:len(jid)-len(skypeExt.NewUserSuffix)])
+ return
+ }
+ ce.User.Conn.GetConversations("", handler.bridge.Config.Bridge.InitialChatSync)
+ fmt.Println("user.Conn.Store.Chats: ", user.Conn.Store.Chats)
+ chat, ok := user.Conn.Store.Chats[jid]
+ if !ok {
+ ce.Reply("Group ID not found in contacts. Try syncing contacts with `sync` first.")
+ return
+ }
+ handler.log.Debugln("Importing", jid, "for", user)
+ portal := user.bridge.GetPortalByJID(database.GroupPortalKey(jid))
+ fmt.Println("CommandOpen portal.MXID", portal.MXID)
+ if len(portal.MXID) > 0 {
+ portal.SyncSkype(user, chat)
+ ce.Reply("Portal room synced.")
+ } else {
+ portal.SyncSkype(user, chat)
+ ce.Reply("Portal room created.")
+ }
+ _, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: user.MXID})
+
+ //resp, err := ce.User.Conn.GetConsumptionHorizons(jid)
+ //if err != nil {
+ // return
+ //}
+ //// var existIds []string
+ //for _, con := range resp.ConsumptionHorizons {
+ // // existIds = append(existIds, strings.Replace(userId, skypeExt.NewUserSuffix, "", 1))
+ // has := false
+ // uId := ""
+ // for userId, _ := range ce.User.Conn.Store.Contacts {
+ // if con.Id == strings.Replace(userId, skypeExt.NewUserSuffix, "", 1) {
+ // has = true
+ // }
+ // uId = con.Id
+ // }
+ // if user.JID == con.Id + skypeExt.NewUserSuffix {
+ // continue
+ // }
+ // if !has && uId != "" {
+ // fmt.Println(fmt.Sprintf("https://avatar.skype.com/v1/avatars/%s/public?returnDefaultImage=false", uId))
+ // fmt.Println()
+ // newId := strings.Replace(con.Id, "8:", "", 1)
+ // avatar := &skypeExt.ProfilePicInfo{
+ // URL: fmt.Sprintf("https://avatar.skype.com/v1/avatars/%s/public?returnDefaultImage=false", newId),
+ // Tag: fmt.Sprintf("https://avatar.skype.com/v1/avatars/%s/public?returnDefaultImage=false", newId),
+ // Status: 0,
+ // }
+ // puppet := user.bridge.GetPuppetByJID(con.Id + skypeExt.NewUserSuffix)
+ // if puppet.Avatar != avatar.URL {
+ // puppet.UpdateAvatar(nil, avatar)
+ // _, err = ce.User.Conn.NameSearch(newId)
+ // if err != nil {
+ // ce.Reply("Failed to synchronize non-contact %s info", newId)
+ // }
+ // }
+ // }
+ //}
+ syncNonContactInfo(ce.User)
+}
+
+//func (handler *CommandHandler) CommandOpen(ce *CommandEvent) {
+// if len(ce.Args) == 0 {
+// ce.Reply("**Usage:** `open `")
+// return
+// }
+//
+// user := ce.User
+// jid := ce.Args[0]
+//
+// if strings.HasSuffix(jid, whatsappExt.NewUserSuffix) {
+// ce.Reply("That looks like a user JID. Did you mean `pm %s`?", jid[:len(jid)-len(whatsappExt.NewUserSuffix)])
+// return
+// }
+//
+// contact, ok := user.Conn.Store.Contacts[jid]
+// if !ok {
+// ce.Reply("Group JID not found in contacts. Try syncing contacts with `sync` first.")
+// return
+// }
+// handler.log.Debugln("Importing", jid, "for", user)
+// portal := user.bridge.GetPortalByJID(database.GroupPortalKey(jid))
+// if len(portal.MXID) > 0 {
+// portal.Sync(user, contact)
+// ce.Reply("Portal room synced.")
+// } else {
+// portal.Sync(user, contact)
+// ce.Reply("Portal room created.")
+// }
+// _, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: user.MXID})
+//}
+
+const cmdPMHelp = `pm <_user ID_> - Open a private chat with the given user id.`
+
+func (handler *CommandHandler) CommandPM(ce *CommandEvent) {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage:** `pm `")
+ return
+ }
+ jid := ce.Args[0] + skypeExt.NewUserSuffix
+
+ handler.log.Debugln("Importing", jid, "for", ce.User)
+
+ contact, ok := ce.User.Conn.Store.Contacts[jid]
+ if !ok {
+ //if !force {
+ ce.Reply("User id not found in contacts. Try syncing contacts with `sync` first. ")
+ return
+ //}
+ //contact = skype.Contact{PersonId: jid}
+ }
+
+ puppet := ce.User.bridge.GetPuppetByJID(contact.PersonId)
+ puppet.Sync(ce.User, contact)
+ portal := ce.User.bridge.GetPortalByJID(database.NewPortalKey(ce.Args[0], ce.User.JID))
+ fmt.Println("CommandPM user.JID", ce.User.JID)
+ if len(portal.MXID) > 0 {
+ _, err := portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: ce.User.MXID})
+ if err != nil {
+ fmt.Println(err)
+ } else {
+ ce.Reply("Existing portal room found, invited you to it.")
+ }
+ return
+ }
+ err := portal.CreateMatrixRoom(ce.User)
+ if err != nil {
+ ce.Reply("Failed to create portal room: %v", err)
+ return
+ }
+ ce.Reply("Created portal room and invited you to it.")
+}
+
+//func (handler *CommandHandler) CommandPM(ce *CommandEvent) {
+// if len(ce.Args) == 0 {
+// ce.Reply("**Usage:** `pm [--force] `")
+// return
+// }
+//
+// force := ce.Args[0] == "--force"
+// if force {
+// ce.Args = ce.Args[1:]
+// }
+//
+// user := ce.User
+//
+// number := strings.Join(ce.Args, "")
+// if number[0] == '+' {
+// number = number[1:]
+// }
+// for _, char := range number {
+// if char < '0' || char > '9' {
+// ce.Reply("Invalid phone number.")
+// return
+// }
+// }
+// jid := number + whatsappExt.NewUserSuffix
+//
+// handler.log.Debugln("Importing", jid, "for", user)
+//
+// contact, ok := user.Conn.Store.Contacts[jid]
+// if !ok {
+// if !force {
+// ce.Reply("Phone number not found in contacts. Try syncing contacts with `sync` first. " +
+// "To create a portal anyway, use `pm --force `.")
+// return
+// }
+// contact = whatsapp.Contact{Jid: jid}
+// }
+// puppet := user.bridge.GetPuppetByJID(contact.Jid)
+// puppet.Sync(user, contact)
+// portal := user.bridge.GetPortalByJID(database.NewPortalKey(contact.Jid, user.JID))
+// if len(portal.MXID) > 0 {
+// _, err := portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: user.MXID})
+// if err != nil {
+// fmt.Println(err)
+// } else {
+// ce.Reply("Existing portal room found, invited you to it.")
+// }
+// return
+// }
+// err := portal.CreateMatrixRoom(user)
+// if err != nil {
+// ce.Reply("Failed to create portal room: %v", err)
+// return
+// }
+// ce.Reply("Created portal room and invited you to it.")
+//}
+
+const cmdLoginMatrixHelp = `login-matrix <_access token_> - Replace your WhatsApp account's Matrix puppet with your real Matrix account.'`
+
+func (handler *CommandHandler) CommandLoginMatrix(ce *CommandEvent) {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage:** `login-matrix `")
+ return
+ }
+ puppet := handler.bridge.GetPuppetByJID(ce.User.JID)
+ err := puppet.SwitchCustomMXID(ce.Args[0], ce.User.MXID)
+ if err != nil {
+ ce.Reply("Failed to switch puppet: %v", err)
+ return
+ }
+ ce.Reply("Successfully switched puppet")
+}
+
+const cmdLogoutMatrixHelp = `logout-matrix - Switch your WhatsApp account's Matrix puppet back to the default one.`
+
+func (handler *CommandHandler) CommandLogoutMatrix(ce *CommandEvent) {
+ puppet := handler.bridge.GetPuppetByJID(ce.User.JID)
+ if len(puppet.CustomMXID) == 0 {
+ ce.Reply("You had not changed your WhatsApp account's Matrix puppet.")
+ return
+ }
+ err := puppet.SwitchCustomMXID("", "")
+ if err != nil {
+ ce.Reply("Failed to remove custom puppet: %v", err)
+ return
+ }
+ ce.Reply("Successfully removed custom puppet")
+}
+
+const cmdInviteHelp = `invite <_group ID_> <_contact id_>,... - Invite members to a group.`
+
+func (handler *CommandHandler) CommandInvite(ce *CommandEvent) {
+ if len(ce.Args) < 2 {
+ ce.Reply("**Usage:** `invite ,...`")
+ return
+ }
+ user := ce.User
+ conversationId := ce.Args[0]
+
+ userNumbers := strings.Split(ce.Args[1], ",")
+
+ if strings.HasSuffix(conversationId, skypeExt.NewUserSuffix) {
+ ce.Reply("**Usage:** `invite ,...`")
+ return
+ }
+
+ _, ok := user.Conn.Store.Chats[conversationId]
+ if !ok {
+ //user.Conn
+ err := ce.User.Conn.GetConversations("", handler.bridge.Config.Bridge.InitialChatSync)
+ //time.Sleep(5 * time.Second)
+ if err != nil {
+ ce.Reply("get conversations failed. Try syncing contacts with `sync` first.")
+ } else {
+ _, ok = user.Conn.Store.Chats[conversationId]
+ if !ok {
+ ce.Reply("Group JID not found in chats. Try syncing groups with `sync` first.")
+ return
+ }
+ }
+ }
+ handler.log.Debugln("GetConversations", conversationId, "for", user)
+ handler.log.Debugln("Inviting", userNumbers, "to", conversationId)
+ err := user.Conn.HandleGroupInvite(conversationId, userNumbers)
+ if err != nil {
+ ce.Reply("Please confirm that you have permission to invite members.")
+ } else {
+ ce.Reply("Group invitation sent.\nIf the member fails to join the group, please check your permissions or command parameters")
+ }
+}
+
+const cmdKickHelp = `kick <_group ID_> <_contact Id>,... - Remove members from the group.`
+
+func (handler *CommandHandler) CommandKick(ce *CommandEvent) {
+ if len(ce.Args) < 2 {
+ ce.Reply("**Usage:** `kick ,... reason`")
+ return
+ }
+
+ user := ce.User
+ converationId := ce.Args[0]
+ userNumbers := strings.Split(ce.Args[1], ",")
+ //reason := "omitempty"
+ //if len(ce.Args) > 2 {
+ // reason = ce.Args[0]
+ //}
+
+ if strings.HasSuffix(converationId, skypeExt.NewUserSuffix) {
+ ce.Reply("**Usage:** `kick ,... reason`")
+ return
+ }
+
+ //fmt.Println("user:", user)
+ //fmt.Println("chats", user.Conn.Store.Chats)
+ _, ok := user.Conn.Store.Chats[converationId]
+ //fmt.Println("查找用户组是否存在:")
+ //fmt.Println(group)
+ if !ok {
+ ce.Reply("Group ID not found in contacts. Try syncing contacts with `sync` first.")
+ return
+ }
+ handler.log.Debugln("Importing", converationId, "for", user)
+ portal := user.bridge.GetPortalByJID(database.GroupPortalKey(converationId))
+
+ for i, number := range userNumbers {
+ userNumbers[i] = number // + skypeExt.NewUserSuffix
+ member := portal.bridge.GetPuppetByJID(number + skypeExt.NewUserSuffix)
+
+ if member == nil {
+ portal.log.Errorln("%s is not a puppet", number)
+ return
+ }
+ }
+
+ handler.log.Debugln("Kicking", userNumbers, "to", converationId)
+ err := user.Conn.HandleGroupKick(converationId, userNumbers)
+ if err != nil {
+ handler.log.Errorln("Kicking err", err)
+ ce.Reply("Please confirm that you have permission to kick members.")
+ } else {
+ ce.Reply("Remove operation completed.\nIf the member has not been removed, please check your permissions or command parameters")
+ }
+}
+
+const cmdLeaveHelp = `leave <_group ID_> - Leave a group.`
+
+func (handler *CommandHandler) CommandLeave(ce *CommandEvent) {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage:** `leave `")
+ return
+ }
+
+ user := ce.User
+ groupId := ce.Args[0]
+
+ if strings.HasSuffix(groupId, skypeExt.NewUserSuffix) {
+ ce.Reply("**Usage:** `leave `")
+ return
+ }
+ //
+ handler.log.Debugln("Importing", groupId, "for", user)
+ portal := user.bridge.GetPortalByJID(database.GroupPortalKey(groupId))
+
+ if len(portal.MXID) > 0 {
+ cli := handler.bridge.Bot
+ fmt.Println("cli appurl:", cli.Prefix, cli.AppServiceUserID, cli.HomeserverURL.String(), )
+ //res, errLeave := cli.LeaveRoom(portal.MXID)
+ //cli.AppServiceUserID = ce.User.MXID
+ u := cli.BuildURL("rooms", portal.MXID, "leave")
+ fmt.Println(u)
+ resp := mautrix.RespLeaveRoom{}
+ res , err := cli.MakeRequest("POST", u, struct{}{}, &resp)
+ fmt.Println("leave res : ", res)
+ fmt.Println("leave res err: ", err)
+ //if errLeave != nil {
+ // portal.log.Errorln("Error leaving matrix room:", errLeave)
+ //}
+ }
+ err := user.Conn.HandleGroupLeave(groupId)
+ if err != nil {
+ fmt.Println(err)
+ ce.Reply("Leave operation failed.")
+ return
+ }
+ ce.Reply("Leave operation completed and successful.")
+
+}
+const cmdShareHelp = `share <_group ID_> - Generate a link to join the group.`
+
+func (handler *CommandHandler) CommandShare(ce *CommandEvent) {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage:** `share `")
+ return
+ }
+ user := ce.User
+ converationId := ce.Args[0]
+ fmt.Println("share converationId : ", converationId)
+ //check the group is exists
+ _, ok := user.Conn.Store.Chats[converationId]
+ if !ok {
+ ce.Reply("Group ID not found in groups. Try syncing groups with `sync` first.")
+ return
+ }
+ //set enabled
+ enstr := map[string]string{
+ "joiningenabled":"true",
+ }
+ _, err := user.Conn.SetConversationThreads(converationId, enstr)
+ if err != nil {
+ ce.Reply("Set ConversationThreads failed.")
+ return
+ }
+ //create share link
+ err, link := user.Conn.HandleGroupShare(converationId)
+ if err != nil {
+ ce.Reply("Generate the share link failed.")
+ }
+ ce.Reply("The link : " + link)
+
+}
+
+const cmdJoinHelp = `join <_invitation link_> - Join the group via the invitation link.`
+
+func (handler *CommandHandler) CommandJoin(ce *CommandEvent) {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage:** `join `")
+ return
+ }
+
+ user := ce.User
+ joinurl := ce.Args[0]
+
+ err, _ := user.Conn.HandleGroupJoin(joinurl)
+
+ if err != nil {
+ ce.Reply("Join group completed and failed.")
+ }
+ ce.Reply("Join group completed and successful.")
+ //contact, ok := user.Conn.Store.Contacts[jid]
+ //if !ok {
+ // ce.Reply("Group JID not found in contacts. Try syncing contacts with `sync` first.")
+ // return
+ //}
+ //handler.log.Debugln("Importing", jid, "for", user)
+ //portal := user.bridge.GetPortalByJID(database.GroupPortalKey(jid))
+ //if len(portal.MXID) > 0 {
+ // portal.Sync(user, contact)
+ // ce.Reply("Portal room synced.")
+ //} else {
+ // portal.Sync(user, contact)
+ // ce.Reply("Portal room created.")
+ //}
+}
+
+//
+//func (handler *CommandHandler) CommandJoin(ce *CommandEvent) {
+// if len(ce.Args) == 0 {
+// ce.Reply("**Usage:** `join `")
+// return
+// }
+//
+// user := ce.User
+// params := strings.Split(ce.Args[0], "com/")
+//
+// jid, err := user.Conn.HandleGroupJoin(params[len(params)-1])
+// if err == nil {
+// ce.Reply("Join operation completed.")
+// }
+//
+// contact, ok := user.Conn.Store.Contacts[jid]
+// if !ok {
+// ce.Reply("Group JID not found in contacts. Try syncing contacts with `sync` first.")
+// return
+// }
+// handler.log.Debugln("Importing", jid, "for", user)
+// portal := user.bridge.GetPortalByJID(database.GroupPortalKey(jid))
+// if len(portal.MXID) > 0 {
+// portal.Sync(user, contact)
+// ce.Reply("Portal room synced.")
+// } else {
+// portal.Sync(user, contact)
+// ce.Reply("Portal room created.")
+// }
+//}
+
+const cmdCreateHelp = `create - Create a group chat.`
+
+func (handler *CommandHandler) CommandCreate(ce *CommandEvent) {
+ if ce.Portal != nil {
+ ce.Reply("This is already a portal room")
+ return
+ }
+
+ members, err := ce.Bot.JoinedMembers(ce.RoomID)
+ handler.log.Debugln("Create Group-1", members)
+ if err != nil {
+ ce.Reply("Failed to get room members: %v", err)
+ return
+ }
+
+ var roomNameEvent event.RoomNameEventContent
+ err = ce.Bot.StateEvent(ce.RoomID, event.StateRoomName, "", &roomNameEvent)
+ if err != nil && !errors.Is(err, mautrix.MNotFound) {
+ ce.Reply("Failed to get room name")
+ return
+ } else if len(roomNameEvent.Name) == 0 {
+ ce.Reply("Please set a name for the room first")
+ return
+ }
+
+ var encryptionEvent event.EncryptionEventContent
+ err = ce.Bot.StateEvent(ce.RoomID, event.StateEncryption, "", &encryptionEvent)
+ if err != nil && !errors.Is(err, mautrix.MNotFound) {
+ ce.Reply("Failed to get room encryption status")
+ return
+ }
+
+ var participants []string
+ for userID := range members.Joined {
+ jid, ok := handler.bridge.ParsePuppetMXID(userID)
+ if ok && jid != ce.User.JID {
+ participants = append(participants, jid)
+ }
+ }
+
+ selfMembers := skype.Members{}
+ member2 := skype.Member{
+ Id: strings.Replace(ce.User.JID, skypeExt.NewUserSuffix,"", 1),
+ Role: "Admin",
+ }
+
+ selfMembers.Members = append(selfMembers.Members, member2)
+ selfMembers.Properties = skype.Properties{
+ HistoryDisclosed: "true",
+ Topic: roomNameEvent.Name,
+ }
+ handler.log.Debugln("Create Group", roomNameEvent.Name, "with", selfMembers, participants)
+ err = ce.User.Conn.HandleGroupCreate(selfMembers)
+ if err != nil {
+ ce.Reply("Failed to create group: %v", err)
+ return
+ }
+ participantMembers := skype.Members{}
+ for _, participant := range participants {
+ memberId := strings.Replace(participant, skypeExt.NewUserSuffix, "", 1)
+ participantMembers.Members = append(participantMembers.Members, skype.Member{
+ Id: memberId,
+ Role: "Admin",
+ })
+ }
+ conversationId, ok := <-ce.User.Conn.CreateChan
+ if ok {
+ portal := handler.bridge.GetPortalByJID(database.GroupPortalKey(conversationId))
+ portal.roomCreateLock.Lock()
+ defer portal.roomCreateLock.Unlock()
+ if len(portal.MXID) != 0 {
+ portal.log.Warnln("Detected race condition in room creation")
+ // TODO race condition, clean up the old room
+ }
+ portal.MXID = ce.RoomID
+ portal.Name = roomNameEvent.Name
+ portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1
+ if !portal.Encrypted && handler.bridge.Config.Bridge.Encryption.Default {
+ _, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateEncryption, "", &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1})
+ if err != nil {
+ portal.log.Warnln("Failed to enable e2be:", err)
+ }
+ portal.Encrypted = true
+ }
+
+ portal.Update()
+ portal.UpdateBridgeInfo()
+
+ err = ce.User.Conn.AddMember(participantMembers, conversationId)
+ ce.Reply("Successfully created Skype group %s", portal.Key.JID)
+ }
+
+ //ce.User.addPortalToCommunity(portal)
+}
diff --git a/community.go b/community.go
new file mode 100644
index 0000000..4544a66
--- /dev/null
+++ b/community.go
@@ -0,0 +1,116 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+
+ "maunium.net/go/mautrix"
+)
+
+func (user *User) inviteToCommunity() {
+ url := user.bridge.Bot.BuildURL("groups", user.CommunityID, "admin", "users", "invite", user.MXID)
+ reqBody := map[string]interface{}{}
+ _, err := user.bridge.Bot.MakeRequest(http.MethodPut, url, &reqBody, nil)
+ if err != nil {
+ user.log.Warnfln("Failed to invite user to personal filtering community %s: %v", user.CommunityID, err)
+ }
+}
+
+func (user *User) updateCommunityProfile() {
+ url := user.bridge.Bot.BuildURL("groups", user.CommunityID, "profile")
+ profileReq := struct {
+ Name string `json:"name"`
+ AvatarURL string `json:"avatar_url"`
+ ShortDescription string `json:"short_description"`
+ }{"Skype", user.bridge.Config.AppService.Bot.Avatar, "Your Skype bridged chats"}
+ _, err := user.bridge.Bot.MakeRequest(http.MethodPost, url, &profileReq, nil)
+ if err != nil {
+ user.log.Warnfln("Failed to update metadata of %s: %v", user.CommunityID, err)
+ }
+}
+
+func (user *User) createCommunity() {
+ if user.IsRelaybot || !user.bridge.Config.Bridge.EnableCommunities() {
+ return
+ }
+
+ localpart, server, _ := user.MXID.Parse()
+ community := user.bridge.Config.Bridge.FormatCommunity(localpart, server)
+ user.log.Debugln("Creating personal filtering community", community)
+ bot := user.bridge.Bot
+ req := struct {
+ Localpart string `json:"localpart"`
+ }{community}
+ resp := struct {
+ GroupID string `json:"group_id"`
+ }{}
+ _, err := bot.MakeRequest(http.MethodPost, bot.BuildURL("create_group"), &req, &resp)
+ if err != nil {
+ if httpErr, ok := err.(mautrix.HTTPError); ok {
+ if httpErr.RespError.Err != "Group already exists" {
+ user.log.Warnln("Server responded with error creating personal filtering community:", err)
+ return
+ } else {
+ user.log.Debugln("Personal filtering community", resp.GroupID, "already existed")
+ user.CommunityID = fmt.Sprintf("+%s:%s", req.Localpart, user.bridge.Config.Homeserver.Domain)
+ }
+ } else {
+ user.log.Warnln("Unknown error creating personal filtering community:", err)
+ return
+ }
+ } else {
+ user.log.Infoln("Created personal filtering community %s", resp.GroupID)
+ user.CommunityID = resp.GroupID
+ user.inviteToCommunity()
+ user.updateCommunityProfile()
+ }
+}
+
+func (user *User) addPuppetToCommunity(puppet *Puppet) bool {
+ if user.IsRelaybot || len(user.CommunityID) == 0 {
+ return false
+ }
+ bot := user.bridge.Bot
+ url := bot.BuildURL("groups", user.CommunityID, "admin", "users", "invite", puppet.MXID)
+ blankReqBody := map[string]interface{}{}
+ _, err := bot.MakeRequest(http.MethodPut, url, &blankReqBody, nil)
+ if err != nil {
+ user.log.Warnfln("Failed to invite %s to %s: %v", puppet.MXID, user.CommunityID, err)
+ return false
+ }
+ reqBody := map[string]map[string]string{
+ "m.visibility": {
+ "type": "private",
+ },
+ }
+ url = bot.BuildURLWithQuery(mautrix.URLPath{"groups", user.CommunityID, "self", "accept_invite"}, map[string]string{
+ "user_id": puppet.MXID.String(),
+ })
+ _, err = bot.MakeRequest(http.MethodPut, url, &reqBody, nil)
+ if err != nil {
+ user.log.Warnfln("Failed to join %s as %s: %v", user.CommunityID, puppet.MXID, err)
+ return false
+ }
+ user.log.Debugln("Added", puppet.MXID, "to", user.CommunityID)
+ return true
+}
+
+func (user *User) addPortalToCommunity(portal *Portal) bool {
+ if user.IsRelaybot || len(user.CommunityID) == 0 || len(portal.MXID) == 0 {
+ return false
+ }
+ bot := user.bridge.Bot
+ url := bot.BuildURL("groups", user.CommunityID, "admin", "rooms", portal.MXID)
+ reqBody := map[string]map[string]string{
+ "m.visibility": {
+ "type": "private",
+ },
+ }
+ _, err := bot.MakeRequest(http.MethodPut, url, &reqBody, nil)
+ if err != nil {
+ user.log.Warnfln("Failed to add %s to %s: %v", portal.MXID, user.CommunityID, err)
+ return false
+ }
+ user.log.Debugln("Added", portal.MXID, "to", user.CommunityID)
+ return true
+}
diff --git a/config/bridge.go b/config/bridge.go
new file mode 100644
index 0000000..31b08ac
--- /dev/null
+++ b/config/bridge.go
@@ -0,0 +1,324 @@
+package config
+
+import (
+ "bytes"
+ skype "github.com/kelaresg/go-skypeapi"
+ "strconv"
+ "strings"
+ "text/template"
+
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "github.com/kelaresg/matrix-skype/types"
+)
+
+type BridgeConfig struct {
+ UsernameTemplate string `yaml:"username_template"`
+ DisplaynameTemplate string `yaml:"displayname_template"`
+ CommunityTemplate string `yaml:"community_template"`
+
+ ConnectionTimeout int `yaml:"connection_timeout"`
+ FetchMessageOnTimeout bool `yaml:"fetch_message_on_timeout"`
+ DeliveryReceipts bool `yaml:"delivery_receipts"`
+ LoginQRRegenCount int `yaml:"login_qr_regen_count"`
+ MaxConnectionAttempts int `yaml:"max_connection_attempts"`
+ ConnectionRetryDelay int `yaml:"connection_retry_delay"`
+ ReportConnectionRetry bool `yaml:"report_connection_retry"`
+ ChatListWait int `yaml:"chat_list_wait"`
+ PortalSyncWait int `yaml:"portal_sync_wait"`
+
+ CallNotices struct {
+ Start bool `yaml:"start"`
+ End bool `yaml:"end"`
+ } `yaml:"call_notices"`
+
+ InitialChatSync int `yaml:"initial_chat_sync_count"`
+ InitialHistoryFill int `yaml:"initial_history_fill_count"`
+ HistoryDisableNotifs bool `yaml:"initial_history_disable_notifications"`
+ RecoverChatSync int `yaml:"recovery_chat_sync_count"`
+ RecoverHistory bool `yaml:"recovery_history_backfill"`
+ SyncChatMaxAge uint64 `yaml:"sync_max_chat_age"`
+
+ SyncWithCustomPuppets bool `yaml:"sync_with_custom_puppets"`
+ LoginSharedSecret string `yaml:"login_shared_secret"`
+
+ InviteOwnPuppetForBackfilling bool `yaml:"invite_own_puppet_for_backfilling"`
+ PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"`
+
+ WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"`
+
+ AllowUserInvite bool `yaml:"allow_user_invite"`
+
+ CommandPrefix string `yaml:"command_prefix"`
+
+ Encryption struct {
+ Allow bool `yaml:"allow"`
+ Default bool `yaml:"default"`
+
+ KeySharing struct {
+ Allow bool `yaml:"allow"`
+ RequireCrossSigning bool `yaml:"require_cross_signing"`
+ RequireVerification bool `yaml:"require_verification"`
+ } `yaml:"key_sharing"`
+
+ PuppetId struct {
+ Allow bool `yaml:"allow"`
+ Key string `yaml:"key"`
+ UsernameTemplatePrefix string `yaml:"username_template_prefix"`
+ } `yaml:"puppet_id"`
+ } `yaml:"encryption"`
+
+ Permissions PermissionConfig `yaml:"permissions"`
+
+ Relaybot RelaybotConfig `yaml:"relaybot"`
+
+ usernameTemplate *template.Template `yaml:"-"`
+ displaynameTemplate *template.Template `yaml:"-"`
+ communityTemplate *template.Template `yaml:"-"`
+}
+
+func (bc *BridgeConfig) setDefaults() {
+ bc.ConnectionTimeout = 20
+ bc.FetchMessageOnTimeout = false
+ bc.DeliveryReceipts = false
+ bc.LoginQRRegenCount = 2
+ bc.MaxConnectionAttempts = 3
+ bc.ConnectionRetryDelay = -1
+ bc.ReportConnectionRetry = true
+ bc.ChatListWait = 30
+ bc.PortalSyncWait = 600
+
+ bc.CallNotices.Start = true
+ bc.CallNotices.End = true
+
+ bc.InitialChatSync = 10
+ bc.InitialHistoryFill = 20
+ bc.RecoverChatSync = -1
+ bc.RecoverHistory = true
+ bc.SyncChatMaxAge = 259200
+
+ bc.SyncWithCustomPuppets = true
+ bc.LoginSharedSecret = ""
+
+ bc.InviteOwnPuppetForBackfilling = true
+ bc.PrivateChatPortalMeta = false
+}
+
+type umBridgeConfig BridgeConfig
+
+func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
+ err := unmarshal((*umBridgeConfig)(bc))
+ if err != nil {
+ return err
+ }
+
+ bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
+ if err != nil {
+ return err
+ }
+
+ bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
+ if err != nil {
+ return err
+ }
+
+ if len(bc.CommunityTemplate) > 0 {
+ bc.communityTemplate, err = template.New("community").Parse(bc.CommunityTemplate)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+type UsernameTemplateArgs struct {
+ UserID id.UserID
+}
+
+func (bc BridgeConfig) FormatDisplayname(contact skype.Contact) (string, int8) {
+ var buf bytes.Buffer
+ if index := strings.IndexRune(contact.PersonId, '@'); index > 0 {
+ contact.PersonId = "+" + contact.PersonId[:index]
+ }
+ bc.displaynameTemplate.Execute(&buf, contact)
+ var quality int8
+ switch {
+ //case len(contact.Notify) > 0:
+ // quality = 3
+ case len(contact.DisplayName) > 0:
+ quality = 3
+ case len(contact.PersonId) > 0:
+ quality = 1
+ default:
+ quality = 0
+ }
+ return buf.String(), quality
+}
+
+func (bc BridgeConfig) FormatUsername(userID types.SkypeID) string {
+ var buf bytes.Buffer
+ bc.usernameTemplate.Execute(&buf, userID)
+ return buf.String()
+}
+
+type CommunityTemplateArgs struct {
+ Localpart string
+ Server string
+}
+
+func (bc BridgeConfig) EnableCommunities() bool {
+ return bc.communityTemplate != nil
+}
+
+func (bc BridgeConfig) FormatCommunity(localpart, server string) string {
+ var buf bytes.Buffer
+ bc.communityTemplate.Execute(&buf, CommunityTemplateArgs{localpart, server})
+ return buf.String()
+}
+
+type PermissionConfig map[string]PermissionLevel
+
+type PermissionLevel int
+
+const (
+ PermissionLevelDefault PermissionLevel = 0
+ PermissionLevelRelaybot PermissionLevel = 5
+ PermissionLevelUser PermissionLevel = 10
+ PermissionLevelAdmin PermissionLevel = 100
+)
+
+func (pc *PermissionConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
+ rawPC := make(map[string]string)
+ err := unmarshal(&rawPC)
+ if err != nil {
+ return err
+ }
+
+ if *pc == nil {
+ *pc = make(map[string]PermissionLevel)
+ }
+ for key, value := range rawPC {
+ switch strings.ToLower(value) {
+ case "relaybot":
+ (*pc)[key] = PermissionLevelRelaybot
+ case "user":
+ (*pc)[key] = PermissionLevelUser
+ case "admin":
+ (*pc)[key] = PermissionLevelAdmin
+ default:
+ val, err := strconv.Atoi(value)
+ if err != nil {
+ (*pc)[key] = PermissionLevelDefault
+ } else {
+ (*pc)[key] = PermissionLevel(val)
+ }
+ }
+ }
+ return nil
+}
+
+func (pc *PermissionConfig) MarshalYAML() (interface{}, error) {
+ if *pc == nil {
+ return nil, nil
+ }
+ rawPC := make(map[string]string)
+ for key, value := range *pc {
+ switch value {
+ case PermissionLevelRelaybot:
+ rawPC[key] = "relaybot"
+ case PermissionLevelUser:
+ rawPC[key] = "user"
+ case PermissionLevelAdmin:
+ rawPC[key] = "admin"
+ default:
+ rawPC[key] = strconv.Itoa(int(value))
+ }
+ }
+ return rawPC, nil
+}
+
+func (pc PermissionConfig) IsRelaybotWhitelisted(userID id.UserID) bool {
+ return pc.GetPermissionLevel(userID) >= PermissionLevelRelaybot
+}
+
+func (pc PermissionConfig) IsWhitelisted(userID id.UserID) bool {
+ return pc.GetPermissionLevel(userID) >= PermissionLevelUser
+}
+
+func (pc PermissionConfig) IsAdmin(userID id.UserID) bool {
+ return pc.GetPermissionLevel(userID) >= PermissionLevelAdmin
+}
+
+func (pc PermissionConfig) GetPermissionLevel(userID id.UserID) PermissionLevel {
+ permissions, ok := pc[string(userID)]
+ if ok {
+ return permissions
+ }
+
+ _, homeserver, _ := userID.Parse()
+ permissions, ok = pc[homeserver]
+ if len(homeserver) > 0 && ok {
+ return permissions
+ }
+
+ permissions, ok = pc["*"]
+ if ok {
+ return permissions
+ }
+
+ return PermissionLevelDefault
+}
+
+type RelaybotConfig struct {
+ Enabled bool `yaml:"enabled"`
+ ManagementRoom id.RoomID `yaml:"management"`
+ InviteUsers []id.UserID `yaml:"invites"`
+
+ MessageFormats map[event.MessageType]string `yaml:"message_formats"`
+ messageTemplates *template.Template `yaml:"-"`
+}
+
+type umRelaybotConfig RelaybotConfig
+
+func (rc *RelaybotConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
+ err := unmarshal((*umRelaybotConfig)(rc))
+ if err != nil {
+ return err
+ }
+
+ rc.messageTemplates = template.New("messageTemplates")
+ for key, format := range rc.MessageFormats {
+ _, err := rc.messageTemplates.New(string(key)).Parse(format)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+type Sender struct {
+ UserID id.UserID
+ *event.MemberEventContent
+}
+
+type formatData struct {
+ Sender Sender
+ Message string
+ Content *event.MessageEventContent
+}
+
+func (rc *RelaybotConfig) FormatMessage(content *event.MessageEventContent, sender id.UserID, member *event.MemberEventContent) (string, error) {
+ var output strings.Builder
+ err := rc.messageTemplates.ExecuteTemplate(&output, string(content.MsgType), formatData{
+ Sender: Sender{
+ UserID: sender,
+ MemberEventContent: member,
+ },
+ Content: content,
+ Message: content.FormattedBody,
+ })
+ return output.String(), err
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..b999fd7
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,110 @@
+// matrix-skype - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2019 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package config
+
+import (
+ "io/ioutil"
+ "maunium.net/go/mautrix/patch"
+
+ "gopkg.in/yaml.v2"
+
+ "maunium.net/go/mautrix/appservice"
+)
+
+type Config struct {
+ Homeserver struct {
+ Address string `yaml:"address"`
+ Domain string `yaml:"domain"`
+ ServerName string `yaml:"server_name"`
+ } `yaml:"homeserver"`
+
+ AppService struct {
+ Address string `yaml:"address"`
+ Hostname string `yaml:"hostname"`
+ Port uint16 `yaml:"port"`
+
+ Database struct {
+ Type string `yaml:"type"`
+ URI string `yaml:"uri"`
+
+ MaxOpenConns int `yaml:"max_open_conns"`
+ MaxIdleConns int `yaml:"max_idle_conns"`
+ } `yaml:"database"`
+
+ StateStore string `yaml:"state_store_path,omitempty"`
+
+ Provisioning struct {
+ Prefix string `yaml:"prefix"`
+ SharedSecret string `yaml:"shared_secret"`
+ } `yaml:"provisioning"`
+
+ ID string `yaml:"id"`
+ Bot struct {
+ Username string `yaml:"username"`
+ Displayname string `yaml:"displayname"`
+ Avatar string `yaml:"avatar"`
+ } `yaml:"bot"`
+
+ ASToken string `yaml:"as_token"`
+ HSToken string `yaml:"hs_token"`
+ } `yaml:"appservice"`
+
+ Bridge BridgeConfig `yaml:"bridge"`
+
+ Logging appservice.LogConfig `yaml:"logging"`
+}
+
+func (config *Config) setDefaults() {
+ config.AppService.Database.MaxOpenConns = 20
+ config.AppService.Database.MaxIdleConns = 2
+ config.Bridge.setDefaults()
+}
+
+func Load(path string) (*Config, error) {
+ data, err := ioutil.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var config = &Config{}
+ config.setDefaults()
+ err = yaml.Unmarshal(data, config)
+ return config, err
+}
+
+func (config *Config) Save(path string) error {
+ data, err := yaml.Marshal(config)
+ if err != nil {
+ return err
+ }
+ return ioutil.WriteFile(path, data, 0600)
+}
+
+func (config *Config) MakeAppService() (*appservice.AppService, error) {
+ as := appservice.Create()
+ as.HomeserverDomain = config.Homeserver.Domain
+ as.HomeserverURL = config.Homeserver.Address
+ as.Host.Hostname = config.AppService.Hostname
+ as.Host.Port = config.AppService.Port
+ patch.ThirdPartyIdEncrypt = config.Bridge.Encryption.PuppetId.Allow
+ patch.AsBotName = config.AppService.Bot.Username
+ patch.AsUserPrefix = config.Bridge.Encryption.PuppetId.UsernameTemplatePrefix
+ patch.XorKey = config.Bridge.Encryption.PuppetId.Key
+ var err error
+ as.Registration, err = config.GetRegistration()
+ return as, err
+}
diff --git a/config/registration.go b/config/registration.go
new file mode 100644
index 0000000..fafd59e
--- /dev/null
+++ b/config/registration.go
@@ -0,0 +1,74 @@
+// matrix-skype - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2019 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package config
+
+import (
+ "fmt"
+ "regexp"
+
+ "maunium.net/go/mautrix/appservice"
+)
+
+func (config *Config) NewRegistration() (*appservice.Registration, error) {
+ registration := appservice.CreateRegistration()
+
+ err := config.copyToRegistration(registration)
+ if err != nil {
+ return nil, err
+ }
+
+ config.AppService.ASToken = registration.AppToken
+ config.AppService.HSToken = registration.ServerToken
+
+ // Workaround for https://github.com/matrix-org/synapse/pull/5758
+ registration.SenderLocalpart = appservice.RandomString(32)
+ botRegex := regexp.MustCompile(fmt.Sprintf("^@%s:%s$", config.AppService.Bot.Username, config.Homeserver.Domain))
+ registration.Namespaces.RegisterUserIDs(botRegex, true)
+
+ return registration, nil
+}
+
+func (config *Config) GetRegistration() (*appservice.Registration, error) {
+ registration := appservice.CreateRegistration()
+
+ err := config.copyToRegistration(registration)
+ if err != nil {
+ return nil, err
+ }
+
+ registration.AppToken = config.AppService.ASToken
+ registration.ServerToken = config.AppService.HSToken
+ return registration, nil
+}
+
+func (config *Config) copyToRegistration(registration *appservice.Registration) error {
+ registration.ID = config.AppService.ID
+ registration.URL = config.AppService.Address
+ falseVal := false
+ registration.RateLimited = &falseVal
+ registration.SenderLocalpart = config.AppService.Bot.Username
+
+ userIDRegex, err := regexp.Compile(fmt.Sprintf("^@%s:%s$",
+ //config.Bridge.FormatUsername("[0-9]+"),
+ config.Bridge.FormatUsername("(.*)"),
+ config.Homeserver.Domain))
+ if err != nil {
+ return err
+ }
+ registration.Namespaces.RegisterUserIDs(userIDRegex, true)
+ return nil
+}
diff --git a/crypto.go b/crypto.go
new file mode 100644
index 0000000..14ecc97
--- /dev/null
+++ b/crypto.go
@@ -0,0 +1,313 @@
+// 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 .
+
+// +build cgo,!nocrypto
+
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/sha512"
+ "encoding/hex"
+ "fmt"
+ "time"
+
+ "github.com/pkg/errors"
+ "maunium.net/go/maulogger/v2"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/crypto"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "github.com/kelaresg/matrix-skype/database"
+)
+
+var levelTrace = maulogger.Level{
+ Name: "Trace",
+ Severity: -10,
+ Color: -1,
+}
+
+type CryptoHelper struct {
+ bridge *Bridge
+ client *mautrix.Client
+ mach *crypto.OlmMachine
+ store *database.SQLCryptoStore
+ log maulogger.Logger
+ baseLog maulogger.Logger
+}
+
+func NewCryptoHelper(bridge *Bridge) Crypto {
+ if !bridge.Config.Bridge.Encryption.Allow {
+ bridge.Log.Debugln("Bridge built with end-to-bridge encryption, but disabled in config")
+ return nil
+ } else if bridge.Config.Bridge.LoginSharedSecret == "" {
+ bridge.Log.Warnln("End-to-bridge encryption enabled, but login_shared_secret not set")
+ return nil
+ }
+ baseLog := bridge.Log.Sub("Crypto")
+ return &CryptoHelper{
+ bridge: bridge,
+ log: baseLog.Sub("Helper"),
+ baseLog: baseLog,
+ }
+}
+
+func (helper *CryptoHelper) Init() error {
+ helper.log.Debugln("Initializing end-to-bridge encryption...")
+
+ helper.store = database.NewSQLCryptoStore(helper.bridge.DB, helper.bridge.AS.BotMXID(),
+ fmt.Sprintf("@%s:%s", helper.bridge.Config.Bridge.FormatUsername("%"), helper.bridge.AS.HomeserverDomain))
+
+ var err error
+ helper.client, err = helper.loginBot()
+ if err != nil {
+ return err
+ }
+
+ helper.log.Debugln("Logged in as bridge bot with device ID", helper.client.DeviceID)
+ logger := &cryptoLogger{helper.baseLog}
+ stateStore := &cryptoStateStore{helper.bridge}
+ helper.mach = crypto.NewOlmMachine(helper.client, logger, helper.store, stateStore)
+ helper.mach.AllowKeyShare = helper.allowKeyShare
+
+ helper.client.Syncer = &cryptoSyncer{helper.mach}
+ helper.client.Store = &cryptoClientStore{helper.store}
+
+ return helper.mach.Load()
+}
+
+func (helper *CryptoHelper) allowKeyShare(device *crypto.DeviceIdentity, info event.RequestedKeyInfo) *crypto.KeyShareRejection {
+ cfg := helper.bridge.Config.Bridge.Encryption.KeySharing
+ if !cfg.Allow {
+ return &crypto.KeyShareRejectNoResponse
+ } else if device.Trust == crypto.TrustStateBlacklisted {
+ return &crypto.KeyShareRejectBlacklisted
+ } else if device.Trust == crypto.TrustStateVerified || !cfg.RequireVerification {
+ portal := helper.bridge.GetPortalByMXID(info.RoomID)
+ if portal == nil {
+ helper.log.Debugfln("Rejecting key request for %s from %s/%s: room is not a portal", info.SessionID, device.UserID, device.DeviceID)
+ return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnavailable, Reason: "Requested room is not a portal room"}
+ }
+ user := helper.bridge.GetUserByMXID(device.UserID)
+ if !user.IsInPortal(portal.Key) {
+ helper.log.Debugfln("Rejecting key request for %s from %s/%s: user is not in portal", info.SessionID, device.UserID, device.DeviceID)
+ return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "You're not in that portal"}
+ }
+ helper.log.Debugfln("Accepting key request for %s from %s/%s", info.SessionID, device.UserID, device.DeviceID)
+ return nil
+ } else {
+ return &crypto.KeyShareRejectUnverified
+ }
+}
+
+func (helper *CryptoHelper) loginBot() (*mautrix.Client, error) {
+ deviceID := helper.store.FindDeviceID()
+ if len(deviceID) > 0 {
+ helper.log.Debugln("Found existing device ID for bot in database:", deviceID)
+ }
+ client, err := mautrix.NewClient(helper.bridge.AS.HomeserverURL, "", "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize client: %w", err)
+ }
+ client.Logger = helper.baseLog.Sub("Bot")
+ client.Client = helper.bridge.AS.HTTPClient
+ client.DefaultHTTPRetries = helper.bridge.AS.DefaultHTTPRetries
+ flows, err := client.GetLoginFlows()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get supported login flows: %w", err)
+ }
+ if !flows.HasFlow(mautrix.AuthTypeHalfyAppservice) {
+ return nil, fmt.Errorf("homeserver does not support appservice login")
+ }
+ // We set the API token to the AS token here to authenticate the appservice login
+ // It'll get overridden after the login
+ client.AccessToken = helper.bridge.AS.Registration.AppToken
+ resp, err := client.Login(&mautrix.ReqLogin{
+ Type: mautrix.AuthTypeHalfyAppservice,
+ Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(helper.bridge.AS.BotMXID())},
+ DeviceID: deviceID,
+ InitialDeviceDisplayName: "Skype Bridge",
+ StoreCredentials: true,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to log in as bridge bot: %w", err)
+ }
+ if len(deviceID) == 0 {
+ helper.store.DeviceID = resp.DeviceID
+ }
+ return client, nil
+}
+
+func (helper *CryptoHelper) loginBotOld() (*mautrix.Client, error) {
+ deviceID := helper.store.FindDeviceID()
+ if len(deviceID) > 0 {
+ helper.log.Debugln("Found existing device ID for bot in database:", deviceID)
+ }
+ mac := hmac.New(sha512.New, []byte(helper.bridge.Config.Bridge.LoginSharedSecret))
+ mac.Write([]byte(helper.bridge.AS.BotMXID()))
+ client, err := mautrix.NewClient(helper.bridge.AS.HomeserverURL, "", "")
+ if err != nil {
+ return nil, err
+ }
+ resp, err := client.Login(&mautrix.ReqLogin{
+ Type: mautrix.AuthTypePassword,
+ Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(helper.bridge.AS.BotMXID())},
+ Password: hex.EncodeToString(mac.Sum(nil)),
+ DeviceID: deviceID,
+ InitialDeviceDisplayName: "Skype Bridge",
+ StoreCredentials: true,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(deviceID) == 0 {
+ helper.store.DeviceID = resp.DeviceID
+ }
+ return client, nil
+}
+
+func (helper *CryptoHelper) Start() {
+ helper.log.Debugln("Starting syncer for receiving to-device messages")
+ err := helper.client.Sync()
+ if err != nil {
+ helper.log.Errorln("Fatal error syncing:", err)
+ }
+}
+
+func (helper *CryptoHelper) Stop() {
+ helper.client.StopSync()
+}
+
+func (helper *CryptoHelper) Decrypt(evt *event.Event) (*event.Event, error) {
+ return helper.mach.DecryptMegolmEvent(evt)
+}
+
+func (helper *CryptoHelper) Encrypt(roomID id.RoomID, evtType event.Type, content event.Content) (*event.EncryptedEventContent, error) {
+ encrypted, err := helper.mach.EncryptMegolmEvent(roomID, evtType, &content)
+ if err != nil {
+ if err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession {
+ return nil, err
+ }
+ helper.log.Debugfln("Got %v while encrypting event for %s, sharing group session and trying again...", err, roomID)
+ users, err := helper.store.GetRoomMembers(roomID)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to get room member list")
+ }
+ err = helper.mach.ShareGroupSession(roomID, users)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to share group session")
+ }
+ encrypted, err = helper.mach.EncryptMegolmEvent(roomID, evtType, &content)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to encrypt event after re-sharing group session")
+ }
+ }
+ return encrypted, nil
+}
+
+func (helper *CryptoHelper) HandleMemberEvent(evt *event.Event) {
+ helper.mach.HandleMemberEvent(evt)
+}
+
+type cryptoSyncer struct {
+ *crypto.OlmMachine
+}
+
+func (syncer *cryptoSyncer) ProcessResponse(resp *mautrix.RespSync, since string) error {
+ syncer.ProcessSyncResponse(resp, since)
+ return nil
+}
+
+func (syncer *cryptoSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
+ syncer.Log.Error("Error /syncing, waiting 10 seconds: %v", err)
+ return 10 * time.Second, nil
+}
+
+func (syncer *cryptoSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
+ everything := []event.Type{{Type: "*"}}
+ return &mautrix.Filter{
+ Presence: mautrix.FilterPart{NotTypes: everything},
+ AccountData: mautrix.FilterPart{NotTypes: everything},
+ Room: mautrix.RoomFilter{
+ IncludeLeave: false,
+ Ephemeral: mautrix.FilterPart{NotTypes: everything},
+ AccountData: mautrix.FilterPart{NotTypes: everything},
+ State: mautrix.FilterPart{NotTypes: everything},
+ Timeline: mautrix.FilterPart{NotTypes: everything},
+ },
+ }
+}
+
+type cryptoLogger struct {
+ int maulogger.Logger
+}
+
+func (c *cryptoLogger) Error(message string, args ...interface{}) {
+ c.int.Errorfln(message, args...)
+}
+
+func (c *cryptoLogger) Warn(message string, args ...interface{}) {
+ c.int.Warnfln(message, args...)
+}
+
+func (c *cryptoLogger) Debug(message string, args ...interface{}) {
+ c.int.Debugfln(message, args...)
+}
+
+func (c *cryptoLogger) Trace(message string, args ...interface{}) {
+ c.int.Logfln(levelTrace, message, args...)
+}
+
+type cryptoClientStore struct {
+ int *database.SQLCryptoStore
+}
+
+func (c cryptoClientStore) SaveFilterID(_ id.UserID, _ string) {}
+func (c cryptoClientStore) LoadFilterID(_ id.UserID) string { return "" }
+func (c cryptoClientStore) SaveRoom(_ *mautrix.Room) {}
+func (c cryptoClientStore) LoadRoom(_ id.RoomID) *mautrix.Room { return nil }
+
+func (c cryptoClientStore) SaveNextBatch(_ id.UserID, nextBatchToken string) {
+ c.int.PutNextBatch(nextBatchToken)
+}
+
+func (c cryptoClientStore) LoadNextBatch(_ id.UserID) string {
+ return c.int.GetNextBatch()
+}
+
+var _ mautrix.Storer = (*cryptoClientStore)(nil)
+
+type cryptoStateStore struct {
+ bridge *Bridge
+}
+
+var _ crypto.StateStore = (*cryptoStateStore)(nil)
+
+func (c *cryptoStateStore) IsEncrypted(id id.RoomID) bool {
+ portal := c.bridge.GetPortalByMXID(id)
+ if portal != nil {
+ return portal.Encrypted
+ }
+ return false
+}
+
+func (c *cryptoStateStore) FindSharedRooms(id id.UserID) []id.RoomID {
+ return c.bridge.StateStore.FindSharedRooms(id)
+}
+
+func (c *cryptoStateStore) GetEncryptionEvent(id.RoomID) *event.EncryptionEventContent {
+ // TODO implement
+ return nil
+}
diff --git a/custompuppet.go b/custompuppet.go
new file mode 100644
index 0000000..95379ec
--- /dev/null
+++ b/custompuppet.go
@@ -0,0 +1,266 @@
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/sha512"
+ "encoding/hex"
+ "strings"
+ "time"
+
+ "github.com/pkg/errors"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/appservice"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+)
+
+var (
+ ErrNoCustomMXID = errors.New("no custom mxid set")
+ ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
+)
+
+func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
+ prevCustomMXID := puppet.CustomMXID
+ if puppet.customIntent != nil {
+ puppet.stopSyncing()
+ }
+ puppet.CustomMXID = mxid
+ puppet.AccessToken = accessToken
+
+ err := puppet.StartCustomMXID()
+ if err != nil {
+ return err
+ }
+
+ if len(prevCustomMXID) > 0 {
+ delete(puppet.bridge.puppetsByCustomMXID, prevCustomMXID)
+ }
+ if len(puppet.CustomMXID) > 0 {
+ puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
+ }
+ puppet.bridge.AS.StateStore.MarkRegistered(puppet.CustomMXID)
+ puppet.Update()
+ // TODO leave rooms with default puppet
+ return nil
+}
+
+func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
+ mac := hmac.New(sha512.New, []byte(puppet.bridge.Config.Bridge.LoginSharedSecret))
+ mac.Write([]byte(mxid))
+ resp, err := puppet.bridge.AS.BotClient().Login(&mautrix.ReqLogin{
+ Type: "m.login.password",
+ Identifier: mautrix.UserIdentifier{Type: "m.id.user", User: string(mxid)},
+ Password: hex.EncodeToString(mac.Sum(nil)),
+ DeviceID: "Skype Bridge",
+ InitialDeviceDisplayName: "Skype Bridge",
+ })
+ if err != nil {
+ return "", err
+ }
+ return resp.AccessToken, nil
+}
+
+func (puppet *Puppet) newCustomIntent() (*appservice.IntentAPI, error) {
+ if len(puppet.CustomMXID) == 0 {
+ return nil, ErrNoCustomMXID
+ }
+ client, err := mautrix.NewClient(puppet.bridge.AS.HomeserverURL, puppet.CustomMXID, puppet.AccessToken)
+ if err != nil {
+ return nil, err
+ }
+ client.Logger = puppet.bridge.AS.Log.Sub(string(puppet.CustomMXID))
+ client.Syncer = puppet
+ client.Store = puppet
+
+ ia := puppet.bridge.AS.NewIntentAPI("custom")
+ ia.Client = client
+ ia.Localpart, _, _ = puppet.CustomMXID.Parse()
+ ia.UserID = puppet.CustomMXID
+ ia.IsCustomPuppet = true
+ return ia, nil
+}
+
+func (puppet *Puppet) clearCustomMXID() {
+ puppet.CustomMXID = ""
+ puppet.AccessToken = ""
+ puppet.customIntent = nil
+ puppet.customTypingIn = nil
+ puppet.customUser = nil
+}
+
+func (puppet *Puppet) StartCustomMXID() error {
+ if len(puppet.CustomMXID) == 0 {
+ puppet.clearCustomMXID()
+ return nil
+ }
+ intent, err := puppet.newCustomIntent()
+ if err != nil {
+ puppet.clearCustomMXID()
+ return err
+ }
+ resp, err := intent.Whoami()
+ if err != nil {
+ if strings.Index(err.Error(), "M_UNKNOWN_TOKEN (HTTP 401)") > -1 {
+ puppet.log.Debugln("StartCustomMXID UpdateAccessToken: ", puppet.MXID)
+ if puppet.customUser != nil {
+ err, _ = puppet.customUser.UpdateAccessToken(puppet)
+ }
+ }
+ if err != nil {
+ puppet.clearCustomMXID()
+ return err
+ }
+ }
+ if resp.UserID != puppet.CustomMXID {
+ puppet.clearCustomMXID()
+ return ErrMismatchingMXID
+ }
+ puppet.customIntent = intent
+ puppet.customTypingIn = make(map[id.RoomID]bool)
+ puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
+ puppet.startSyncing()
+ return nil
+}
+
+func (puppet *Puppet) startSyncing() {
+ if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets {
+ return
+ }
+ go func() {
+ puppet.log.Debugln("Starting syncing...")
+ puppet.customIntent.SyncPresence = "offline"
+ err := puppet.customIntent.Sync()
+ if err != nil {
+ puppet.log.Errorln("Fatal error syncing:", err)
+ }
+ }()
+}
+
+func (puppet *Puppet) stopSyncing() {
+ if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets {
+ return
+ }
+ puppet.customIntent.StopSync()
+}
+
+func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, since string) error {
+ if !puppet.customUser.IsConnected() {
+ puppet.log.Debugln("Skipping sync processing: custom user not connected to whatsapp")
+ return nil
+ }
+ for roomID, events := range resp.Rooms.Join {
+ portal := puppet.bridge.GetPortalByMXID(roomID)
+ if portal == nil {
+ continue
+ }
+ for _, evt := range events.Ephemeral.Events {
+ err := evt.Content.ParseRaw(evt.Type)
+ if err != nil {
+ continue
+ }
+ switch evt.Type {
+ case event.EphemeralEventReceipt:
+ go puppet.handleReceiptEvent(portal, evt)
+ case event.EphemeralEventTyping:
+ go puppet.handleTypingEvent(portal, evt)
+ }
+ }
+ }
+ for _, evt := range resp.Presence.Events {
+ if evt.Sender != puppet.CustomMXID {
+ continue
+ }
+ err := evt.Content.ParseRaw(evt.Type)
+ if err != nil {
+ continue
+ }
+ go puppet.handlePresenceEvent(evt)
+ }
+ return nil
+}
+
+func (puppet *Puppet) handlePresenceEvent(event *event.Event) {
+ //presence := whatsapp.PresenceAvailable
+ //if event.Content.Raw["presence"].(string) != "online" {
+ // presence = whatsapp.PresenceUnavailable
+ // puppet.customUser.log.Debugln("Marking offline")
+ //} else {
+ // puppet.customUser.log.Debugln("Marking online")
+ //}
+ //_, err := puppet.customUser.Conn.Presence("", presence)
+ //if err != nil {
+ // puppet.customUser.log.Warnln("Failed to set presence:", err)
+ //}
+}
+
+func (puppet *Puppet) handleReceiptEvent(portal *Portal, event *event.Event) {
+ //for eventID, receipts := range *event.Content.AsReceipt() {
+ // if _, ok := receipts.Read[puppet.CustomMXID]; !ok {
+ // continue
+ // }
+ // message := puppet.bridge.DB.Message.GetByMXID(eventID)
+ // if message == nil {
+ // continue
+ // }
+ // puppet.customUser.log.Infofln("Marking %s/%s in %s/%s as read", message.JID, message.MXID, portal.Key.JID, portal.MXID)
+ // _, err := puppet.customUser.Conn.Read(portal.Key.JID, message.JID)
+ // if err != nil {
+ // puppet.customUser.log.Warnln("Error marking read:", err)
+ // }
+ //}
+}
+
+func (puppet *Puppet) handleTypingEvent(portal *Portal, evt *event.Event) {
+ //isTyping := false
+ //for _, userID := range evt.Content.AsTyping().UserIDs {
+ // if userID == puppet.CustomMXID {
+ // isTyping = true
+ // break
+ // }
+ //}
+ //if puppet.customTypingIn[evt.RoomID] != isTyping {
+ // puppet.customTypingIn[evt.RoomID] = isTyping
+ // presence := whatsapp.PresenceComposing
+ // if !isTyping {
+ // puppet.customUser.log.Infofln("Marking not typing in %s/%s", portal.Key.JID, portal.MXID)
+ // presence = whatsapp.PresencePaused
+ // } else {
+ // puppet.customUser.log.Infofln("Marking typing in %s/%s", portal.Key.JID, portal.MXID)
+ // }
+ // _, err := puppet.customUser.Conn.Presence(portal.Key.JID, presence)
+ // if err != nil {
+ // puppet.customUser.log.Warnln("Error setting typing:", err)
+ // }
+ //}
+}
+
+func (puppet *Puppet) OnFailedSync(res *mautrix.RespSync, err error) (time.Duration, error) {
+ puppet.log.Warnln("Sync error:", err)
+ return 10 * time.Second, nil
+}
+
+func (puppet *Puppet) GetFilterJSON(_ id.UserID) *mautrix.Filter {
+ everything := []event.Type{{Type: "*"}}
+ return &mautrix.Filter{
+ Presence: mautrix.FilterPart{
+ Senders: []id.UserID{puppet.CustomMXID},
+ Types: []event.Type{event.EphemeralEventPresence},
+ },
+ AccountData: mautrix.FilterPart{NotTypes: everything},
+ Room: mautrix.RoomFilter{
+ Ephemeral: mautrix.FilterPart{Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt}},
+ IncludeLeave: false,
+ AccountData: mautrix.FilterPart{NotTypes: everything},
+ State: mautrix.FilterPart{NotTypes: everything},
+ Timeline: mautrix.FilterPart{NotTypes: everything},
+ },
+ }
+}
+
+func (puppet *Puppet) SaveFilterID(_ id.UserID, _ string) {}
+func (puppet *Puppet) SaveNextBatch(_ id.UserID, nbt string) { puppet.NextBatch = nbt; puppet.Update() }
+func (puppet *Puppet) SaveRoom(room *mautrix.Room) {}
+func (puppet *Puppet) LoadFilterID(_ id.UserID) string { return "" }
+func (puppet *Puppet) LoadNextBatch(_ id.UserID) string { return puppet.NextBatch }
+func (puppet *Puppet) LoadRoom(roomID id.RoomID) *mautrix.Room { return nil }
diff --git a/database/cryptostore.go b/database/cryptostore.go
new file mode 100644
index 0000000..7dd49e9
--- /dev/null
+++ b/database/cryptostore.go
@@ -0,0 +1,102 @@
+// 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 .
+
+// +build cgo,!nocrypto
+
+package database
+
+import (
+ "database/sql"
+
+ log "maunium.net/go/maulogger/v2"
+
+ "maunium.net/go/mautrix/crypto"
+ "maunium.net/go/mautrix/id"
+)
+
+type SQLCryptoStore struct {
+ *crypto.SQLCryptoStore
+ UserID id.UserID
+ GhostIDFormat string
+}
+
+var _ crypto.Store = (*SQLCryptoStore)(nil)
+
+func NewSQLCryptoStore(db *Database, userID id.UserID, ghostIDFormat string) *SQLCryptoStore {
+ return &SQLCryptoStore{
+ SQLCryptoStore: crypto.NewSQLCryptoStore(db.DB, db.dialect, "", "",
+ []byte("maunium.net/go/mautrix-whatsapp"),
+ &cryptoLogger{db.log.Sub("CryptoStore")}),
+ UserID: userID,
+ GhostIDFormat: ghostIDFormat,
+ }
+}
+
+func (store *SQLCryptoStore) FindDeviceID() (deviceID id.DeviceID) {
+ err := store.DB.QueryRow("SELECT device_id FROM crypto_account WHERE account_id=$1", store.AccountID).Scan(&deviceID)
+ if err != nil && err != sql.ErrNoRows {
+ store.Log.Warn("Failed to scan device ID: %v", err)
+ }
+ return
+}
+
+func (store *SQLCryptoStore) GetRoomMembers(roomID id.RoomID) (members []id.UserID, err error) {
+ var rows *sql.Rows
+ rows, err = store.DB.Query(`
+ SELECT user_id FROM mx_user_profile
+ WHERE room_id=$1
+ AND (membership='join' OR membership='invite')
+ AND user_id<>$2
+ AND user_id NOT LIKE $3
+ `, roomID, store.UserID, store.GhostIDFormat)
+ if err != nil {
+ return
+ }
+ for rows.Next() {
+ var userID id.UserID
+ err := rows.Scan(&userID)
+ if err != nil {
+ store.Log.Warn("Failed to scan member in %s: %v", roomID, err)
+ } else {
+ members = append(members, userID)
+ }
+ }
+ return
+}
+
+// TODO merge this with the one in the parent package
+type cryptoLogger struct {
+ int log.Logger
+}
+
+var levelTrace = log.Level{
+ Name: "Trace",
+ Severity: -10,
+ Color: -1,
+}
+
+func (c *cryptoLogger) Error(message string, args ...interface{}) {
+ c.int.Errorfln(message, args...)
+}
+
+func (c *cryptoLogger) Warn(message string, args ...interface{}) {
+ c.int.Warnfln(message, args...)
+}
+
+func (c *cryptoLogger) Debug(message string, args ...interface{}) {
+ c.int.Debugfln(message, args...)
+}
+
+func (c *cryptoLogger) Trace(message string, args ...interface{}) {
+ c.int.Logfln(levelTrace, message, args...)
+}
diff --git a/database/database.go b/database/database.go
new file mode 100644
index 0000000..b217ae4
--- /dev/null
+++ b/database/database.go
@@ -0,0 +1,81 @@
+// matrix-skype - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2019 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package database
+
+import (
+ "database/sql"
+
+ _ "github.com/lib/pq"
+ _ "github.com/mattn/go-sqlite3"
+
+ log "maunium.net/go/maulogger/v2"
+
+ "github.com/kelaresg/matrix-skype/database/upgrades"
+)
+
+type Database struct {
+ *sql.DB
+ log log.Logger
+ dialect string
+
+ User *UserQuery
+ Portal *PortalQuery
+ Puppet *PuppetQuery
+ Message *MessageQuery
+}
+
+func New(dbType string, uri string) (*Database, error) {
+ conn, err := sql.Open(dbType, uri)
+ if err != nil {
+ return nil, err
+ }
+
+ if dbType == "sqlite3" {
+ _, _ = conn.Exec("PRAGMA foreign_keys = ON")
+ }
+
+ db := &Database{
+ DB: conn,
+ log: log.Sub("Database"),
+ dialect: dbType,
+ }
+ db.User = &UserQuery{
+ db: db,
+ log: db.log.Sub("User"),
+ }
+ db.Portal = &PortalQuery{
+ db: db,
+ log: db.log.Sub("Portal"),
+ }
+ db.Puppet = &PuppetQuery{
+ db: db,
+ log: db.log.Sub("Puppet"),
+ }
+ db.Message = &MessageQuery{
+ db: db,
+ log: db.log.Sub("Message"),
+ }
+ return db, nil
+}
+
+func (db *Database) Init() error {
+ return upgrades.Run(db.log.Sub("Upgrade"), db.dialect, db.DB)
+}
+
+type Scannable interface {
+ Scan(...interface{}) error
+}
diff --git a/database/message.go b/database/message.go
new file mode 100644
index 0000000..528629b
--- /dev/null
+++ b/database/message.go
@@ -0,0 +1,145 @@
+package database
+
+import (
+ "bytes"
+ "database/sql"
+ "encoding/json"
+ log "maunium.net/go/maulogger/v2"
+
+ "github.com/kelaresg/matrix-skype/types"
+ "maunium.net/go/mautrix/id"
+)
+
+type MessageQuery struct {
+ db *Database
+ log log.Logger
+}
+
+func (mq *MessageQuery) New() *Message {
+ return &Message{
+ db: mq.db,
+ log: mq.log,
+ }
+}
+
+func (mq *MessageQuery) GetAll(chat PortalKey) (messages []*Message) {
+ rows, err := mq.db.Query("SELECT id, chat_jid, chat_receiver, jid, mxid, sender, timestamp, content FROM message WHERE chat_jid=$1 AND chat_receiver=$2", chat.JID, chat.Receiver)
+ if err != nil || rows == nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ messages = append(messages, mq.New().Scan(rows))
+ }
+ return
+}
+
+func (mq *MessageQuery) GetByJID(chat PortalKey, jid types.SkypeMessageID) *Message {
+ return mq.get("SELECT id, chat_jid, chat_receiver, jid, mxid, sender, timestamp, content " +
+ "FROM message WHERE chat_jid=$1 AND jid=$2", chat.JID, jid)
+}
+
+func (mq *MessageQuery) oldGetByJID(chat PortalKey, jid types.SkypeMessageID) *Message {
+ return mq.get("SELECT id, chat_jid, chat_receiver, jid, mxid, sender, timestamp, content " +
+ "FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3", chat.JID, chat.Receiver, jid)
+}
+
+func (mq *MessageQuery) GetByMXID(mxid id.EventID) *Message {
+ return mq.get("SELECT id, chat_jid, chat_receiver, jid, mxid, sender, timestamp, content " +
+ "FROM message WHERE mxid=$1", mxid)
+}
+
+func (mq *MessageQuery) GetByID(id string) *Message {
+ return mq.get("SELECT id, chat_jid, chat_receiver, jid, mxid, sender, timestamp, content " +
+ "FROM message WHERE id=$1", id)
+}
+
+func (mq *MessageQuery) GetLastInChat(chat PortalKey) *Message {
+ msg := mq.get("SELECT id, chat_jid, chat_receiver, jid, mxid, sender, timestamp, content " +
+ "FROM message WHERE chat_jid=$1 AND chat_receiver=$2 ORDER BY timestamp DESC LIMIT 1", chat.JID, chat.Receiver)
+ if msg == nil || msg.Timestamp == 0 {
+ // Old db, we don't know what the last message is.
+ return nil
+ }
+ return msg
+}
+
+func (mq *MessageQuery) get(query string, args ...interface{}) *Message {
+ row := mq.db.QueryRow(query, args...)
+ if row == nil {
+ return nil
+ }
+ return mq.New().Scan(row)
+}
+
+type Message struct {
+ db *Database
+ log log.Logger
+
+ ID types.SkypeMessageID
+ Chat PortalKey
+ JID types.SkypeMessageID
+ MXID id.EventID
+ Sender types.SkypeID
+ Timestamp uint64
+ Content string
+}
+
+func (msg *Message) Scan(row Scannable) *Message {
+ var content []byte
+ err := row.Scan(&msg.ID, &msg.Chat.JID, &msg.Chat.Receiver, &msg.JID, &msg.MXID, &msg.Sender, &msg.Timestamp, &content)
+ if err != nil {
+ if err != sql.ErrNoRows {
+ msg.log.Errorln("Database scan failed:", err)
+ }
+ return nil
+ }
+
+ msg.decodeBinaryContent(content)
+
+ return msg
+}
+
+func (msg *Message) decodeBinaryContent(content []byte) {
+ //msg.Content = &skype.Resource{}
+ msg.Content = ""
+ reader := bytes.NewReader(content)
+ dec := json.NewDecoder(reader)
+ err := dec.Decode(&msg.Content)
+ if err != nil {
+ msg.log.Warnln("Failed to decode message content:", err)
+ }
+}
+
+func (msg *Message) encodeBinaryContent() []byte {
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ err := enc.Encode(msg.Content)
+ if err != nil {
+ msg.log.Warnln("Failed to encode message content:", err)
+ }
+ return buf.Bytes()
+}
+
+func (msg *Message) Insert() {
+ _, err := msg.db.Exec("INSERT INTO message (id, chat_jid, chat_receiver, jid, mxid, sender, timestamp, content) " +
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
+ msg.ID, msg.Chat.JID, msg.Chat.Receiver, msg.JID, msg.MXID, msg.Sender, msg.Timestamp, msg.encodeBinaryContent())
+ if err != nil {
+ msg.log.Warnfln("Failed to insert %s@%s: %v", msg.Chat, msg.JID, err)
+ }
+}
+
+func (msg *Message) Delete() {
+ _, err := msg.db.Exec("DELETE FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3", msg.Chat.JID, msg.Chat.Receiver, msg.JID)
+ if err != nil {
+ msg.log.Warnfln("Failed to delete %s@%s: %v", msg.Chat, msg.JID, err)
+ }
+}
+
+func (msg *Message) UpdateIDByJID(id string) {
+ _, err := msg.db.Exec("UPDATE message SET id=$1 WHERE chat_jid=$2 AND jid=$3", id, msg.Chat.JID, msg.JID)
+ if err != nil {
+ msg.log.Warnfln("Failed to UpdateIDByJID %s@%s: %v", msg.Chat.JID, msg.JID, err)
+ }
+}
diff --git a/database/migrate.go b/database/migrate.go
new file mode 100644
index 0000000..de59a3e
--- /dev/null
+++ b/database/migrate.go
@@ -0,0 +1,152 @@
+package database
+
+import (
+ "fmt"
+ "math"
+ "strings"
+)
+
+func countRows(db *Database, table string) (int, error) {
+ countRow := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table))
+ var count int
+ err := countRow.Scan(&count)
+ return count, err
+}
+
+const VariableCountLimit = 512
+
+func migrateTable(old *Database, new *Database, table string, columns ...string) error {
+ columnNames := strings.Join(columns, ",")
+ fmt.Printf("Migrating %s: ", table)
+ rowCount, err := countRows(old, table)
+ if err != nil {
+ return err
+ }
+ fmt.Print("found ", rowCount, " rows of data, ")
+ rows, err := old.Query(fmt.Sprintf("SELECT %s FROM \"%s\"", columnNames, table))
+ if err != nil {
+ return err
+ }
+ serverColNames, err := rows.Columns()
+ if err != nil {
+ return err
+ }
+ colCount := len(serverColNames)
+ valueStringFormat := strings.Repeat("$%d, ", colCount)
+ valueStringFormat = fmt.Sprintf("(%s)", valueStringFormat[:len(valueStringFormat)-2])
+ cols := make([]interface{}, colCount)
+ colPtrs := make([]interface{}, colCount)
+ for i := 0; i < colCount; i++ {
+ colPtrs[i] = &cols[i]
+ }
+ batchSize := VariableCountLimit / colCount
+ values := make([]interface{}, batchSize*colCount)
+ valueStrings := make([]string, batchSize)
+ var inserted int64
+ batchCount := int(math.Ceil(float64(rowCount) / float64(batchSize)))
+ tx, err := new.Begin()
+ if err != nil {
+ return err
+ }
+ fmt.Printf("migrating in %d batches: ", batchCount)
+ for rowCount > 0 {
+ var i int
+ for ; rows.Next() && i < batchSize; i++ {
+ colPtrs := make([]interface{}, colCount)
+ valueStringArgs := make([]interface{}, colCount)
+ for j := 0; j < colCount; j++ {
+ pos := i*colCount + j
+ colPtrs[j] = &values[pos]
+ valueStringArgs[j] = pos + 1
+ }
+ valueStrings[i] = fmt.Sprintf(valueStringFormat, valueStringArgs...)
+ err = rows.Scan(colPtrs...)
+ if err != nil {
+ panic(err)
+ }
+ }
+ slicedValues := values
+ slicedValueStrings := valueStrings
+ if i < len(valueStrings) {
+ slicedValueStrings = slicedValueStrings[:i]
+ slicedValues = slicedValues[:i*colCount]
+ }
+ res, err := tx.Exec(fmt.Sprintf("INSERT INTO \"%s\" (%s) VALUES %s", table, columnNames, strings.Join(slicedValueStrings, ",")), slicedValues...)
+ if err != nil {
+ panic(err)
+ }
+ count, _ := res.RowsAffected()
+ inserted += count
+ rowCount -= batchSize
+ fmt.Print("#")
+ }
+ err = tx.Commit()
+ if err != nil {
+ return err
+ }
+ fmt.Println(" -- done with", inserted, "rows inserted")
+ return nil
+}
+
+func Migrate(old *Database, new *Database) {
+ err := migrateTable(old, new, "portal", "jid", "receiver", "mxid", "name", "topic", "avatar", "avatar_url", "encrypted")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "user", "mxid", "jid", "management_room", "endpoint_id", "skype_token", "registration_token", "registration_token_str", "location_host", "last_connection")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "puppet", "jid", "avatar", "displayname", "name_quality", "custom_mxid", "access_token", "next_batch", "avatar_url")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "user_portal", "user_jid", "portal_jid", "portal_receiver", "in_community")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "message", "chat_jid", "chat_receiver", "jid", "mxid", "sender", "content", "timestamp")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "mx_registrations", "user_id")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "mx_user_profile", "room_id", "user_id", "membership")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "mx_room_state", "room_id", "power_levels")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "crypto_account", "device_id", "shared", "sync_token", "account")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "crypto_message_index", "sender_key", "session_id", `"index"`, "event_id", "timestamp")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "crypto_tracked_user", "user_id")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "crypto_device", "user_id", "device_id", "identity_key", "signing_key", "trust", "deleted", "name")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "crypto_olm_session", "session_id", "sender_key", "session", "created_at", "last_used")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "crypto_megolm_inbound_session", "session_id", "sender_key", "signing_key", "room_id", "session", "forwarding_chains")
+ if err != nil {
+ panic(err)
+ }
+ err = migrateTable(old, new, "crypto_megolm_outbound_session", "room_id", "session_id", "session", "shared", "max_messages", "message_count", "max_age", "created_at", "last_used")
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/database/portal.go b/database/portal.go
new file mode 100644
index 0000000..ade1cba
--- /dev/null
+++ b/database/portal.go
@@ -0,0 +1,176 @@
+package database
+
+import (
+ "database/sql"
+ skypeExt "github.com/kelaresg/matrix-skype/skype-ext"
+ "strings"
+
+ log "maunium.net/go/maulogger/v2"
+
+ "maunium.net/go/mautrix/id"
+
+ "github.com/kelaresg/matrix-skype/types"
+)
+
+type PortalKey struct {
+ JID types.SkypeID
+ Receiver types.SkypeID
+}
+
+func GroupPortalKey(jid types.SkypeID) PortalKey {
+ return PortalKey{
+ JID: jid,
+ Receiver: jid,
+ }
+}
+
+func NewPortalKey(jid, receiver types.SkypeID) PortalKey {
+ if strings.HasSuffix(jid, skypeExt.GroupSuffix) {
+ receiver = jid
+ }
+ jid = strings.Replace(jid, skypeExt.NewUserSuffix, "", 1)
+ return PortalKey{
+ JID: jid,
+ Receiver: receiver,
+ }
+}
+
+func (key PortalKey) String() string {
+ if key.Receiver == key.JID {
+ return key.JID
+ }
+ return key.JID + "-" + key.Receiver
+}
+
+type PortalQuery struct {
+ db *Database
+ log log.Logger
+}
+
+func (pq *PortalQuery) New() *Portal {
+ return &Portal{
+ db: pq.db,
+ log: pq.log,
+ }
+}
+
+func (pq *PortalQuery) GetAll() []*Portal {
+ return pq.getAll("SELECT * FROM portal")
+}
+
+func (pq *PortalQuery) GetByJID(key PortalKey) *Portal {
+ return pq.get("SELECT * FROM portal WHERE jid=$1 AND receiver=$2", key.JID, key.Receiver)
+}
+
+func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
+ return pq.get("SELECT * FROM portal WHERE mxid=$1", mxid)
+}
+
+func (pq *PortalQuery) GetAllByJID(jid types.SkypeID) []*Portal {
+ return pq.getAll("SELECT * FROM portal WHERE jid=$1", jid)
+}
+
+func (pq *PortalQuery) getAll(query string, args ...interface{}) (portals []*Portal) {
+ rows, err := pq.db.Query(query, args...)
+ if err != nil || rows == nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ portals = append(portals, pq.New().Scan(rows))
+ }
+ return
+}
+
+func (pq *PortalQuery) get(query string, args ...interface{}) *Portal {
+ row := pq.db.QueryRow(query, args...)
+ if row == nil {
+ return nil
+ }
+ return pq.New().Scan(row)
+}
+
+type Portal struct {
+ db *Database
+ log log.Logger
+
+ Key PortalKey
+ MXID id.RoomID
+
+ Name string
+ Topic string
+ Avatar string
+ AvatarURL id.ContentURI
+ Encrypted bool
+}
+
+func (portal *Portal) Scan(row Scannable) *Portal {
+ var mxid, avatarURL sql.NullString
+ err := row.Scan(&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.Topic, &portal.Avatar, &avatarURL, &portal.Encrypted)
+ if err != nil {
+ if err != sql.ErrNoRows {
+ portal.log.Errorln("Database scan failed:", err)
+ }
+ return nil
+ }
+ portal.MXID = id.RoomID(mxid.String)
+ portal.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
+ return portal
+}
+
+func (portal *Portal) mxidPtr() *id.RoomID {
+ if len(portal.MXID) > 0 {
+ return &portal.MXID
+ }
+ return nil
+}
+
+func (portal *Portal) Insert() {
+ _, err := portal.db.Exec("INSERT INTO portal (jid, receiver, mxid, name, topic, avatar, avatar_url, encrypted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
+ portal.Key.JID, portal.Key.Receiver, portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted)
+ if err != nil {
+ portal.log.Warnfln("Failed to insert %s: %v", portal.Key, err)
+ }
+}
+
+func (portal *Portal) Update() {
+ var mxid *id.RoomID
+ if len(portal.MXID) > 0 {
+ mxid = &portal.MXID
+ }
+ _, err := portal.db.Exec("UPDATE portal SET mxid=$1, name=$2, topic=$3, avatar=$4, avatar_url=$5, encrypted=$6 WHERE jid=$7 AND receiver=$8",
+ mxid, portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted, portal.Key.JID, portal.Key.Receiver)
+ if err != nil {
+ portal.log.Warnfln("Failed to update %s: %v", portal.Key, err)
+ }
+}
+
+func (portal *Portal) Delete() {
+ _, err := portal.db.Exec("DELETE FROM portal WHERE jid=$1 AND receiver=$2", portal.Key.JID, portal.Key.Receiver)
+ if err != nil {
+ portal.log.Warnfln("Failed to delete %s: %v", portal.Key, err)
+ }
+}
+
+func (portal *Portal) GetUserIDs() []id.UserID {
+ rows, err := portal.db.Query(`SELECT "user".mxid FROM "user", user_portal
+ WHERE "user".jid=user_portal.user_jid
+ AND user_portal.portal_jid=$1
+ AND user_portal.portal_receiver=$2`,
+ portal.Key.JID, portal.Key.Receiver)
+ if err != nil {
+ portal.log.Debugln("Failed to get portal user ids:", err)
+ return nil
+ }
+ var userIDs []id.UserID
+ for rows.Next() {
+ var userID id.UserID
+ err = rows.Scan(&userID)
+ if err != nil {
+ portal.log.Warnln("Failed to scan row:", err)
+ continue
+ }
+ userIDs = append(userIDs, userID)
+ }
+ return userIDs
+}
diff --git a/database/puppet.go b/database/puppet.go
new file mode 100644
index 0000000..b21db69
--- /dev/null
+++ b/database/puppet.go
@@ -0,0 +1,113 @@
+package database
+
+import (
+ "database/sql"
+
+ log "maunium.net/go/maulogger/v2"
+
+ "github.com/kelaresg/matrix-skype/types"
+ "maunium.net/go/mautrix/id"
+)
+
+type PuppetQuery struct {
+ db *Database
+ log log.Logger
+}
+
+func (pq *PuppetQuery) New() *Puppet {
+ return &Puppet{
+ db: pq.db,
+ log: pq.log,
+ }
+}
+
+func (pq *PuppetQuery) GetAll() (puppets []*Puppet) {
+ rows, err := pq.db.Query("SELECT jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch FROM puppet")
+ if err != nil || rows == nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ puppets = append(puppets, pq.New().Scan(rows))
+ }
+ return
+}
+
+func (pq *PuppetQuery) Get(jid types.SkypeID) *Puppet {
+ row := pq.db.QueryRow("SELECT jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch FROM puppet WHERE jid=$1", jid)
+ if row == nil {
+ return nil
+ }
+ return pq.New().Scan(row)
+}
+
+func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet {
+ row := pq.db.QueryRow("SELECT jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch FROM puppet WHERE custom_mxid=$1", mxid)
+ if row == nil {
+ return nil
+ }
+ return pq.New().Scan(row)
+}
+
+func (pq *PuppetQuery) GetAllWithCustomMXID() (puppets []*Puppet) {
+ rows, err := pq.db.Query("SELECT jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch FROM puppet WHERE custom_mxid<>''")
+ if err != nil || rows == nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ puppets = append(puppets, pq.New().Scan(rows))
+ }
+ return
+}
+
+type Puppet struct {
+ db *Database
+ log log.Logger
+
+ JID types.SkypeID
+ Avatar string
+ AvatarURL id.ContentURI
+ Displayname string
+ NameQuality int8
+
+ CustomMXID id.UserID
+ AccessToken string
+ NextBatch string
+}
+
+func (puppet *Puppet) Scan(row Scannable) *Puppet {
+ var displayname, avatar, avatarURL, customMXID, accessToken, nextBatch sql.NullString
+ var quality sql.NullInt64
+ err := row.Scan(&puppet.JID, &avatar, &avatarURL, &displayname, &quality, &customMXID, &accessToken, &nextBatch)
+ if err != nil {
+ if err != sql.ErrNoRows {
+ puppet.log.Errorln("Database scan failed:", err)
+ }
+ return nil
+ }
+ puppet.Displayname = displayname.String
+ puppet.Avatar = avatar.String
+ puppet.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
+ puppet.NameQuality = int8(quality.Int64)
+ puppet.CustomMXID = id.UserID(customMXID.String)
+ puppet.AccessToken = accessToken.String
+ puppet.NextBatch = nextBatch.String
+ return puppet
+}
+
+func (puppet *Puppet) Insert() {
+ _, err := puppet.db.Exec("INSERT INTO puppet (jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
+ puppet.JID, puppet.Avatar, puppet.AvatarURL.String(), puppet.Displayname, puppet.NameQuality, puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch)
+ if err != nil {
+ puppet.log.Warnfln("Failed to insert %s: %v", puppet.JID, err)
+ }
+}
+
+func (puppet *Puppet) Update() {
+ _, err := puppet.db.Exec("UPDATE puppet SET displayname=$1, name_quality=$2, avatar=$3, avatar_url=$4, custom_mxid=$5, access_token=$6, next_batch=$7 WHERE jid=$8",
+ puppet.Displayname, puppet.NameQuality, puppet.Avatar, puppet.AvatarURL.String(), puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch, puppet.JID)
+ if err != nil {
+ puppet.log.Warnfln("Failed to update %s->%s: %v", puppet.JID, err)
+ }
+}
diff --git a/database/statestore.go b/database/statestore.go
new file mode 100644
index 0000000..cded90b
--- /dev/null
+++ b/database/statestore.go
@@ -0,0 +1,286 @@
+package database
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "sync"
+
+ log "maunium.net/go/maulogger/v2"
+
+ "maunium.net/go/mautrix/appservice"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+)
+
+type SQLStateStore struct {
+ *appservice.TypingStateStore
+
+ db *Database
+ log log.Logger
+
+ Typing map[id.RoomID]map[id.UserID]int64
+ typingLock sync.RWMutex
+}
+
+var _ appservice.StateStore = (*SQLStateStore)(nil)
+
+func NewSQLStateStore(db *Database) *SQLStateStore {
+ return &SQLStateStore{
+ TypingStateStore: appservice.NewTypingStateStore(),
+ db: db,
+ log: db.log.Sub("StateStore"),
+ }
+}
+
+func (store *SQLStateStore) IsRegistered(userID id.UserID) bool {
+ row := store.db.QueryRow("SELECT EXISTS(SELECT 1 FROM mx_registrations WHERE user_id=$1)", userID)
+ var isRegistered bool
+ err := row.Scan(&isRegistered)
+ if err != nil {
+ store.log.Warnfln("Failed to scan registration existence for %s: %v", userID, err)
+ }
+ return isRegistered
+}
+
+func (store *SQLStateStore) MarkRegistered(userID id.UserID) {
+ var err error
+ if store.db.dialect == "postgres" {
+ _, err = store.db.Exec("INSERT INTO mx_registrations (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING", userID)
+ } else if store.db.dialect == "sqlite3" {
+ _, err = store.db.Exec("INSERT OR REPLACE INTO mx_registrations (user_id) VALUES ($1)", userID)
+ } else {
+ err = fmt.Errorf("unsupported dialect %s", store.db.dialect)
+ }
+ if err != nil {
+ store.log.Warnfln("Failed to mark %s as registered: %v", userID, err)
+ }
+}
+
+func (store *SQLStateStore) GetRoomMembers(roomID id.RoomID) map[id.UserID]*event.MemberEventContent {
+ members := make(map[id.UserID]*event.MemberEventContent)
+ rows, err := store.db.Query("SELECT user_id, membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1", roomID)
+ if err != nil {
+ return members
+ }
+ var userID id.UserID
+ var member event.MemberEventContent
+ for rows.Next() {
+ err := rows.Scan(&userID, &member.Membership, &member.Displayname, &member.AvatarURL)
+ if err != nil {
+ store.log.Warnfln("Failed to scan member in %s: %v", roomID, err)
+ } else {
+ members[userID] = &member
+ }
+ }
+ return members
+}
+
+func (store *SQLStateStore) GetMembership(roomID id.RoomID, userID id.UserID) event.Membership {
+ row := store.db.QueryRow("SELECT membership FROM mx_user_profile WHERE room_id=$1 AND user_id=$2", roomID, userID)
+ membership := event.MembershipLeave
+ err := row.Scan(&membership)
+ if err != nil && err != sql.ErrNoRows {
+ store.log.Warnfln("Failed to scan membership of %s in %s: %v", userID, roomID, err)
+ }
+ return membership
+}
+
+func (store *SQLStateStore) GetMember(roomID id.RoomID, userID id.UserID) *event.MemberEventContent {
+ member, ok := store.TryGetMember(roomID, userID)
+ if !ok {
+ member.Membership = event.MembershipLeave
+ }
+ return member
+}
+
+func (store *SQLStateStore) TryGetMember(roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, bool) {
+ row := store.db.QueryRow("SELECT membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1 AND user_id=$2", roomID, userID)
+ var member event.MemberEventContent
+ err := row.Scan(&member.Membership, &member.Displayname, &member.AvatarURL)
+ if err != nil && err != sql.ErrNoRows {
+ store.log.Warnfln("Failed to scan member info of %s in %s: %v", userID, roomID, err)
+ }
+ return &member, err == nil
+}
+
+func (store *SQLStateStore) FindSharedRooms(userID id.UserID) (rooms []id.RoomID) {
+ rows, err := store.db.Query(`
+ SELECT room_id FROM mx_user_profile
+ LEFT JOIN portal ON portal.mxid=mx_user_profile.room_id
+ WHERE user_id=$1 AND portal.encrypted=true
+ `, userID)
+ if err != nil {
+ store.log.Warnfln("Failed to query shared rooms with %s: %v", userID, err)
+ return
+ }
+ for rows.Next() {
+ var roomID id.RoomID
+ err := rows.Scan(&roomID)
+ if err != nil {
+ store.log.Warnfln("Failed to scan room ID: %v", err)
+ } else {
+ rooms = append(rooms, roomID)
+ }
+ }
+ return
+}
+
+func (store *SQLStateStore) IsInRoom(roomID id.RoomID, userID id.UserID) bool {
+ return store.IsMembership(roomID, userID, "join")
+}
+
+func (store *SQLStateStore) IsInvited(roomID id.RoomID, userID id.UserID) bool {
+ return store.IsMembership(roomID, userID, "join", "invite")
+}
+
+func (store *SQLStateStore) IsMembership(roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool {
+ membership := store.GetMembership(roomID, userID)
+ for _, allowedMembership := range allowedMemberships {
+ if allowedMembership == membership {
+ return true
+ }
+ }
+ return false
+}
+
+func (store *SQLStateStore) SetMembership(roomID id.RoomID, userID id.UserID, membership event.Membership) {
+ var err error
+ if store.db.dialect == "postgres" {
+ _, err = store.db.Exec(`INSERT INTO mx_user_profile (room_id, user_id, membership) VALUES ($1, $2, $3)
+ ON CONFLICT (room_id, user_id) DO UPDATE SET membership=$3`, roomID, userID, membership)
+ } else if store.db.dialect == "sqlite3" {
+ _, err = store.db.Exec("INSERT OR REPLACE INTO mx_user_profile (room_id, user_id, membership) VALUES ($1, $2, $3)", roomID, userID, membership)
+ } else {
+ err = fmt.Errorf("unsupported dialect %s", store.db.dialect)
+ }
+ if err != nil {
+ store.log.Warnfln("Failed to set membership of %s in %s to %s: %v", userID, roomID, membership, err)
+ }
+}
+
+func (store *SQLStateStore) SetMember(roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) {
+ var err error
+ if store.db.dialect == "postgres" {
+ _, err = store.db.Exec(`INSERT INTO mx_user_profile (room_id, user_id, membership, displayname, avatar_url) VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (room_id, user_id) DO UPDATE SET membership=$3`, roomID, userID, member.Membership, member.Displayname, member.AvatarURL)
+ } else if store.db.dialect == "sqlite3" {
+ _, err = store.db.Exec("INSERT OR REPLACE INTO mx_user_profile (room_id, user_id, membership, displayname, avatar_url) VALUES ($1, $2, $3, $4, $5)",
+ roomID, userID, member.Membership, member.Displayname, member.AvatarURL)
+ } else {
+ err = fmt.Errorf("unsupported dialect %s", store.db.dialect)
+ }
+ if err != nil {
+ store.log.Warnfln("Failed to set membership of %s in %s to %s: %v", userID, roomID, member, err)
+ }
+}
+
+func (store *SQLStateStore) SetPowerLevels(roomID id.RoomID, levels *event.PowerLevelsEventContent) {
+ levelsBytes, err := json.Marshal(levels)
+ if err != nil {
+ store.log.Errorfln("Failed to marshal power levels of %s: %v", roomID, err)
+ return
+ }
+ if store.db.dialect == "postgres" {
+ _, err = store.db.Exec(`INSERT INTO mx_room_state (room_id, power_levels) VALUES ($1, $2)
+ ON CONFLICT (room_id) DO UPDATE SET power_levels=$2`, roomID, levelsBytes)
+ } else if store.db.dialect == "sqlite3" {
+ _, err = store.db.Exec("INSERT OR REPLACE INTO mx_room_state (room_id, power_levels) VALUES ($1, $2)", roomID, levelsBytes)
+ } else {
+ err = fmt.Errorf("unsupported dialect %s", store.db.dialect)
+ }
+ if err != nil {
+ store.log.Warnfln("Failed to store power levels of %s: %v", roomID, err)
+ }
+}
+
+func (store *SQLStateStore) GetPowerLevels(roomID id.RoomID) (levels *event.PowerLevelsEventContent) {
+ row := store.db.QueryRow("SELECT power_levels FROM mx_room_state WHERE room_id=$1", roomID)
+ if row == nil {
+ return
+ }
+ var data []byte
+ err := row.Scan(&data)
+ if err != nil {
+ store.log.Errorln("Failed to scan power levels of %s: %v", roomID, err)
+ return
+ }
+ levels = &event.PowerLevelsEventContent{}
+ err = json.Unmarshal(data, levels)
+ if err != nil {
+ store.log.Errorln("Failed to parse power levels of %s: %v", roomID, err)
+ return nil
+ }
+ return
+}
+
+func (store *SQLStateStore) GetPowerLevel(roomID id.RoomID, userID id.UserID) int {
+ if store.db.dialect == "postgres" {
+ row := store.db.QueryRow(`SELECT
+ COALESCE((power_levels->'users'->$2)::int, (power_levels->'users_default')::int, 0)
+ FROM mx_room_state WHERE room_id=$1`, roomID, userID)
+ if row == nil {
+ // Power levels not in db
+ return 0
+ }
+ var powerLevel int
+ err := row.Scan(&powerLevel)
+ if err != nil {
+ store.log.Errorln("Failed to scan power level of %s in %s: %v", userID, roomID, err)
+ }
+ return powerLevel
+ }
+ return store.GetPowerLevels(roomID).GetUserLevel(userID)
+}
+
+func (store *SQLStateStore) GetPowerLevelRequirement(roomID id.RoomID, eventType event.Type) int {
+ if store.db.dialect == "postgres" {
+ defaultType := "events_default"
+ defaultValue := 0
+ if eventType.IsState() {
+ defaultType = "state_default"
+ defaultValue = 50
+ }
+ row := store.db.QueryRow(`SELECT
+ COALESCE((power_levels->'events'->$2)::int, (power_levels->'$3')::int, $4)
+ FROM mx_room_state WHERE room_id=$1`, roomID, eventType.Type, defaultType, defaultValue)
+ if row == nil {
+ // Power levels not in db
+ return defaultValue
+ }
+ var powerLevel int
+ err := row.Scan(&powerLevel)
+ if err != nil {
+ store.log.Errorln("Failed to scan power level for %s in %s: %v", eventType, roomID, err)
+ }
+ return powerLevel
+ }
+ return store.GetPowerLevels(roomID).GetEventLevel(eventType)
+}
+
+func (store *SQLStateStore) HasPowerLevel(roomID id.RoomID, userID id.UserID, eventType event.Type) bool {
+ if store.db.dialect == "postgres" {
+ defaultType := "events_default"
+ defaultValue := 0
+ if eventType.IsState() {
+ defaultType = "state_default"
+ defaultValue = 50
+ }
+ row := store.db.QueryRow(`SELECT
+ COALESCE((power_levels->'users'->$2)::int, (power_levels->'users_default')::int, 0)
+ >=
+ COALESCE((power_levels->'events'->$3)::int, (power_levels->'$4')::int, $5)
+ FROM mx_room_state WHERE room_id=$1`, roomID, userID, eventType.Type, defaultType, defaultValue)
+ if row == nil {
+ // Power levels not in db
+ return defaultValue == 0
+ }
+ var hasPower bool
+ err := row.Scan(&hasPower)
+ if err != nil {
+ store.log.Errorln("Failed to scan power level for %s in %s: %v", eventType, roomID, err)
+ }
+ return hasPower
+ }
+ return store.GetPowerLevel(roomID, userID) >= store.GetPowerLevelRequirement(roomID, eventType)
+}
diff --git a/database/upgrades/2018-09-01-initial-schema.go b/database/upgrades/2018-09-01-initial-schema.go
new file mode 100644
index 0000000..6e097cc
--- /dev/null
+++ b/database/upgrades/2018-09-01-initial-schema.go
@@ -0,0 +1,67 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[0] = upgrade{"Initial schema", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS portal (
+ jid VARCHAR(255),
+ receiver VARCHAR(255),
+ mxid VARCHAR(255) UNIQUE,
+
+ name VARCHAR(255) NOT NULL,
+ topic VARCHAR(255) NOT NULL,
+ avatar VARCHAR(255) NOT NULL,
+
+ PRIMARY KEY (jid, receiver)
+ )`)
+ if err != nil {
+ return err
+ }
+
+ _, err = tx.Exec(`CREATE TABLE IF NOT EXISTS puppet (
+ jid VARCHAR(255) PRIMARY KEY,
+ avatar VARCHAR(255),
+ displayname VARCHAR(255),
+ name_quality SMALLINT
+ )`)
+ if err != nil {
+ return err
+ }
+
+ _, err = tx.Exec(`CREATE TABLE IF NOT EXISTS "user" (
+ mxid VARCHAR(255) PRIMARY KEY,
+ jid VARCHAR(255) UNIQUE,
+
+ management_room VARCHAR(255),
+
+ endpoint_id VARCHAR(255),
+ skype_token VARCHAR(255),
+ registration_token VARCHAR(255),
+ registration_token_str VARCHAR(255),
+ location_host VARCHAR(255)
+ )`)
+ if err != nil {
+ return err
+ }
+
+ _, err = tx.Exec(`CREATE TABLE IF NOT EXISTS message (
+ chat_jid VARCHAR(255),
+ chat_receiver VARCHAR(255),
+ jid VARCHAR(255),
+ mxid VARCHAR(255) NOT NULL UNIQUE,
+ sender VARCHAR(255) NOT NULL,
+ content bytea NOT NULL,
+
+ PRIMARY KEY (chat_jid, chat_receiver, jid),
+ FOREIGN KEY (chat_jid, chat_receiver) REFERENCES portal(jid, receiver) ON DELETE CASCADE
+ )`)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ }}
+}
diff --git a/database/upgrades/2019-05-16-message-delete-cascade.go b/database/upgrades/2019-05-16-message-delete-cascade.go
new file mode 100644
index 0000000..97ee5c9
--- /dev/null
+++ b/database/upgrades/2019-05-16-message-delete-cascade.go
@@ -0,0 +1,31 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[1] = upgrade{"Add ON DELETE CASCADE to message table", func(tx *sql.Tx, ctx context) error {
+ if ctx.dialect == SQLite {
+ // SQLite doesn't support constraint updates, but it isn't that careful with constraints anyway.
+ return nil
+ }
+ res, _ := ctx.db.Query(`SELECT EXISTS(SELECT constraint_name FROM information_schema.table_constraints
+ WHERE table_name='message' AND constraint_name='message_chat_jid_fkey')`)
+ var exists bool
+ _ = res.Scan(&exists)
+ if exists {
+ _, err := tx.Exec("ALTER TABLE message DROP CONSTRAINT IF EXISTS message_chat_jid_fkey")
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`ALTER TABLE message ADD CONSTRAINT message_chat_jid_fkey
+ FOREIGN KEY (chat_jid, chat_receiver) REFERENCES portal(jid, receiver)
+ ON DELETE CASCADE`)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2019-05-21-message-timestamp-column.go b/database/upgrades/2019-05-21-message-timestamp-column.go
new file mode 100644
index 0000000..cb93614
--- /dev/null
+++ b/database/upgrades/2019-05-21-message-timestamp-column.go
@@ -0,0 +1,15 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[2] = upgrade{"Add timestamp column to messages", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec("ALTER TABLE message ADD COLUMN timestamp BIGINT NOT NULL DEFAULT 0")
+ if err != nil {
+ return err
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2019-05-22-user-last-connection-column.go b/database/upgrades/2019-05-22-user-last-connection-column.go
new file mode 100644
index 0000000..3e1a236
--- /dev/null
+++ b/database/upgrades/2019-05-22-user-last-connection-column.go
@@ -0,0 +1,15 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[3] = upgrade{"Add last_connection column to users", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`ALTER TABLE "user" ADD COLUMN last_connection BIGINT NOT NULL DEFAULT 0`)
+ if err != nil {
+ return err
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2019-05-23-protoupgrade.go b/database/upgrades/2019-05-23-protoupgrade.go
new file mode 100644
index 0000000..ce250dd
--- /dev/null
+++ b/database/upgrades/2019-05-23-protoupgrade.go
@@ -0,0 +1,61 @@
+package upgrades
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+)
+
+func init() {
+ var keys = []string{"imageMessage", "contactMessage", "locationMessage", "extendedTextMessage", "documentMessage", "audioMessage", "videoMessage"}
+ upgrades[4] = upgrade{"Update message content to new protocol version. This may take a while.", func(tx *sql.Tx, ctx context) error {
+ rows, err := ctx.db.Query("SELECT mxid, content FROM message")
+ if err != nil {
+ return err
+ }
+ for rows.Next() {
+ var mxid string
+ var rawContent []byte
+ err = rows.Scan(&mxid, &rawContent)
+ if err != nil {
+ fmt.Println("Error scanning:", err)
+ continue
+ }
+ var content map[string]interface{}
+ err = json.Unmarshal(rawContent, &content)
+ if err != nil {
+ fmt.Printf("Error unmarshaling content of %s: %v\n", mxid, err)
+ continue
+ }
+
+ for _, key := range keys {
+ val, ok := content[key].(map[string]interface{})
+ if !ok {
+ continue
+ }
+ ci, ok := val["contextInfo"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+ qm, ok := ci["quotedMessage"].([]interface{})
+ if !ok {
+ continue
+ }
+ ci["quotedMessage"] = qm[0]
+ goto save
+ }
+ continue
+
+ save:
+ rawContent, err = json.Marshal(&content)
+ if err != nil {
+ fmt.Printf("Error marshaling updated content of %s: %v\n", mxid, err)
+ }
+ _, err = tx.Exec("UPDATE message SET content=$1 WHERE mxid=$2", rawContent, mxid)
+ if err != nil {
+ fmt.Printf("Error updating row of %s: %v\n", mxid, err)
+ }
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2019-05-23-puppet-custom-mxid-columns.go b/database/upgrades/2019-05-23-puppet-custom-mxid-columns.go
new file mode 100644
index 0000000..2f17154
--- /dev/null
+++ b/database/upgrades/2019-05-23-puppet-custom-mxid-columns.go
@@ -0,0 +1,23 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[5] = upgrade{"Add columns to store custom puppet info", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`ALTER TABLE puppet ADD COLUMN custom_mxid VARCHAR(255)`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`ALTER TABLE puppet ADD COLUMN access_token VARCHAR(1023)`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`ALTER TABLE puppet ADD COLUMN next_batch VARCHAR(255)`)
+ if err != nil {
+ return err
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2019-05-28-user-portal-table.go b/database/upgrades/2019-05-28-user-portal-table.go
new file mode 100644
index 0000000..18d8550
--- /dev/null
+++ b/database/upgrades/2019-05-28-user-portal-table.go
@@ -0,0 +1,19 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[6] = upgrade{"Add user-portal mapping table", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`CREATE TABLE user_portal (
+ user_jid VARCHAR(255),
+ portal_jid VARCHAR(255),
+ portal_receiver VARCHAR(255),
+ PRIMARY KEY (user_jid, portal_jid, portal_receiver),
+ FOREIGN KEY (user_jid) REFERENCES "user"(jid) ON DELETE CASCADE,
+ FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON DELETE CASCADE
+ )`)
+ return err
+ }}
+}
diff --git a/database/upgrades/2019-06-01-avatar-url-fields.go b/database/upgrades/2019-06-01-avatar-url-fields.go
new file mode 100644
index 0000000..938b291
--- /dev/null
+++ b/database/upgrades/2019-06-01-avatar-url-fields.go
@@ -0,0 +1,19 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[7] = upgrade{"Add columns to store avatar MXC URIs", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`ALTER TABLE puppet ADD COLUMN avatar_url VARCHAR(255)`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`ALTER TABLE portal ADD COLUMN avatar_url VARCHAR(255)`)
+ if err != nil {
+ return err
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2019-08-10-portal-in-community-field.go b/database/upgrades/2019-08-10-portal-in-community-field.go
new file mode 100644
index 0000000..44893fd
--- /dev/null
+++ b/database/upgrades/2019-08-10-portal-in-community-field.go
@@ -0,0 +1,12 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[8] = upgrade{"Add columns to store portal in filtering community meta", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`ALTER TABLE user_portal ADD COLUMN in_community BOOLEAN NOT NULL DEFAULT FALSE`)
+ return err
+ }}
+}
diff --git a/database/upgrades/2019-08-25-move-state-store-to-db.go b/database/upgrades/2019-08-25-move-state-store-to-db.go
new file mode 100644
index 0000000..cbb6001
--- /dev/null
+++ b/database/upgrades/2019-08-25-move-state-store-to-db.go
@@ -0,0 +1,141 @@
+package upgrades
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "strings"
+
+ "maunium.net/go/mautrix/event"
+)
+
+func init() {
+ migrateRegistrations := func(tx *sql.Tx, registrations map[string]bool) error {
+ if len(registrations) == 0 {
+ return nil
+ }
+
+ executeBatch := func(tx *sql.Tx, valueStrings []string, values ...interface{}) error {
+ valueString := strings.Join(valueStrings, ",")
+ _, err := tx.Exec("INSERT INTO mx_registrations (user_id) VALUES "+valueString, values...)
+ return err
+ }
+
+ batchSize := 100
+ values := make([]interface{}, 0, batchSize)
+ valueStrings := make([]string, 0, batchSize)
+ i := 1
+ for userID, registered := range registrations {
+ if i == batchSize {
+ err := executeBatch(tx, valueStrings, values...)
+ if err != nil {
+ return err
+ }
+ i = 1
+ values = make([]interface{}, 0, batchSize)
+ valueStrings = make([]string, 0, batchSize)
+ }
+ if registered {
+ values = append(values, userID)
+ valueStrings = append(valueStrings, fmt.Sprintf("($%d)", i))
+ i++
+ }
+ }
+ return executeBatch(tx, valueStrings, values...)
+ }
+
+ migrateMemberships := func(tx *sql.Tx, rooms map[string]map[string]event.Membership) error {
+ for roomID, members := range rooms {
+ if len(members) == 0 {
+ continue
+ }
+ var values []interface{}
+ var valueStrings []string
+ i := 1
+ for userID, membership := range members {
+ values = append(values, roomID, userID, membership)
+ valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d, $%d)", i, i+1, i+2))
+ i += 3
+ }
+ valueString := strings.Join(valueStrings, ",")
+ _, err := tx.Exec("INSERT INTO mx_user_profile (room_id, user_id, membership) VALUES "+valueString, values...)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+
+ migratePowerLevels := func(tx *sql.Tx, rooms map[string]*event.PowerLevelsEventContent) error {
+ if len(rooms) == 0 {
+ return nil
+ }
+ var values []interface{}
+ var valueStrings []string
+ i := 1
+ for roomID, powerLevels := range rooms {
+ powerLevelBytes, err := json.Marshal(powerLevels)
+ if err != nil {
+ return err
+ }
+ values = append(values, roomID, powerLevelBytes)
+ valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d)", i, i+1))
+ i += 2
+ }
+ valueString := strings.Join(valueStrings, ",")
+ _, err := tx.Exec("INSERT INTO mx_room_state (room_id, power_levels) VALUES "+valueString, values...)
+ return err
+ }
+
+ userProfileTable := `CREATE TABLE mx_user_profile (
+ room_id VARCHAR(255),
+ user_id VARCHAR(255),
+ membership VARCHAR(15) NOT NULL,
+ PRIMARY KEY (room_id, user_id)
+ )`
+
+ roomStateTable := `CREATE TABLE mx_room_state (
+ room_id VARCHAR(255) PRIMARY KEY,
+ power_levels TEXT
+ )`
+
+ registrationsTable := `CREATE TABLE mx_registrations (
+ user_id VARCHAR(255) PRIMARY KEY
+ )`
+
+ type TempStateStore struct {
+ Registrations map[string]bool `json:"registrations"`
+ Members map[string]map[string]event.Membership `json:"memberships"`
+ PowerLevels map[string]*event.PowerLevelsEventContent `json:"power_levels"`
+ }
+
+ upgrades[9] = upgrade{"Move state store to main DB", func(tx *sql.Tx, ctx context) error {
+ if ctx.dialect == Postgres {
+ roomStateTable = strings.Replace(roomStateTable, "TEXT", "JSONB", 1)
+ }
+
+ var store TempStateStore
+ if _, err := tx.Exec(userProfileTable); err != nil {
+ return err
+ } else if _, err = tx.Exec(roomStateTable); err != nil {
+ return err
+ } else if _, err = tx.Exec(registrationsTable); err != nil {
+ return err
+ } else if data, err := ioutil.ReadFile("mx-state.json"); err != nil {
+ ctx.log.Debugln("mx-state.json not found, not migrating state store")
+ } else if err = json.Unmarshal(data, &store); err != nil {
+ return err
+ } else if err = migrateRegistrations(tx, store.Registrations); err != nil {
+ return err
+ } else if err = migrateMemberships(tx, store.Members); err != nil {
+ return err
+ } else if err = migratePowerLevels(tx, store.PowerLevels); err != nil {
+ return err
+ } else if err = os.Rename("mx-state.json", "mx-state.json.bak"); err != nil {
+ return err
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2019-11-10-full-member-state-store.go b/database/upgrades/2019-11-10-full-member-state-store.go
new file mode 100644
index 0000000..4040e7f
--- /dev/null
+++ b/database/upgrades/2019-11-10-full-member-state-store.go
@@ -0,0 +1,16 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[10] = upgrade{"Add columns to store full member info in state store", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`ALTER TABLE mx_user_profile ADD COLUMN displayname TEXT`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`ALTER TABLE mx_user_profile ADD COLUMN avatar_url VARCHAR(255)`)
+ return err
+ }}
+}
diff --git a/database/upgrades/2019-11-12-fix-room-topic-length.go b/database/upgrades/2019-11-12-fix-room-topic-length.go
new file mode 100644
index 0000000..3532d35
--- /dev/null
+++ b/database/upgrades/2019-11-12-fix-room-topic-length.go
@@ -0,0 +1,16 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[11] = upgrade{"Adjust the length of column topic in portal", func(tx *sql.Tx, ctx context) error {
+ if ctx.dialect == SQLite {
+ // SQLite doesn't support constraint updates, but it isn't that careful with constraints anyway.
+ return nil
+ }
+ _, err := tx.Exec(`ALTER TABLE portal ALTER COLUMN topic TYPE VARCHAR(512)`)
+ return err
+ }}
+}
diff --git a/database/upgrades/2020-05-09-add-portal-encrypted-field.go b/database/upgrades/2020-05-09-add-portal-encrypted-field.go
new file mode 100644
index 0000000..ef0f963
--- /dev/null
+++ b/database/upgrades/2020-05-09-add-portal-encrypted-field.go
@@ -0,0 +1,12 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[12] = upgrade{"Add encryption status to portal table", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`ALTER TABLE portal ADD COLUMN encrypted BOOLEAN NOT NULL DEFAULT false`)
+ return err
+ }}
+}
diff --git a/database/upgrades/2020-05-09-crypto-store.go b/database/upgrades/2020-05-09-crypto-store.go
new file mode 100644
index 0000000..8be6cd8
--- /dev/null
+++ b/database/upgrades/2020-05-09-crypto-store.go
@@ -0,0 +1,73 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[13] = upgrade{"Add crypto store to database", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`CREATE TABLE crypto_account (
+ device_id VARCHAR(255) PRIMARY KEY,
+ shared BOOLEAN NOT NULL,
+ sync_token TEXT NOT NULL,
+ account bytea NOT NULL
+ )`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`CREATE TABLE crypto_message_index (
+ sender_key CHAR(43),
+ session_id CHAR(43),
+ "index" INTEGER,
+ event_id VARCHAR(255) NOT NULL,
+ timestamp BIGINT NOT NULL,
+
+ PRIMARY KEY (sender_key, session_id, "index")
+ )`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`CREATE TABLE crypto_tracked_user (
+ user_id VARCHAR(255) PRIMARY KEY
+ )`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`CREATE TABLE crypto_device (
+ user_id VARCHAR(255),
+ device_id VARCHAR(255),
+ identity_key CHAR(43) NOT NULL,
+ signing_key CHAR(43) NOT NULL,
+ trust SMALLINT NOT NULL,
+ deleted BOOLEAN NOT NULL,
+ name VARCHAR(255) NOT NULL,
+
+ PRIMARY KEY (user_id, device_id)
+ )`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`CREATE TABLE crypto_olm_session (
+ session_id CHAR(43) PRIMARY KEY,
+ sender_key CHAR(43) NOT NULL,
+ session bytea NOT NULL,
+ created_at timestamp NOT NULL,
+ last_used timestamp NOT NULL
+ )`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`CREATE TABLE crypto_megolm_inbound_session (
+ session_id CHAR(43) PRIMARY KEY,
+ sender_key CHAR(43) NOT NULL,
+ signing_key CHAR(43) NOT NULL,
+ room_id VARCHAR(255) NOT NULL,
+ session bytea NOT NULL,
+ forwarding_chains bytea NOT NULL
+ )`)
+ if err != nil {
+ return err
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2020-05-12-outbound-group-session-store.go b/database/upgrades/2020-05-12-outbound-group-session-store.go
new file mode 100644
index 0000000..0f108a6
--- /dev/null
+++ b/database/upgrades/2020-05-12-outbound-group-session-store.go
@@ -0,0 +1,25 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[14] = upgrade{"Add outbound group sessions to database", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`CREATE TABLE crypto_megolm_outbound_session (
+ room_id VARCHAR(255) PRIMARY KEY,
+ session_id CHAR(43) NOT NULL UNIQUE,
+ session bytea NOT NULL,
+ shared BOOLEAN NOT NULL,
+ max_messages INTEGER NOT NULL,
+ message_count INTEGER NOT NULL,
+ max_age BIGINT NOT NULL,
+ created_at timestamp NOT NULL,
+ last_used timestamp NOT NULL
+ )`)
+ if err != nil {
+ return err
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2020-07-10-update-crypto-store.go b/database/upgrades/2020-07-10-update-crypto-store.go
new file mode 100644
index 0000000..6b6d44d
--- /dev/null
+++ b/database/upgrades/2020-07-10-update-crypto-store.go
@@ -0,0 +1,13 @@
+package upgrades
+
+import (
+ "database/sql"
+
+ "maunium.net/go/mautrix/crypto/sql_store_upgrade"
+)
+
+func init() {
+ upgrades[15] = upgrade{"Add account_id to crypto store", func(tx *sql.Tx, c context) error {
+ return sql_store_upgrade.Upgrades[1](tx, c.dialect.String())
+ }}
+}
diff --git a/database/upgrades/2020-08-03-update-crypto-store.go b/database/upgrades/2020-08-03-update-crypto-store.go
new file mode 100644
index 0000000..4322e59
--- /dev/null
+++ b/database/upgrades/2020-08-03-update-crypto-store.go
@@ -0,0 +1,13 @@
+package upgrades
+
+import (
+ "database/sql"
+
+ "maunium.net/go/mautrix/crypto/sql_store_upgrade"
+)
+
+func init() {
+ upgrades[16] = upgrade{"Add megolm withheld data to crypto store", func(tx *sql.Tx, c context) error {
+ return sql_store_upgrade.Upgrades[2](tx, c.dialect.String())
+ }}
+}
diff --git a/database/upgrades/2020-08-25-message-id-column.go b/database/upgrades/2020-08-25-message-id-column.go
new file mode 100644
index 0000000..00a862c
--- /dev/null
+++ b/database/upgrades/2020-08-25-message-id-column.go
@@ -0,0 +1,15 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[17] = upgrade{"Add id column to messages", func(tx *sql.Tx, ctx context) error {
+ _, err := tx.Exec(`ALTER TABLE message ADD COLUMN id CHAR(13) DEFAULT ''`)
+ if err != nil {
+ return err
+ }
+ return nil
+ }}
+}
diff --git a/database/upgrades/2020-10-28-crypto-store-cross-signing.go b/database/upgrades/2020-10-28-crypto-store-cross-signing.go
new file mode 100644
index 0000000..2fb89f0
--- /dev/null
+++ b/database/upgrades/2020-10-28-crypto-store-cross-signing.go
@@ -0,0 +1,13 @@
+package upgrades
+
+import (
+ "database/sql"
+
+ "maunium.net/go/mautrix/crypto/sql_store_upgrade"
+)
+
+func init() {
+ upgrades[18] = upgrade{"Add cross-signing keys to crypto store", func(tx *sql.Tx, c context) error {
+ return sql_store_upgrade.Upgrades[3](tx, c.dialect.String())
+ }}
+}
diff --git a/database/upgrades/2021-10-19-update-user-fields-varying.go b/database/upgrades/2021-10-19-update-user-fields-varying.go
new file mode 100644
index 0000000..3b9de18
--- /dev/null
+++ b/database/upgrades/2021-10-19-update-user-fields-varying.go
@@ -0,0 +1,20 @@
+package upgrades
+
+import (
+ "database/sql"
+)
+
+func init() {
+ upgrades[19] = upgrade{"Update user fields varying.", func(tx *sql.Tx, c context) error {
+ if c.dialect == Postgres {
+ _, err := tx.Exec(`ALTER TABLE "user" ALTER COLUMN skype_token TYPE varchar(1500),
+ ALTER COLUMN registration_token TYPE varchar(1500),
+ ALTER COLUMN registration_token_str TYPE varchar(1500)`)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }}
+}
diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go
new file mode 100644
index 0000000..e224593
--- /dev/null
+++ b/database/upgrades/upgrades.go
@@ -0,0 +1,112 @@
+package upgrades
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+
+ log "maunium.net/go/maulogger/v2"
+)
+
+type Dialect int
+
+const (
+ Postgres Dialect = iota
+ SQLite
+)
+
+func (dialect Dialect) String() string {
+ switch dialect {
+ case Postgres:
+ return "postgres"
+ case SQLite:
+ return "sqlite3"
+ default:
+ return ""
+ }
+}
+
+type upgradeFunc func(*sql.Tx, context) error
+
+type context struct {
+ dialect Dialect
+ db *sql.DB
+ log log.Logger
+}
+
+type upgrade struct {
+ message string
+ fn upgradeFunc
+}
+
+const NumberOfUpgrades = 20
+
+var upgrades [NumberOfUpgrades]upgrade
+
+var UnsupportedDatabaseVersion = fmt.Errorf("unsupported database version")
+
+func GetVersion(db *sql.DB) (int, error) {
+ _, err := db.Exec("CREATE TABLE IF NOT EXISTS version (version INTEGER)")
+ if err != nil {
+ return -1, err
+ }
+
+ version := 0
+ row := db.QueryRow("SELECT version FROM version LIMIT 1")
+ if row != nil {
+ _ = row.Scan(&version)
+ }
+ return version, nil
+}
+
+func SetVersion(tx *sql.Tx, version int) error {
+ _, err := tx.Exec("DELETE FROM version")
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec("INSERT INTO version (version) VALUES ($1)", version)
+ return err
+}
+
+func Run(log log.Logger, dialectName string, db *sql.DB) error {
+ var dialect Dialect
+ switch strings.ToLower(dialectName) {
+ case "postgres":
+ dialect = Postgres
+ case "sqlite3":
+ dialect = SQLite
+ default:
+ return fmt.Errorf("unknown dialect %s", dialectName)
+ }
+
+ version, err := GetVersion(db)
+ if err != nil {
+ return err
+ }
+
+ if version > NumberOfUpgrades {
+ return UnsupportedDatabaseVersion
+ }
+
+ log.Infofln("Database currently on v%d, latest: v%d", version, NumberOfUpgrades)
+ for i, upgrade := range upgrades[version:] {
+ log.Infofln("Upgrading database to v%d: %s", version+i+1, upgrade.message)
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ err = upgrade.fn(tx, context{dialect, db, log})
+ if err != nil {
+ return err
+ }
+ err = SetVersion(tx, version+i+1)
+ if err != nil {
+ return err
+ }
+ err = tx.Commit()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/database/user.go b/database/user.go
new file mode 100644
index 0000000..45ac771
--- /dev/null
+++ b/database/user.go
@@ -0,0 +1,241 @@
+package database
+
+import (
+ "database/sql"
+ "fmt"
+ skype "github.com/kelaresg/go-skypeapi"
+ skypeExt "github.com/kelaresg/matrix-skype/skype-ext"
+ "strings"
+ "time"
+
+ log "maunium.net/go/maulogger/v2"
+
+ "github.com/kelaresg/matrix-skype/types"
+ "maunium.net/go/mautrix/id"
+)
+
+type UserQuery struct {
+ db *Database
+ log log.Logger
+}
+
+func (uq *UserQuery) New() *User {
+ return &User{
+ db: uq.db,
+ log: uq.log,
+ }
+}
+
+func (uq *UserQuery) GetAll() (users []*User) {
+ rows, err := uq.db.Query(`SELECT mxid, jid, management_room, last_connection, endpoint_id, skype_token, registration_token, registration_token_str, location_host FROM "user"`)
+ if err != nil || rows == nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ users = append(users, uq.New().Scan(rows))
+ }
+ return
+}
+
+func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
+ row := uq.db.QueryRow(`SELECT mxid, jid, management_room, last_connection, endpoint_id, skype_token, registration_token, registration_token_str, location_host FROM "user" WHERE mxid=$1`, userID)
+ if row == nil {
+ return nil
+ }
+ return uq.New().Scan(row)
+}
+
+func (uq *UserQuery) GetByJID(userID types.SkypeID) *User {
+ row := uq.db.QueryRow(`SELECT mxid, jid, management_room, last_connection, endpoint_id, skype_token, registration_token, registration_token_str, location_host FROM "user" WHERE jid=$1`, stripSuffix(userID))
+ if row == nil {
+ return nil
+ }
+ return uq.New().Scan(row)
+}
+
+type User struct {
+ db *Database
+ log log.Logger
+
+ MXID id.UserID
+ JID types.SkypeID
+ ManagementRoom id.RoomID
+ Session *skype.Session
+ LastConnection uint64
+}
+
+func (user *User) Scan(row Scannable) *User {
+ var jid, endpointId, skypeToken, registrationToken, registrationTokenStr, locationHost sql.NullString
+ err := row.Scan(&user.MXID, &jid, &user.ManagementRoom, &user.LastConnection, &endpointId, &skypeToken, ®istrationToken, ®istrationTokenStr, &locationHost)
+ if err != nil {
+ if err != sql.ErrNoRows {
+ user.log.Errorln("Database scan failed:", err)
+ }
+ return nil
+ }
+ if len(jid.String) > 0 && len(endpointId.String) > 0 {
+ user.JID = jid.String + skypeExt.NewUserSuffix
+ user.Session = &skype.Session{
+ EndpointId: endpointId.String,
+ SkypeToken: skypeToken.String,
+ RegistrationToken: registrationToken.String,
+ RegistrationTokenStr: registrationTokenStr.String,
+ LocationHost: locationHost.String,
+ }
+ } else {
+ user.Session = nil
+ }
+ return user
+}
+
+func stripSuffix(jid types.SkypeID) string {
+ if len(jid) == 0 {
+ return jid
+ }
+
+ index := strings.IndexRune(jid, '@')
+ if index < 0 {
+ return jid
+ }
+
+ return jid[:index]
+}
+
+func (user *User) jidPtr() *string {
+ if len(user.JID) > 0 {
+ str := stripSuffix(user.JID)
+ return &str
+ }
+ return nil
+}
+
+func (user *User) sessionUnptr() (sess skype.Session) {
+ if user.Session != nil {
+ sess = *user.Session
+ }
+ return
+}
+
+func (user *User) Insert() {
+ sess := user.sessionUnptr()
+ _, err := user.db.Exec(`INSERT INTO "user" (mxid, jid, management_room, last_connection, endpoint_id, skype_token, registration_token, registration_token_str, location_host) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+ user.MXID, user.jidPtr(),
+ user.ManagementRoom, user.LastConnection,
+ sess.EndpointId, sess.SkypeToken, sess.RegistrationToken, sess.RegistrationTokenStr, sess.LocationHost)
+ if err != nil {
+ user.log.Warnfln("Failed to insert %s: %v", user.MXID, err)
+ }
+}
+
+func (user *User) UpdateLastConnection() {
+ user.LastConnection = uint64(time.Now().Unix())
+ _, err := user.db.Exec(`UPDATE "user" SET last_connection=$1 WHERE mxid=$2`,
+ user.LastConnection, user.MXID)
+ if err != nil {
+ user.log.Warnfln("Failed to update last connection ts: %v", err)
+ }
+}
+
+func (user *User) Update() {
+ sess := user.sessionUnptr()
+ _, err := user.db.Exec(`UPDATE "user" SET jid=$1, management_room=$2, last_connection=$3, endpoint_id=$4, skype_token=$5, registration_token=$6, registration_token_str=$7, location_host=$8 WHERE mxid=$9`,
+ user.jidPtr(), user.ManagementRoom, user.LastConnection,
+ sess.EndpointId, sess.SkypeToken, sess.RegistrationToken, sess.RegistrationTokenStr, sess.LocationHost,
+ user.MXID)
+ if err != nil {
+ user.log.Warnfln("Failed to update %s: %v", user.MXID, err)
+ }
+}
+
+type PortalKeyWithMeta struct {
+ PortalKey
+ InCommunity bool
+}
+
+func (user *User) SetPortalKeys(newKeys []PortalKeyWithMeta) error {
+ tx, err := user.db.Begin()
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec("DELETE FROM user_portal WHERE user_jid=$1", user.jidPtr())
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ valueStrings := make([]string, len(newKeys))
+ values := make([]interface{}, len(newKeys)*4)
+ for i, key := range newKeys {
+ pos := i * 4
+ valueStrings[i] = fmt.Sprintf("($%d, $%d, $%d, $%d)", pos+1, pos+2, pos+3, pos+4)
+ values[pos] = user.jidPtr()
+ values[pos+1] = key.JID
+ values[pos+2] = key.Receiver
+ values[pos+3] = key.InCommunity
+ }
+ query := fmt.Sprintf("INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES %s",
+ strings.Join(valueStrings, ", "))
+ _, err = tx.Exec(query, values...)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+func (user *User) CreateUserPortal(newKey PortalKeyWithMeta) {
+ user.log.Debugfln("Creating new portal %s for %s", newKey.PortalKey.JID, newKey.PortalKey.Receiver)
+ _, err := user.db.Exec(`INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES ($1, $2, $3, $4)`,
+ user.jidPtr(),
+ newKey.PortalKey.JID, newKey.PortalKey.Receiver,
+ newKey.InCommunity)
+ if err != nil {
+ user.log.Warnfln("Failed to insert %s: %v", user.MXID, err)
+ }
+}
+
+func (user *User) IsInPortal(key PortalKey) bool {
+ row := user.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM user_portal WHERE user_jid=$1 AND portal_jid=$2 AND portal_receiver=$3)`, user.jidPtr(), &key.JID, &key.Receiver)
+ var exists bool
+ _ = row.Scan(&exists)
+ return exists
+}
+
+func (user *User) GetPortalKeys() []PortalKey {
+ rows, err := user.db.Query(`SELECT portal_jid, portal_receiver FROM user_portal WHERE user_jid=$1`, user.jidPtr())
+ if err != nil {
+ user.log.Warnln("Failed to get user portal keys:", err)
+ return nil
+ }
+ var keys []PortalKey
+ for rows.Next() {
+ var key PortalKey
+ err = rows.Scan(&key.JID, &key.Receiver)
+ if err != nil {
+ user.log.Warnln("Failed to scan row:", err)
+ continue
+ }
+ keys = append(keys, key)
+ }
+ return keys
+}
+
+func (user *User) GetInCommunityMap() map[PortalKey]bool {
+ rows, err := user.db.Query(`SELECT portal_jid, portal_receiver, in_community FROM user_portal WHERE user_jid=$1`, user.jidPtr())
+ if err != nil {
+ user.log.Warnln("Failed to get user portal keys:", err)
+ return nil
+ }
+ keys := make(map[PortalKey]bool)
+ for rows.Next() {
+ var key PortalKey
+ var inCommunity bool
+ err = rows.Scan(&key.JID, &key.Receiver, &inCommunity)
+ if err != nil {
+ user.log.Warnln("Failed to scan row:", err)
+ continue
+ }
+ keys[key] = inCommunity
+ }
+ return keys
+}
diff --git a/docker-run.sh b/docker-run.sh
new file mode 100755
index 0000000..a55cae1
--- /dev/null
+++ b/docker-run.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+
+if [[ -z "$GID" ]]; then
+ GID="$UID"
+fi
+
+# Define functions.
+function fixperms {
+ chown -R $UID:$GID /data /opt/matrix-skype
+}
+
+if [[ ! -f /data/config.yaml ]]; then
+ cp /opt/matrix-skype/example-config.yaml /data/config.yaml
+ echo "Didn't find a config file."
+ echo "Copied default config file to /data/config.yaml"
+ echo "Modify that config file to your liking."
+ echo "Start the container again after that to generate the registration file."
+ exit
+fi
+
+if [[ ! -f /data/registration.yaml ]]; then
+ /usr/bin/matrix-skype -g -c /data/config.yaml -r /data/registration.yaml
+ echo "Didn't find a registration file."
+ echo "Generated one for you."
+ echo "Copy that over to synapses app service directory."
+ exit
+fi
+
+cd /data
+fixperms
+exec su-exec $UID:$GID /usr/bin/matrix-skype
diff --git a/example-config.yaml b/example-config.yaml
new file mode 100644
index 0000000..8688df9
--- /dev/null
+++ b/example-config.yaml
@@ -0,0 +1,229 @@
+# 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
+ # If you don’t know what this is, no need to modify(for parse "mention user/reply message, etc")
+ server_name: matrix.to
+
+# 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:29319
+
+ # The hostname and port where this appservice should listen.
+ hostname: 0.0.0.0
+ port: 29319
+
+ # Database config.
+ database:
+ # The database type. "sqlite3" and "postgres" are supported.
+ type: sqlite3
+ # The database URI.
+ # SQLite: File name is enough. https://github.com/mattn/go-sqlite3#connection-string
+ # Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
+ uri: matrix-skype.db
+ # Maximum number of connections. Mostly relevant for Postgres.
+ max_open_conns: 20
+ max_idle_conns: 2
+
+ # Settings for provisioning API
+ provisioning:
+ # Prefix for the provisioning API paths.
+ prefix: /_matrix/provision/v1
+ # Shared secret for authentication. If set to "disable", the provisioning API will be disabled.
+ shared_secret: disable
+
+ # The unique ID of this appservice.
+ id: skype
+ # Appservice bot details.
+ bot:
+ # Username of the appservice bot.
+ username: skypebridgebot
+ # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
+ # to leave display name/avatar as-is.
+ displayname: Skype bridge bot
+ avatar: mxc://matrix.org/kGQUDQyPiwbRXPFkjoBrPyhC
+
+ # 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"
+
+# Bridge config
+bridge:
+ # Localpart template of MXIDs for Skype users.
+ # {{.}} is replaced with the phone number of the Skype user.
+ username_template: skype-{{.}}
+ # Displayname template for Skype users.
+ # {{.Notify}} - nickname set by the Skype user
+ # {{.Jid}} - phone number (international format)
+ # The following variables are also available, but will cause problems on multi-user instances:
+ # {{.Name}} - display name from contact list
+ # {{.Short}} - short display name from contact list
+ # To use multiple if's, you need to use: {{else if .Name}}, for example:
+ # "{{if .Notify}}{{.Notify}}{{else if .Name}}{{.Name}}{{else}}{{.Jid}}{{end}} (WA)"
+ displayname_template: "{{if .DisplayName}}{{.DisplayName}}{{else}}{{.PersonId}}{{end}} (Skype)"
+ # Localpart template for per-user room grouping community IDs.
+ # On startup, the bridge will try to create these communities, add all of the specific user's
+ # portals to the community, and invite the Matrix user to it.
+ # (Note that, by default, non-admins might not have your homeserver's permission to create
+ # communities.)
+ # {{.Localpart}} is the MXID localpart and {{.Server}} is the MXID server part of the user.
+ community_template: skype-{{.Localpart}}={{.Server}}
+
+ # Skype connection timeout in seconds.
+ connection_timeout: 20
+ # If Skype doesn't respond within connection_timeout, should the bridge try to fetch the message
+ # to see if it was actually bridged? Use this if you have problems with sends timing out but actually
+ # succeeding.
+ fetch_message_on_timeout: false
+ # Whether or not the bridge should send a read receipt from the bridge bot when a message has been
+ # sent to Skype. If fetch_message_on_timeout is enabled, a successful post-timeout fetch will
+ # trigger a read receipt too.
+ delivery_receipts: false
+ # Number of times to regenerate QR code when logging in.
+ # The regenerated QR code is sent as an edit and essentially multiplies the login timeout (20 seconds)
+ login_qr_regen_count: 2
+ # Maximum number of times to retry connecting on connection error.
+ max_connection_attempts: 3
+ # Number of seconds to wait between connection attempts.
+ # Negative numbers are exponential backoff: -connection_retry_delay + 1 + 2^attempts
+ connection_retry_delay: -1
+ # Whether or not the bridge should send a notice to the user's management room when it retries connecting.
+ # If false, it will only report when it stops retrying.
+ report_connection_retry: true
+ # Maximum number of seconds to wait for chats to be sent at startup.
+ # If this is too low and you have lots of chats, it could cause backfilling to fail.
+ chat_list_wait: 30
+ # Maximum number of seconds to wait to sync portals before force unlocking message processing.
+ # If this is too low and you have lots of chats, it could cause backfilling to fail.
+ portal_sync_wait: 600
+
+ # Whether or not to send call start/end notices to Matrix.
+ call_notices:
+ start: true
+ end: true
+
+ # Number of chats to sync for new users.
+ # Since some of the obtained conversations are not the conversations that the user needs to see,
+ # the actual number of conversations displayed on the matrix client will be slightly less than the set value
+ initial_chat_sync_count: 10
+ # Number of old messages to fill when creating new portal rooms.
+ initial_history_fill_count: 20
+ # Whether or not notifications should be turned off while filling initial history.
+ # Only applicable when using double puppeting.
+ initial_history_disable_notifications: false
+ # Maximum number of chats to sync when recovering from downtime.
+ # Set to -1 to sync all new chats during downtime.
+ recovery_chat_sync_limit: -1
+ # Whether or not to sync history when recovering from downtime.
+ recovery_history_backfill: true
+ # Maximum number of seconds since last message in chat to skip
+ # syncing the chat in any case. This setting will take priority
+ # over both recovery_chat_sync_limit and initial_chat_sync_count.
+ # Default is 3 days = 259200 seconds
+ sync_max_chat_age: 259200
+
+ # Whether or not to sync with custom puppets to receive EDUs that
+ # are not normally sent to appservices.
+ sync_with_custom_puppets: true
+ # Shared secret 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.
+ login_shared_secret: null
+
+ # Whether or not to invite own Skype user's Matrix puppet into private
+ # chat portals when backfilling if needed.
+ # This always uses the default puppet instead of custom puppets due to
+ # rate limits and timestamp massaging.
+ invite_own_puppet_for_backfilling: true
+ # Whether or not to explicitly set the avatar and room name for private
+ # chat portal rooms. This can be useful if the previous field works fine,
+ # but causes room avatar/name bugs.
+ private_chat_portal_meta: true
+
+ # Whether or not thumbnails from Skype should be sent.
+ # They're disabled by default due to very low resolution.
+ Skype_thumbnail: false
+
+ # Allow invite permission for user. User can invite any bots to room with Skype
+ # users (private chat and groups)
+ allow_user_invite: false
+
+ # The prefix for commands. Only required in non-management rooms.
+ command_prefix: "!wa"
+
+ # End-to-bridge encryption support options. This requires 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.
+ # It is recommended to also set private_chat_portal_meta to true when using this.
+ default: false
+
+ puppet_id:
+ # when set to true, the matrixid of the contact (puppet) from the bridge to the matrix will be encrypted into another string
+ default: false
+ # 8 characters
+ key: '12dsf323'
+ # Use the username_template prefix. (Warning: At present, username_template cannot be too complicated, otherwise this function may cause unknown errors)
+ username_template_prefix: 'skype-'
+
+ # Permissions for using the bridge.
+ # Permitted values:
+ # relaybot - Talk through the relaybot (if enabled), no access otherwise
+ # user - Access to use the bridge to chat with a Skype account.
+ # admin - User level and some additional administration tools
+ # Permitted keys:
+ # * - All Matrix users
+ # domain - All users on that homeserver
+ # mxid - Specific user
+ permissions:
+ "*": relaybot
+ "example.com": user
+ "@admin:example.com": admin
+
+ relaybot:
+ # Whether or not relaybot support is enabled.
+ enabled: false
+ # The management room for the bot. This is where all status notifications are posted and
+ # in this room, you can use `!wa ` instead of `!wa relaybot `. Omitting
+ # the command prefix completely like in user management rooms is not possible.
+ management: !foo:example.com
+ # List of users to invite to all created rooms that include the relaybot.
+ invites: []
+ # The formats to use when sending messages to Skype via the relaybot.
+ message_formats:
+ m.text: "{{ .Sender.Displayname }}: {{ .Message }}"
+ m.notice: "{{ .Sender.Displayname }}: {{ .Message }}"
+ m.emote: "* {{ .Sender.Displayname }} {{ .Message }}"
+ m.file: "{{ .Sender.Displayname }} sent a file"
+ m.image: "{{ .Sender.Displayname }} sent an image"
+ m.audio: "{{ .Sender.Displayname }} sent an audio file"
+ m.video: "{{ .Sender.Displayname }} sent a video"
+ m.location: "{{ .Sender.Displayname }} sent a location"
+
+# Logging config.
+logging:
+ # The directory for log files. Will be created if not found.
+ directory: ./logs
+ # Available variables: .Date for the file date and .Index for different log files on the same day.
+ file_name_format: "{{.Date}}-{{.Index}}.log"
+ # Date format for file names in the Go time format: https://golang.org/pkg/time/#pkg-constants
+ file_date_format: 2006-01-02
+ # Log file permissions.
+ file_mode: 0600
+ # Timestamp format for log entries in the Go time format.
+ timestamp_format: Jan _2, 2006 15:04:05
+ # Minimum severity for log messages.
+ # Options: debug, info, warn, error, fatal
+ print_level: debug
diff --git a/formatting.go b/formatting.go
new file mode 100644
index 0000000..9c8d881
--- /dev/null
+++ b/formatting.go
@@ -0,0 +1,221 @@
+package main
+
+import (
+ "fmt"
+ skypeExt "github.com/kelaresg/matrix-skype/skype-ext"
+ "html"
+ "regexp"
+ "strings"
+
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/format"
+ "maunium.net/go/mautrix/id"
+
+ "github.com/kelaresg/matrix-skype/types"
+)
+
+var italicRegex = regexp.MustCompile("([\\s>~*]|^)_(.+?)_([^a-zA-Z\\d]|$)")
+var boldRegex = regexp.MustCompile("([\\s>_~]|^)\\*(.+?)\\*([^a-zA-Z\\d]|$)")
+var strikethroughRegex = regexp.MustCompile("([\\s>_*]|^)~(.+?)~([^a-zA-Z\\d]|$)")
+var codeBlockRegex = regexp.MustCompile("```(?:.|\n)+?```")
+
+//var mentionRegex = regexp.MustCompile("@[0-9]+")
+//var mentionRegex = regexp.MustCompile("@(.*)")
+var mentionRegex = regexp.MustCompile("]+\\bid=\"([^\"]+)\"(.*?)*")
+
+type Formatter struct {
+ bridge *Bridge
+
+ matrixHTMLParser *format.HTMLParser
+
+ waReplString map[*regexp.Regexp]string
+ waReplFunc map[*regexp.Regexp]func(string) string
+ waReplFuncText map[*regexp.Regexp]func(string) string
+}
+
+func NewFormatter(bridge *Bridge) *Formatter {
+ formatter := &Formatter{
+ bridge: bridge,
+ matrixHTMLParser: &format.HTMLParser{
+ TabsToSpaces: 4,
+ Newline: "\n",
+
+ PillConverter: func(mxid, eventID string, ctx format.Context) string {
+ if mxid[0] == '@' {
+ puppet := bridge.GetPuppetByMXID(id.UserID(mxid))
+ if puppet != nil {
+ return "@" + puppet.PhoneNumber()
+ }
+ }
+ return mxid
+ },
+ BoldConverter: func(text string, _ format.Context) string {
+ return fmt.Sprintf("*%s*", text)
+ },
+ ItalicConverter: func(text string, _ format.Context) string {
+ return fmt.Sprintf("_%s_", text)
+ },
+ StrikethroughConverter: func(text string, _ format.Context) string {
+ return fmt.Sprintf("~%s~", text)
+ },
+ MonospaceConverter: func(text string, _ format.Context) string {
+ return fmt.Sprintf("```%s```", text)
+ },
+ MonospaceBlockConverter: func(text, language string, _ format.Context) string {
+ return fmt.Sprintf("```%s```", text)
+ },
+ },
+ waReplString: map[*regexp.Regexp]string{
+ italicRegex: "$1$2$3",
+ boldRegex: "$1$2$3",
+ strikethroughRegex: "$1$2$3",
+ },
+ }
+ formatter.waReplFunc = map[*regexp.Regexp]func(string) string{
+ codeBlockRegex: func(str string) string {
+ str = str[3 : len(str)-3]
+ if strings.ContainsRune(str, '\n') {
+ return fmt.Sprintf("
%s
", str)
+ }
+ return fmt.Sprintf("%s", str)
+ },
+ }
+ formatter.waReplFuncText = map[*regexp.Regexp]func(string) string{
+ }
+ return formatter
+}
+
+func (formatter *Formatter) getMatrixInfoByJID(jid types.SkypeID) (mxid id.UserID, displayname string) {
+ if user := formatter.bridge.GetUserByJID(jid); user != nil {
+ mxid = user.MXID
+ displayname = string(user.MXID)
+ } else if puppet := formatter.bridge.GetPuppetByJID(jid); puppet != nil {
+ mxid = puppet.MXID
+ displayname = puppet.Displayname
+ }
+ return
+}
+
+func (formatter *Formatter) ParseSkype(content *event.MessageEventContent, RoomMXID id.RoomID) {
+ // parse '' tag
+ reg:= regexp.MustCompile(`(?U)((.*))`)
+ bodyMatch := reg.FindAllStringSubmatch(content.Body, -1)
+ for _, match := range bodyMatch {
+ content.Body = strings.ReplaceAll(content.Body, match[1], match[2])
+ }
+
+ output := content.Body
+ for regex, replacement := range formatter.waReplString {
+ output = regex.ReplaceAllString(output, replacement)
+ }
+ for regex, replacer := range formatter.waReplFunc {
+ output = regex.ReplaceAllStringFunc(output, replacer)
+ }
+ content.Body = html.UnescapeString(content.Body)
+
+ var backStr string
+ if output != content.Body {
+ output = strings.Replace(output, "\n", " ", -1)
+ content.FormattedBody = output
+ content.Format = event.FormatHTML
+ var mxid id.UserID
+
+ // parse quote message(set reply)
+ content.Body = strings.ReplaceAll(content.Body, "\n", "")
+ quoteReg := regexp.MustCompile(`]+\bauthor="([^"]+)" authorname="([^"]+)" timestamp="([^"]+)" conversation="([^"]+)" messageid="([^"]+)".*>.*?(.*?).*?(.*)`)
+ quoteMatches := quoteReg.FindAllStringSubmatch(content.Body, -1)
+
+ if len(quoteMatches) > 0 {
+ for _, match := range quoteMatches {
+ for index, a := range match {
+ fmt.Println("index: ", index)
+ fmt.Println("ParseSkype quoteMatches a:", a)
+ fmt.Println()
+ }
+ portal := formatter.bridge.GetPortalByMXID(RoomMXID)
+ if portal.Key.JID != match[4] {
+ content.FormattedBody = match[6]
+ content.Body = fmt.Sprintf("%s\n\n", match[6])
+
+ // this means that there are forwarding messages across groups
+ if strings.HasSuffix(match[4], skypeExt.GroupSuffix) || strings.HasSuffix(portal.Key.JID, skypeExt.GroupSuffix){
+ continue
+ }
+ }
+ msgMXID := ""
+ msg := formatter.bridge.DB.Message.GetByID(match[5])
+ if msg != nil {
+ msgMXID = string(msg.MXID)
+ }
+ mxid, _ = formatter.getMatrixInfoByJID("8:" + match[1] + skypeExt.NewUserSuffix)
+ href1 := fmt.Sprintf(`https://%s/#/room/%s/%s?via=%s`, formatter.bridge.Config.Homeserver.ServerName, RoomMXID, msgMXID, formatter.bridge.Config.Homeserver.Domain)
+ href2 := fmt.Sprintf(`https://%s/#/user/%s`, formatter.bridge.Config.Homeserver.ServerName, mxid)
+ newContent := fmt.Sprintf(`