From a5a50f43e0579204e95fc6339b940c737b06d772 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 28 Sep 2020 02:03:47 +0300 Subject: [PATCH] Initial commit --- .dockerignore | 8 + .editorconfig | 18 + .gitignore | 18 + .gitlab-ci.yml | 41 ++ Dockerfile | 48 ++ LICENSE | 661 +++++++++++++++++++++++++ MANIFEST.in | 4 + README.md | 14 + ROADMAP.md | 43 ++ docker-run.sh | 30 ++ mausignald/README.md | 2 + mausignald/__init__.py | 1 + mausignald/errors.py | 44 ++ mausignald/rpc.py | 164 ++++++ mausignald/signald.py | 136 +++++ mausignald/types.py | 183 +++++++ mautrix_signal/__init__.py | 2 + mautrix_signal/__main__.py | 106 ++++ mautrix_signal/commands/__init__.py | 2 + mautrix_signal/commands/auth.py | 92 ++++ mautrix_signal/commands/conn.py | 43 ++ mautrix_signal/commands/handler.py | 98 ++++ mautrix_signal/config.py | 101 ++++ mautrix_signal/db/__init__.py | 16 + mautrix_signal/db/message.py | 98 ++++ mautrix_signal/db/portal.py | 92 ++++ mautrix_signal/db/puppet.py | 105 ++++ mautrix_signal/db/reaction.py | 91 ++++ mautrix_signal/db/upgrade.py | 91 ++++ mautrix_signal/db/user.py | 65 +++ mautrix_signal/example-config.yaml | 189 +++++++ mautrix_signal/get_version.py | 50 ++ mautrix_signal/matrix.py | 142 ++++++ mautrix_signal/portal.py | 597 ++++++++++++++++++++++ mautrix_signal/puppet.py | 292 +++++++++++ mautrix_signal/signal.py | 118 +++++ mautrix_signal/user.py | 155 ++++++ mautrix_signal/util/__init__.py | 1 + mautrix_signal/util/color_log.py | 25 + mautrix_signal/version.py | 1 + mautrix_signal/web/__init__.py | 1 + mautrix_signal/web/provisioning_api.py | 114 +++++ optional-requirements.txt | 17 + requirements.txt | 8 + setup.py | 69 +++ 45 files changed, 4196 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100755 docker-run.sh create mode 100644 mausignald/README.md create mode 100644 mausignald/__init__.py create mode 100644 mausignald/errors.py create mode 100644 mausignald/rpc.py create mode 100644 mausignald/signald.py create mode 100644 mausignald/types.py create mode 100644 mautrix_signal/__init__.py create mode 100644 mautrix_signal/__main__.py create mode 100644 mautrix_signal/commands/__init__.py create mode 100644 mautrix_signal/commands/auth.py create mode 100644 mautrix_signal/commands/conn.py create mode 100644 mautrix_signal/commands/handler.py create mode 100644 mautrix_signal/config.py create mode 100644 mautrix_signal/db/__init__.py create mode 100644 mautrix_signal/db/message.py create mode 100644 mautrix_signal/db/portal.py create mode 100644 mautrix_signal/db/puppet.py create mode 100644 mautrix_signal/db/reaction.py create mode 100644 mautrix_signal/db/upgrade.py create mode 100644 mautrix_signal/db/user.py create mode 100644 mautrix_signal/example-config.yaml create mode 100644 mautrix_signal/get_version.py create mode 100644 mautrix_signal/matrix.py create mode 100644 mautrix_signal/portal.py create mode 100644 mautrix_signal/puppet.py create mode 100644 mautrix_signal/signal.py create mode 100644 mautrix_signal/user.py create mode 100644 mautrix_signal/util/__init__.py create mode 100644 mautrix_signal/util/color_log.py create mode 100644 mautrix_signal/version.py create mode 100644 mautrix_signal/web/__init__.py create mode 100644 mautrix_signal/web/provisioning_api.py create mode 100644 optional-requirements.txt create mode 100644 requirements.txt create mode 100644 setup.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b7c2055 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.editorconfig +logs +.venv +start +config.yaml +registration.yaml +*.db +*.pickle diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6342bef --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.py] +max_line_length = 99 + +[*.{yaml,yml,py}] +indent_style = space + +[.gitlab-ci.yml] +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b20ee4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +/.idea/ + +/.venv +/env/ +pip-selfcheck.json +*.pyc +__pycache__ +/build +/dist +/*.egg-info +/.eggs + +/config.yaml +/registration.yaml +*.log* +*.db +*.pickle +*.bak diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..d690654 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,41 @@ +image: docker:stable + +stages: +- build +- manifest + +default: + before_script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + +build amd64: + stage: build + tags: + - amd64 + script: + - docker pull $CI_REGISTRY_IMAGE:latest || true + - docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=amd64 --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 + - docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 + +build arm64: + stage: build + tags: + - arm64 + script: + - docker pull $CI_REGISTRY_IMAGE:latest || true + - docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=arm64 --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 + - docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 + +manifest: + stage: manifest + before_script: + - "mkdir -p $HOME/.docker && echo '{\"experimental\": \"enabled\"}' > $HOME/.docker/config.json" + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + script: + - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 + - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 + - if [ "$CI_COMMIT_BRANCH" = "master" ]; then docker manifest create $CI_REGISTRY_IMAGE:latest $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 && docker manifest push $CI_REGISTRY_IMAGE:latest; fi + - if [ "$CI_COMMIT_BRANCH" != "master" ]; then docker manifest create $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 && docker manifest push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME; fi + - docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0bc68d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +FROM alpine:3.12 + +ARG TARGETARCH=amd64 + +RUN echo $'\ +@edge http://dl-cdn.alpinelinux.org/alpine/edge/main\n\ +@edge http://dl-cdn.alpinelinux.org/alpine/edge/testing\n\ +@edge http://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories + +RUN apk add --no-cache \ + python3 py3-pip py3-setuptools py3-wheel \ + py3-virtualenv \ + py3-pillow \ + py3-aiohttp \ + py3-magic \ + py3-ruamel.yaml \ + py3-commonmark@edge \ + # Other dependencies + ca-certificates \ + su-exec \ + # encryption + olm-dev \ + py3-cffi \ + py3-pycryptodome \ + py3-unpaddedbase64 \ + py3-future \ + bash \ + curl \ + jq && \ + curl -sLo yq https://github.com/mikefarah/yq/releases/download/3.3.2/yq_linux_${TARGETARCH} && \ + chmod +x yq && mv yq /usr/bin/yq + +COPY requirements.txt /opt/mautrix-signal/requirements.txt +COPY optional-requirements.txt /opt/mautrix-signal/optional-requirements.txt +WORKDIR /opt/mautrix-signal +RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \ + && pip3 install -r requirements.txt -r optional-requirements.txt \ + && apk del .build-deps + +COPY . /opt/mautrix-signal +RUN apk add git && pip3 install .[all] && apk del git \ + # This doesn't make the image smaller, but it's needed so that the `version` command works properly + && cp mautrix_signal/example-config.yaml . && rm -rf mautrix_signal + +VOLUME /data +ENV UID=1337 GID=1337 + +CMD ["/opt/mautrix-signal/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/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..daa36da --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include README.md +include LICENSE +include requirements.txt +include optional-requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..5707dfc --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# mautrix-signal +![Languages](https://img.shields.io/github/languages/top/tulir/mautrix-signal.svg) +[![License](https://img.shields.io/github/license/tulir/mautrix-signal.svg)](LICENSE) +[![Release](https://img.shields.io/github/release/tulir/mautrix-signal/all.svg)](https://github.com/tulir/mautrix-signal/releases) +[![GitLab CI](https://mau.dev/tulir/mautrix-signal/badges/master/pipeline.svg)](https://mau.dev/tulir/mautrix-signal/container_registry) + +A Matrix-Signal puppeting bridge. + +### [Wiki](https://github.com/tulir/mautrix-signal/wiki) + +### [Features & Roadmap](https://github.com/tulir/mautrix-signal/blob/master/ROADMAP.md) + +## Discussion +Matrix room: [`#signal:maunium.net`](https://matrix.to/#/#signal:maunium.net) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..198d476 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,43 @@ +# Features & roadmap + +* Matrix → Signal + * [ ] Message content + * [x] Text + * [ ] ‡Formatting + * [ ] Media + * [ ] Images + * [ ] Files + * [ ] Gifs + * [ ] Locations + * [ ] †Stickers + * [x] Message reactions + * [ ] Typing notifications + * [ ] Read receipts +* Signal → Matrix + * [ ] Message content + * [x] Text + * [ ] Media + * [ ] Images + * [ ] Files + * [ ] Gifs + * [ ] Contacts + * [ ] Locations + * [ ] Stickers + * [x] Message reactions + * [ ] †User and group avatars + * [ ] Typing notifications + * [x] Read receipts + * [ ] Disappearing messages +* Misc + * [x] Automatic portal creation + * [x] At startup + * [ ] When receiving message + * [ ] Provisioning API for logging in + * [ ] Private chat creation by inviting Matrix puppet of Signal user to new room + * [ ] Option to use own Matrix account for messages sent from other Signal clients + * [ ] Automatic login with shared secret + * [ ] Manual login with `login-matrix` + * [x] E2EE in Matrix rooms + +† Not possible in signald +‡ Not possible in Signal diff --git a/docker-run.sh b/docker-run.sh new file mode 100755 index 0000000..3a47960 --- /dev/null +++ b/docker-run.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +# Define functions. +function fixperms { + chown -R $UID:$GID /data /opt/mautrix-signal +} + +cd /opt/mautrix-signal + +if [ ! -f /data/config.yaml ]; then + cp 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." + fixperms + exit +fi + +if [ ! -f /data/registration.yaml ]; then + python3 -m mautrix_signal -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." + fixperms + exit +fi + +fixperms +exec su-exec $UID:$GID python3 -m mautrix_signal -c /data/config.yaml diff --git a/mausignald/README.md b/mausignald/README.md new file mode 100644 index 0000000..1f6847c --- /dev/null +++ b/mausignald/README.md @@ -0,0 +1,2 @@ +# mausignald +A Python/Asyncio library to communicate with [signald](https://gitlab.com/thefinn93/signald). diff --git a/mausignald/__init__.py b/mausignald/__init__.py new file mode 100644 index 0000000..ad4fa69 --- /dev/null +++ b/mausignald/__init__.py @@ -0,0 +1 @@ +from .signald import SignaldClient diff --git a/mausignald/errors.py b/mausignald/errors.py new file mode 100644 index 0000000..165655e --- /dev/null +++ b/mausignald/errors.py @@ -0,0 +1,44 @@ +# Copyright (c) 2020 Tulir Asokan +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +from typing import Any, Dict + + +class RPCError(Exception): + pass + + +class UnexpectedError(RPCError): + pass + + +class UnexpectedResponse(RPCError): + def __init__(self, resp_type: str, data: Any) -> None: + super().__init__(f"Got unexpected response type {resp_type}") + self.resp_type = resp_type + self.data = data + + +class LinkingError(RPCError): + def __init__(self, message: str, number: int) -> None: + super().__init__(message) + self.number = number + + +class LinkingTimeout(LinkingError): + pass + + +class LinkingConflict(LinkingError): + pass + + +def make_linking_error(data: Dict[str, Any]) -> LinkingError: + message = data["message"] + msg_number = data.get("msg_number") + return { + 1: LinkingTimeout, + 3: LinkingConflict, + }.get(msg_number, LinkingError)(message, msg_number) diff --git a/mausignald/rpc.py b/mausignald/rpc.py new file mode 100644 index 0000000..6346888 --- /dev/null +++ b/mausignald/rpc.py @@ -0,0 +1,164 @@ +# Copyright (c) 2020 Tulir Asokan +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +from typing import Optional, Dict, List, Callable, Awaitable, Any, Tuple +from uuid import UUID, uuid4 +import asyncio +import logging +import json + +from mautrix.util.logging import TraceLogger + +from .errors import UnexpectedError, UnexpectedResponse + +EventHandler = Callable[[Dict[str, Any]], Awaitable[None]] + + +class SignaldRPCClient: + loop: asyncio.AbstractEventLoop + log: TraceLogger + + socket_path: str + _reader: Optional[asyncio.StreamReader] + _writer: Optional[asyncio.StreamWriter] + + _response_waiters: Dict[UUID, asyncio.Future] + _rpc_event_handlers: Dict[str, List[EventHandler]] + + def __init__(self, socket_path: str, log: Optional[TraceLogger] = None, + loop: Optional[asyncio.AbstractEventLoop] = None) -> None: + self.socket_path = socket_path + self.log = log or logging.getLogger("mausignald") + self.loop = loop or asyncio.get_event_loop() + self._reader = None + self._writer = None + self._response_waiters = {} + self._rpc_event_handlers = {} + + async def connect(self) -> None: + if self._writer is not None: + return + + self._reader, self._writer = await asyncio.open_unix_connection(self.socket_path) + self.loop.create_task(self._try_read_loop()) + + async def disconnect(self) -> None: + self._writer.write_eof() + await self._writer.drain() + self._writer = None + self._reader = None + + def add_rpc_handler(self, method: str, handler: EventHandler) -> None: + self._rpc_event_handlers.setdefault(method, []).append(handler) + + def remove_rpc_handler(self, method: str, handler: EventHandler) -> None: + self._rpc_event_handlers.setdefault(method, []).remove(handler) + + async def _run_rpc_handler(self, command: str, req: Dict[str, Any]) -> None: + try: + handlers = self._rpc_event_handlers[command] + except KeyError: + self.log.warning("No handlers for RPC request %s", command) + self.log.trace("Data unhandled request: %s", req) + else: + for handler in handlers: + try: + await handler(req) + except Exception: + self.log.exception("Exception in RPC event handler") + + async def _run_response_handlers(self, req_id: UUID, command: str, data: Any) -> None: + try: + waiter = self._response_waiters.pop(req_id) + except KeyError: + self.log.debug(f"Nobody waiting for response to {req_id}") + return + if command == "unexpected_error": + try: + waiter.set_exception(UnexpectedError(data["message"])) + except KeyError: + waiter.set_exception(UnexpectedError("Unexpected error with no message")) + else: + waiter.set_result((command, data)) + + async def _handle_incoming_line(self, line: str) -> None: + try: + req = json.loads(line) + except json.JSONDecodeError: + self.log.debug(f"Got non-JSON data from server: {line}") + return + try: + req_type = req["type"] + except KeyError: + self.log.debug(f"Got invalid request from server: {line}") + return + + self.log.trace("Got data from server: %s", req) + + req_id = req.get("id") + if req_id is None: + await self._run_rpc_handler(req_type, req) + else: + await self._run_response_handlers(UUID(req_id), req_type, req.get("data")) + + async def _try_read_loop(self) -> None: + try: + await self._read_loop() + except Exception: + self.log.exception("Fatal error in read loop") + + async def _read_loop(self) -> None: + while self._reader is not None and not self._reader.at_eof(): + line = await self._reader.readline() + if not line: + continue + try: + line_str = line.decode("utf-8") + except UnicodeDecodeError: + self.log.exception("Got non-unicode request from server: %s", line) + continue + try: + await self._handle_incoming_line(line_str) + except Exception: + self.log.exception("Failed to handle incoming request %s", line_str) + self.log.debug("Reader disconnected") + self._reader = None + self._writer = None + + def _create_request(self, command: str, req_id: Optional[UUID] = None, **data: Any + ) -> Tuple[asyncio.Future, Dict[str, Any]]: + req_id = req_id or uuid4() + req = {"id": str(req_id), "type": command, **data} + self.log.trace("Request %s: %s %s", req_id, command, data) + return self._wait_response(req_id), req + + def _wait_response(self, req_id: UUID) -> asyncio.Future: + try: + future = self._response_waiters[req_id] + except KeyError: + future = self._response_waiters[req_id] = self.loop.create_future() + return future + + async def _send_request(self, data: Dict[str, Any]) -> None: + self._writer.write(json.dumps(data).encode("utf-8")) + self._writer.write(b"\n") + await self._writer.drain() + self.log.trace("Sent data to server server: %s", data) + + async def _raw_request(self, command: str, req_id: Optional[UUID] = None, **data: Any + ) -> Tuple[str, Dict[str, Any]]: + future, data = self._create_request(command, req_id, **data) + await self._send_request(data) + return await future + + async def request(self, command: str, expected_response: str, **data: Any) -> Any: + resp_type, resp_data = await self._raw_request(command, **data) + if resp_type != expected_response: + raise UnexpectedResponse(resp_type, resp_data) + return resp_data + + async def request_nowait(self, command: str, **data: Any) -> None: + _, req = self._create_request(command, **data) + await self._send_request(req) diff --git a/mausignald/signald.py b/mausignald/signald.py new file mode 100644 index 0000000..cb43c21 --- /dev/null +++ b/mausignald/signald.py @@ -0,0 +1,136 @@ +# Copyright (c) 2020 Tulir Asokan +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +from typing import Union, Optional, List, Dict, Any, Callable, Awaitable, TypeVar, Type +from uuid import uuid4 +import asyncio + +from mautrix.util.logging import TraceLogger + +from .rpc import SignaldRPCClient +from .errors import UnexpectedError, UnexpectedResponse, make_linking_error +from .types import (Address, Quote, Attachment, Reaction, Account, Message, Contact, FullGroup, + Profile) + +T = TypeVar('T') +EventHandler = Callable[[T], Awaitable[None]] + + +class SignaldClient(SignaldRPCClient): + _event_handlers: Dict[Type[T], List[EventHandler]] + + def __init__(self, socket_path: str = "/var/run/signald/signald.sock", + log: Optional[TraceLogger] = None, + loop: Optional[asyncio.AbstractEventLoop] = None) -> None: + super().__init__(socket_path, log, loop) + self._event_handlers = {} + self.add_rpc_handler("message", self._parse_message) + + def add_event_handler(self, event_class: Type[T], handler: EventHandler) -> None: + self._event_handlers.setdefault(event_class, []).append(handler) + + def remove_event_handler(self, event_class: Type[T], handler: EventHandler) -> None: + self._event_handlers.setdefault(event_class, []).remove(handler) + + async def _run_event_handler(self, event: T) -> None: + try: + handlers = self._event_handlers[type(event)] + except KeyError: + self.log.warning(f"No handlers for {type(event)}") + else: + for handler in handlers: + try: + await handler(event) + except Exception: + self.log.exception("Exception in event handler") + + async def _parse_message(self, data: Dict[str, Any]) -> None: + event_type = data["type"] + event_data = data["data"] + event_class = { + "message": Message, + }[event_type] + event = event_class.deserialize(event_data) + await self._run_event_handler(event) + + async def subscribe(self, username: str) -> bool: + try: + await self.request("subscribe", "subscribed", username=username) + return True + except UnexpectedError as e: + self.log.debug("Failed to subscribe to %s: %s", username, e) + return False + + async def link(self, url_callback: Callable[[str], Awaitable[None]], + device_name: str = "mausignald") -> Account: + req_id = uuid4() + resp_type, resp = await self._raw_request("link", req_id, deviceName=device_name) + if resp_type == "linking_error": + raise make_linking_error(resp) + elif resp_type != "linking_uri": + raise UnexpectedResponse(resp_type, resp) + + self.loop.create_task(url_callback(resp["uri"])) + + resp_type, resp = await self._wait_response(req_id) + if resp_type == "linking_error": + raise make_linking_error(resp) + elif resp_type != "linking_successful": + raise UnexpectedResponse(resp_type, resp) + + return Account.deserialize(resp) + + async def list_accounts(self) -> List[Account]: + data = await self.request("list_accounts", "account_list") + return [Account.deserialize(acc) for acc in data["accounts"]] + + @staticmethod + def _recipient_to_args(recipient: Union[Address, str]) -> Dict[str, Any]: + if isinstance(recipient, Address): + return {"recipientAddress": recipient.serialize()} + else: + return {"recipientGroupId": recipient} + + async def react(self, username: str, recipient: Union[Address, str], + reaction: Reaction) -> None: + await self.request("react", "send_results", username=username, + reaction=reaction.serialize(), + **self._recipient_to_args(recipient)) + + async def send(self, username: str, recipient: Union[Address, str], body: str, + quote: Optional[Quote] = None, attachments: Optional[List[Attachment]] = None, + timestamp: Optional[int] = None) -> None: + serialized_quote = quote.serialize() if quote else None + serialized_attachments = [attachment.serialize() for attachment in (attachments or [])] + await self.request("send", "send_results", username=username, messageBody=body, + attachments=serialized_attachments, quote=serialized_quote, + timestamp=timestamp, **self._recipient_to_args(recipient)) + # TODO return something? + + async def mark_read(self, username: str, sender: Address, timestamps: List[int], + when: Optional[int] = None) -> None: + await self.request_nowait("mark_read", username=username, timestamps=timestamps, when=when, + recipientAddress=sender.serialize()) + + async def list_contacts(self, username: str) -> List[Contact]: + contacts = await self.request("list_contacts", "contact_list", username=username) + return [Contact.deserialize(contact) for contact in contacts] + + async def list_groups(self, username: str) -> List[FullGroup]: + resp = await self.request("list_groups", "group_list", username=username) + return [FullGroup.deserialize(group) for group in resp["groups"]] + + async def get_profile(self, username: str, address: Address) -> Optional[Profile]: + try: + resp = await self.request("get_profile", "profile", username=username, + recipientAddress=address.serialize()) + except UnexpectedResponse as e: + if e.resp_type == "profile_not_available": + return None + raise + return Profile.deserialize(resp) + + async def set_profile(self, username: str, new_name: str) -> None: + await self.request("set_profile", "profile_set", username=username, name=new_name) diff --git a/mausignald/types.py b/mausignald/types.py new file mode 100644 index 0000000..ed1eb3d --- /dev/null +++ b/mausignald/types.py @@ -0,0 +1,183 @@ +# Copyright (c) 2020 Tulir Asokan +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +from typing import Optional, Dict, Any, List +from uuid import UUID + +from attr import dataclass +import attr + +from mautrix.types import SerializableAttrs, SerializableEnum + + +@dataclass +class Account(SerializableAttrs['Account']): + device_id: int = attr.ib(metadata={"json": "deviceId"}) + username: str + filename: str + registered: bool + has_keys: bool + subscribed: bool + uuid: Optional[UUID] = None + + +@dataclass +class Address(SerializableAttrs['Address']): + number: Optional[str] = None + uuid: Optional[UUID] = None + + @property + def is_valid(self) -> bool: + return bool(self.number) or bool(self.uuid) + + +@dataclass +class Contact(SerializableAttrs['Contact']): + address: Address + name: Optional[str] = None + color: Optional[str] = None + profile_key: Optional[str] = attr.ib(default=None, metadata={"json": "profileKey"}) + message_expiration_time: int = attr.ib(default=0, metadata={"json": "messageExpirationTime"}) + + +@dataclass +class Profile(SerializableAttrs['Profile']): + name: str + avatar: str + identity_key: str + unidentified_access: str + unrestricted_unidentified_access: bool + + +@dataclass +class Group(SerializableAttrs['Group']): + group_id: str = attr.ib(metadata={"json": "groupId"}) + name: str + type: Optional[str] = None + + +@dataclass +class FullGroup(Group, SerializableAttrs['FullGroup']): + members: List[Address] = attr.ib(factory=lambda: []) + avatar_id: int = attr.ib(default=0, metadata={"json": "avatarId"}) + + +@dataclass +class Attachment(SerializableAttrs['Attachment']): + filename: str + caption: Optional[str] = None + width: Optional[int] = None + height: Optional[int] = None + voice_note: Optional[bool] = attr.ib(default=None, metadata={"json": "voiceNote"}) + preview: Optional[str] = None + + +@dataclass +class Quote(SerializableAttrs['Quote']): + id: int + author: Address + text: str + # TODO: attachments, mentions + + +@dataclass +class Reaction(SerializableAttrs['Reaction']): + emoji: str + remove: bool + target_author: Address = attr.ib(metadata={"json": "targetAuthor"}) + target_sent_timestamp: int = attr.ib(metadata={"json": "targetSentTimestamp"}) + + +@dataclass +class MessageData(SerializableAttrs['MessageData']): + timestamp: int + + body: Optional[str] = None + quote: Optional[Quote] = None + reaction: Optional[Reaction] = None + # TODO attachments, mentions + + group: Optional[Group] = None + + end_session: bool = attr.ib(default=False, metadata={"json": "endSession"}) + expires_in_seconds: int = attr.ib(default=0, metadata={"json": "expiresInSeconds"}) + profile_key_update: bool = attr.ib(default=False, metadata={"json": "profileKeyUpdate"}) + view_once: bool = attr.ib(default=False, metadata={"json": "viewOnce"}) + + +@dataclass +class SentSyncMessage(SerializableAttrs['SentSyncMessage']): + message: MessageData + timestamp: int + expiration_start_timestamp: int = attr.ib(metadata={"json": "expirationStartTimestamp"}) + is_recipient_update: bool = attr.ib(default=False, metadata={"json": "isRecipientUpdate"}) + unidentified_status: Dict[str, bool] = attr.ib(factory=lambda: {}) + destination: Optional[Address] = None + + +class TypingAction(SerializableEnum): + STARTED = "STARTED" + STOPPED = "STOPPED" + + +@dataclass +class TypingNotification(SerializableAttrs['TypingNotification']): + action: TypingAction + timestamp: int + group_id: Optional[str] = None + + +@dataclass +class OwnReadReceipt(SerializableAttrs['OwnReadReceipt']): + sender: Address + timestamp: int + + +class ReceiptType(SerializableEnum): + DELIVERY = "DELIVERY" + READ = "READ" + + +@dataclass +class Receipt(SerializableAttrs['Receipt']): + type: ReceiptType + timestamps: List[int] + when: int + + +@dataclass +class SyncMessage(SerializableAttrs['SyncMessage']): + sent: Optional[SentSyncMessage] = None + typing: Optional[TypingNotification] = None + read_messages: Optional[List[OwnReadReceipt]] = attr.ib(default=None, metadata={"json": "readMessages"}) + contacts: Optional[Dict[str, Any]] = None + contacts_complete: bool = attr.ib(default=False, metadata={"json": "contactsComplete"}) + + +class MessageType(SerializableEnum): + CIPHERTEXT = "CIPHERTEXT" + UNIDENTIFIED_SENDER = "UNIDENTIFIED_SENDER" + RECEIPT = "RECEIPT" + + +@dataclass +class Message(SerializableAttrs['Message']): + username: str + source: Address + timestamp: int + timestamp_iso: str = attr.ib(metadata={"json": "timestampISO"}) + + type: MessageType + source_device: int = attr.ib(metadata={"json": "sourceDevice"}) + server_timestamp: int = attr.ib(metadata={"json": "serverTimestamp"}) + server_delivered_timestamp: int = attr.ib(metadata={"json": "serverDeliveredTimestamp"}) + has_content: bool = attr.ib(metadata={"json": "hasContent"}) + is_unidentified_sender: bool = attr.ib(metadata={"json": "isUnidentifiedSender"}) + has_legacy_message: bool = attr.ib(default=False, metadata={"json": "hasLegacyMessage"}) + + data_message: Optional[MessageData] = attr.ib(default=None, metadata={"json": "dataMessage"}) + sync_message: Optional[SyncMessage] = attr.ib(default=None, metadata={"json": "syncMessage"}) + typing: Optional[TypingNotification] = None + receipt: Optional[Receipt] = None diff --git a/mautrix_signal/__init__.py b/mautrix_signal/__init__.py new file mode 100644 index 0000000..c732749 --- /dev/null +++ b/mautrix_signal/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.1.0" +__author__ = "Tulir Asokan " diff --git a/mautrix_signal/__main__.py b/mautrix_signal/__main__.py new file mode 100644 index 0000000..cfedb89 --- /dev/null +++ b/mautrix_signal/__main__.py @@ -0,0 +1,106 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from mautrix.bridge import Bridge +from mautrix.bridge.state_store.asyncpg import PgBridgeStateStore +from mautrix.types import RoomID, UserID +from mautrix.util.async_db import Database + +from .version import version, linkified_version +from .config import Config +from .db import upgrade_table, init as init_db +from .matrix import MatrixHandler +from .signal import SignalHandler +from .user import User +from .portal import Portal +from .puppet import Puppet +from .web import ProvisioningAPI + + +class SignalBridge(Bridge): + module = "mautrix_signal" + name = "mautrix-signal" + command = "python -m mautrix-signal" + description = "A Matrix-Signal puppeting bridge." + repo_url = "https://github.com/tulir/mautrix-signal" + real_user_content_key = "net.maunium.signal.puppet" + version = version + markdown_version = linkified_version + config_class = Config + matrix_class = MatrixHandler + + db: Database + matrix: MatrixHandler + signal: SignalHandler + config: Config + state_store: PgBridgeStateStore + provisioning_api: ProvisioningAPI + + def make_state_store(self) -> None: + self.state_store = PgBridgeStateStore(self.db, self.get_puppet, self.get_double_puppet) + + def prepare_db(self) -> None: + self.db = Database(self.config["appservice.database"], upgrade_table=upgrade_table, + loop=self.loop) + init_db(self.db) + + def prepare_bridge(self) -> None: + super().prepare_bridge() + cfg = self.config["appservice.provisioning"] + # self.provisioning_api = ProvisioningAPI(cfg["shared_secret"]) + # self.az.app.add_subapp(cfg["prefix"], self.provisioning_api.app) + self.signal = SignalHandler(self) + + async def start(self) -> None: + await self.db.start() + await self.state_store.upgrade_table.upgrade(self.db.pool) + User.init_cls(self) + self.add_startup_actions(Puppet.init_cls(self)) + Portal.init_cls(self) + if self.config["bridge.resend_bridge_info"]: + self.add_startup_actions(self.resend_bridge_info()) + self.add_startup_actions(self.signal.start()) + await super().start() + + def prepare_stop(self) -> None: + self.add_shutdown_actions(self.signal.stop()) + for puppet in Puppet.by_custom_mxid.values(): + puppet.stop() + + async def resend_bridge_info(self) -> None: + self.config["bridge.resend_bridge_info"] = False + self.config.save() + self.log.info("Re-sending bridge info state event to all portals") + async for portal in Portal.all_with_room(): + await portal.update_bridge_info() + self.log.info("Finished re-sending bridge info state events") + + async def get_user(self, user_id: UserID) -> User: + return await User.get_by_mxid(user_id) + + async def get_portal(self, room_id: RoomID) -> Portal: + return await Portal.get_by_mxid(room_id) + + async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet: + return await Puppet.get_by_mxid(user_id, create=create) + + async def get_double_puppet(self, user_id: UserID) -> Puppet: + return await Puppet.get_by_custom_mxid(user_id) + + def is_bridge_ghost(self, user_id: UserID) -> bool: + return bool(Puppet.get_id_from_mxid(user_id)) + + +SignalBridge().run() diff --git a/mautrix_signal/commands/__init__.py b/mautrix_signal/commands/__init__.py new file mode 100644 index 0000000..69e3a3c --- /dev/null +++ b/mautrix_signal/commands/__init__.py @@ -0,0 +1,2 @@ +from .handler import (CommandProcessor, command_handler, CommandEvent, CommandHandler, SECTION_AUTH, SECTION_CONNECTION) +from . import auth, conn diff --git a/mautrix_signal/commands/auth.py b/mautrix_signal/commands/auth.py new file mode 100644 index 0000000..546f465 --- /dev/null +++ b/mautrix_signal/commands/auth.py @@ -0,0 +1,92 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import io + +from mausignald.errors import UnexpectedResponse +from mausignald.types import Account + +from mautrix.types import MediaMessageEventContent, MessageType, ImageInfo + +from . import command_handler, CommandEvent, SECTION_AUTH +from .. import puppet as pu + +try: + import qrcode + import PIL as _ +except ImportError: + qrcode = None + + +@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, + help_text="Link the bridge as a secondary device", help_args="[device name]") +async def link(evt: CommandEvent) -> None: + if qrcode is None: + await evt.reply("Can't generate QR code: qrcode and/or PIL not installed") + return + # TODO make default device name configurable + device_name = " ".join(evt.args) or "Mautrix-Signal bridge" + + async def callback(uri: str) -> None: + buffer = io.BytesIO() + image = qrcode.make(uri) + size = image.pixel_size + image.save(buffer, "PNG") + qr = buffer.getvalue() + mxc = await evt.az.intent.upload_media(qr, "image/png", "link-qr.png", len(qr)) + content = MediaMessageEventContent(body=uri, url=mxc, msgtype=MessageType.IMAGE, + info=ImageInfo(mimetype="image/png", size=len(qr), + width=size, height=size)) + await evt.az.intent.send_message(evt.room_id, content) + + account = await evt.bridge.signal.link(callback, device_name=device_name) + await evt.sender.on_signin(account) + await evt.reply(f"Successfully logged in as {pu.Puppet.fmt_phone(evt.sender.username)}") + + +@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, + help_text="Sign into Signal as the primary device", help_args="") +async def register(evt: CommandEvent) -> None: + if len(evt.args) == 0: + await evt.reply("**Usage**: $cmdprefix+sp register ") + return + phone = evt.args[0] + if not phone.startswith("+") or not phone[1:].isdecimal(): + await evt.reply(f"Please enter the phone number in international format (E.164)") + return + resp = await evt.bridge.signal.request("register", "verification_required") + evt.sender.command_status = { + "action": "Register", + "room_id": evt.room_id, + "next": enter_register_code, + "username": resp["username"], + } + await evt.reply("Register SMS requested, please enter the code here.") + + +async def enter_register_code(evt: CommandEvent) -> None: + try: + username = evt.sender.command_status["username"] + resp = await evt.bridge.signal.request("verify", "verification_succeeded", + code=evt.args[0], username=username) + except UnexpectedResponse as e: + if e.resp_type == "error": + await evt.reply(e.data) + else: + raise + else: + account = Account.deserialize(resp) + await evt.sender.on_signin(account) + await evt.reply(f"Successfully logged in as {pu.Puppet.fmt_phone(evt.sender.username)}") diff --git a/mautrix_signal/commands/conn.py b/mautrix_signal/commands/conn.py new file mode 100644 index 0000000..112f84a --- /dev/null +++ b/mautrix_signal/commands/conn.py @@ -0,0 +1,43 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from . import command_handler, CommandEvent, SECTION_CONNECTION + + +@command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION, + help_text="Mark this room as your bridge notice room") +async def set_notice_room(evt: CommandEvent) -> None: + evt.sender.notice_room = evt.room_id + await evt.sender.update() + await evt.reply("This room has been marked as your bridge notice room") + + +# @command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION, +# help_text="Check if you're logged into Twitter") +# async def ping(evt: CommandEvent) -> None: +# if evt.sender.username: +# await evt.reply("") +# user_info = await evt.sender.get_info() +# await evt.reply(f"You're logged in as {user_info.name} " +# f"([@{evt.sender.username}](https://twitter.com/{evt.sender.username}), " +# f"user ID: {evt.sender.twid})") + + +# TODO request syncs or something +# @command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION, +# help_text="Synchronize portals") +# async def sync(evt: CommandEvent) -> None: +# await evt.sender.sync() +# await evt.reply("Synchronization complete") diff --git a/mautrix_signal/commands/handler.py b/mautrix_signal/commands/handler.py new file mode 100644 index 0000000..1c640c8 --- /dev/null +++ b/mautrix_signal/commands/handler.py @@ -0,0 +1,98 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Awaitable, Callable, List, Optional, NamedTuple, TYPE_CHECKING + +from mautrix.types import RoomID, EventID, MessageEventContent +from mautrix.bridge.commands import (HelpSection, CommandEvent as BaseCommandEvent, + CommandHandler as BaseCommandHandler, + CommandProcessor as BaseCommandProcessor, + CommandHandlerFunc, command_handler as base_command_handler) + +from .. import user as u + +if TYPE_CHECKING: + from ..__main__ import SignalBridge + +HelpCacheKey = NamedTuple('HelpCacheKey', is_management=bool, is_portal=bool, is_admin=bool, + is_logged_in=bool) +SECTION_AUTH = HelpSection("Authentication", 10, "") +SECTION_CONNECTION = HelpSection("Connection management", 15, "") + + +class CommandEvent(BaseCommandEvent): + sender: u.User + bridge: 'SignalBridge' + + def __init__(self, processor: 'CommandProcessor', room_id: RoomID, event_id: EventID, + sender: u.User, command: str, args: List[str], content: MessageEventContent, + is_management: bool, is_portal: bool) -> None: + super().__init__(processor, room_id, event_id, sender, command, args, content, + is_management, is_portal) + self.bridge = processor.bridge + self.config = processor.config + + @property + def print_error_traceback(self) -> bool: + return self.sender.is_admin + + async def get_help_key(self) -> HelpCacheKey: + return HelpCacheKey(self.is_management, self.is_portal, self.sender.is_admin, + await self.sender.is_logged_in()) + + +class CommandHandler(BaseCommandHandler): + name: str + + management_only: bool + needs_auth: bool + needs_admin: bool + + def __init__(self, handler: Callable[[CommandEvent], Awaitable[EventID]], + management_only: bool, name: str, help_text: str, help_args: str, + help_section: HelpSection, needs_auth: bool, needs_admin: bool) -> None: + super().__init__(handler, management_only, name, help_text, help_args, help_section, + needs_auth=needs_auth, needs_admin=needs_admin) + + async def get_permission_error(self, evt: CommandEvent) -> Optional[str]: + if self.management_only and not evt.is_management: + return (f"`{evt.command}` is a restricted command: " + "you may only run it in management rooms.") + elif self.needs_admin and not evt.sender.is_admin: + return "This command requires administrator privileges." + elif self.needs_auth and not await evt.sender.is_logged_in(): + return "This command requires you to be logged in." + return None + + def has_permission(self, key: HelpCacheKey) -> bool: + return ((not self.management_only or key.is_management) and + (not self.needs_admin or key.is_admin) and + (not self.needs_auth or key.is_logged_in)) + + +def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: bool = True, + needs_admin: bool = False, management_only: bool = False, + name: Optional[str] = None, help_text: str = "", help_args: str = "", + help_section: HelpSection = None) -> Callable[[CommandHandlerFunc], + CommandHandler]: + return base_command_handler(_func, _handler_class=CommandHandler, name=name, + help_text=help_text, help_args=help_args, + help_section=help_section, management_only=management_only, + needs_auth=needs_auth, needs_admin=needs_admin) + + +class CommandProcessor(BaseCommandProcessor): + def __init__(self, bridge: 'SignalBridge') -> None: + super().__init__(bridge=bridge, event_class=CommandEvent) diff --git a/mautrix_signal/config.py b/mautrix_signal/config.py new file mode 100644 index 0000000..14d1597 --- /dev/null +++ b/mautrix_signal/config.py @@ -0,0 +1,101 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Any, List, NamedTuple +import os + +from mautrix.types import UserID +from mautrix.client import Client +from mautrix.bridge.config import (BaseBridgeConfig, ConfigUpdateHelper, ForbiddenKey, + ForbiddenDefault) + +Permissions = NamedTuple("Permissions", user=bool, admin=bool, level=str) + + +class Config(BaseBridgeConfig): + def __getitem__(self, key: str) -> Any: + try: + return os.environ[f"MAUTRIX_SIGNAL_{key.replace('.', '_').upper()}"] + except KeyError: + return super().__getitem__(key) + + @property + def forbidden_defaults(self) -> List[ForbiddenDefault]: + return [ + *super().forbidden_defaults, + ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"), + ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")), + ] + + def do_update(self, helper: ConfigUpdateHelper) -> None: + super().do_update(helper) + copy, copy_dict, base = helper + + copy("homeserver.asmux") + + copy("appservice.provisioning.enabled") + copy("appservice.provisioning.prefix") + copy("appservice.provisioning.shared_secret") + if base["appservice.provisioning.shared_secret"] == "generate": + base["appservice.provisioning.shared_secret"] = self._new_token() + + copy("appservice.community_id") + + copy("signal.socket_path") + + copy("metrics.enabled") + copy("metrics.listen_port") + + copy("bridge.username_template") + copy("bridge.displayname_template") + copy("bridge.allow_contact_list_name_updates") + copy("bridge.displayname_preference") + + copy("bridge.autocreate_group_portal") + copy("bridge.autocreate_contact_portal") + copy("bridge.sync_with_custom_puppets") + copy("bridge.sync_direct_chat_list") + copy("bridge.login_shared_secret") + copy("bridge.federate_rooms") + copy("bridge.encryption.allow") + copy("bridge.encryption.default") + copy("bridge.encryption.key_sharing.allow") + copy("bridge.encryption.key_sharing.require_cross_signing") + copy("bridge.encryption.key_sharing.require_verification") + copy("bridge.private_chat_portal_meta") + copy("bridge.delivery_receipts") + copy("bridge.delivery_error_reports") + copy("bridge.resend_bridge_info") + + copy("bridge.command_prefix") + + copy_dict("bridge.permissions") + + def _get_permissions(self, key: str) -> Permissions: + level = self["bridge.permissions"].get(key, "") + admin = level == "admin" + user = level == "user" or admin + return Permissions(user, admin, level) + + def get_permissions(self, mxid: UserID) -> Permissions: + permissions = self["bridge.permissions"] + if mxid in permissions: + return self._get_permissions(mxid) + + _, homeserver = Client.parse_user_id(mxid) + if homeserver in permissions: + return self._get_permissions(homeserver) + + return self._get_permissions("*") diff --git a/mautrix_signal/db/__init__.py b/mautrix_signal/db/__init__.py new file mode 100644 index 0000000..89e0c62 --- /dev/null +++ b/mautrix_signal/db/__init__.py @@ -0,0 +1,16 @@ +from mautrix.util.async_db import Database + +from .upgrade import upgrade_table +from .user import User +from .puppet import Puppet +from .portal import Portal +from .message import Message +from .reaction import Reaction + + +def init(db: Database) -> None: + for table in (User, Puppet, Portal, Message, Reaction): + table.db = db + + +__all__ = ["upgrade_table", "init", "User", "Puppet", "Portal", "Message", "Reaction"] diff --git a/mautrix_signal/db/message.py b/mautrix_signal/db/message.py new file mode 100644 index 0000000..a421b17 --- /dev/null +++ b/mautrix_signal/db/message.py @@ -0,0 +1,98 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Optional, ClassVar, Union, List, TYPE_CHECKING +from uuid import UUID + +from attr import dataclass +import asyncpg + +from mautrix.types import RoomID, EventID +from mautrix.util.async_db import Database + +fake_db = Database("") if TYPE_CHECKING else None + + +@dataclass +class Message: + db: ClassVar[Database] = fake_db + + mxid: EventID + mx_room: RoomID + sender: UUID + timestamp: int + signal_chat_id: Union[str, UUID] + signal_receiver: str + + async def insert(self) -> None: + q = ("INSERT INTO message (mxid, mx_room, sender, timestamp, signal_chat_id," + " signal_receiver) VALUES ($1, $2, $3, $4, $5, $6)") + await self.db.execute(q, self.mxid, self.mx_room, self.sender, self.timestamp, + self.signal_chat_id, self.signal_receiver) + + async def delete(self) -> None: + q = ("DELETE FROM message WHERE sender=$1 AND timestamp=$2" + " AND signal_chat_id=$3 AND signal_receiver=$4") + await self.db.execute(q, self.sender, self.timestamp, self.signal_chat_id, + self.signal_receiver) + + @classmethod + async def delete_all(cls, room_id: RoomID) -> None: + await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id) + + @classmethod + def _from_row(cls, row: asyncpg.Record) -> 'Message': + data = {**row} + if data["signal_receiver"]: + chat_id = UUID(data.pop("signal_chat_id")) + else: + chat_id = data.pop("signal_chat_id") + return cls(signal_chat_id=chat_id, **data) + + @classmethod + async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Message']: + q = ("SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver " + "FROM message WHERE mxid=$1 AND mx_room=$2") + row = await cls.db.fetchrow(q, mxid, mx_room) + if not row: + return None + return cls(**row) + + @classmethod + async def get_by_signal_id(cls, sender: UUID, timestamp: int, signal_chat_id: Union[str, UUID], + signal_receiver: str = "") -> Optional['Message']: + q = ("SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver " + "FROM message WHERE sender=$1 AND timestamp=$2" + " AND signal_chat_id=$3 AND signal_receiver=$4") + row = await cls.db.fetchrow(q, sender, timestamp, signal_chat_id, signal_receiver) + if not row: + return None + return cls(**row) + + @classmethod + async def find_by_timestamps(cls, timestamps: List[int]) -> List['Message']: + q = ("SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver " + "FROM message WHERE timestamp=ANY($1)") + rows = await cls.db.fetch(q, timestamps) + return [cls(**row) for row in rows] + + @classmethod + async def find_by_sender_timestamp(cls, sender: UUID, timestamp: int) -> Optional['Message']: + q = ("SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver " + "FROM message WHERE sender=$1 AND timestamp=$2") + row = await cls.db.fetchrow(q, sender, timestamp) + if not row: + return None + return cls(**row) diff --git a/mautrix_signal/db/portal.py b/mautrix_signal/db/portal.py new file mode 100644 index 0000000..521cb72 --- /dev/null +++ b/mautrix_signal/db/portal.py @@ -0,0 +1,92 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Optional, ClassVar, List, Union, TYPE_CHECKING +from uuid import UUID + +from attr import dataclass +import asyncpg + +from mautrix.types import RoomID +from mautrix.util.async_db import Database + +fake_db = Database("") if TYPE_CHECKING else None + + +@dataclass +class Portal: + db: ClassVar[Database] = fake_db + + chat_id: Union[UUID, str] + receiver: str + mxid: Optional[RoomID] + name: Optional[str] + encrypted: bool + + async def insert(self) -> None: + q = ("INSERT INTO portal (chat_id, receiver, mxid, name, encrypted) " + "VALUES ($1, $2, $3, $4, $5)") + await self.db.execute(q, self.chat_id, self.receiver, self.mxid, self.name, self.encrypted) + + async def update(self) -> None: + q = ("UPDATE portal SET mxid=$3, name=$4, encrypted=$5 " + "WHERE chat_id=$1::text AND receiver=$2") + await self.db.execute(q, self.chat_id, self.receiver, self.mxid, self.name, self.encrypted) + + @classmethod + def _from_row(cls, row: asyncpg.Record) -> 'Portal': + data = {**row} + if data["receiver"]: + chat_id = UUID(data.pop("chat_id")) + else: + chat_id = data.pop("chat_id") + return cls(chat_id=chat_id, **data) + + @classmethod + async def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']: + q = "SELECT chat_id, receiver, mxid, name, encrypted FROM portal WHERE mxid=$1" + row = await cls.db.fetchrow(q, mxid) + if not row: + return None + return cls._from_row(row) + + @classmethod + async def get_by_chat_id(cls, chat_id: Union[UUID, str], receiver: str = "" + ) -> Optional['Portal']: + q = ("SELECT chat_id, receiver, mxid, name, encrypted " + "FROM portal WHERE chat_id=$1::text AND receiver=$2") + row = await cls.db.fetchrow(q, chat_id, receiver) + if not row: + return None + return cls._from_row(row) + + @classmethod + async def find_private_chats_of(cls, receiver: str) -> List['Portal']: + q = "SELECT chat_id, receiver, mxid, name, encrypted FROM portal WHERE receiver=$1" + rows = await cls.db.fetch(q, receiver) + return [cls._from_row(row) for row in rows] + + @classmethod + async def find_private_chats_with(cls, other_user: UUID) -> List['Portal']: + q = ("SELECT chat_id, receiver, mxid, name, encrypted FROM portal " + "WHERE chat_id=$1::text AND receiver<>''") + rows = await cls.db.fetch(q, other_user) + return [cls._from_row(row) for row in rows] + + @classmethod + async def all_with_room(cls) -> List['Portal']: + q = "SELECT chat_id, receiver, mxid, name, encrypted FROM portal WHERE mxid IS NOT NULL" + rows = await cls.db.fetch(q) + return [cls._from_row(row) for row in rows] diff --git a/mautrix_signal/db/puppet.py b/mautrix_signal/db/puppet.py new file mode 100644 index 0000000..7ce7d88 --- /dev/null +++ b/mautrix_signal/db/puppet.py @@ -0,0 +1,105 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Optional, ClassVar, List, TYPE_CHECKING +from uuid import UUID + +from attr import dataclass + +from mausignald.types import Address +from mautrix.types import UserID, SyncToken +from mautrix.util.async_db import Database + +fake_db = Database("") if TYPE_CHECKING else None + + +@dataclass +class Puppet: + db: ClassVar[Database] = fake_db + + uuid: Optional[UUID] + number: Optional[str] + name: Optional[str] + + uuid_registered: bool + number_registered: bool + + custom_mxid: Optional[UserID] + access_token: Optional[str] + next_batch: Optional[SyncToken] + + async def insert(self) -> None: + q = ("INSERT INTO puppet (uuid, number, name, uuid_registered, number_registered, " + " custom_mxid, access_token, next_batch) " + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8)") + await self.db.execute(q, self.uuid, self.number, self.name, + self.uuid_registered, self.number_registered, + self.custom_mxid, self.access_token, self.next_batch) + + async def _set_uuid(self, uuid: UUID) -> None: + if self.uuid: + raise ValueError("Can't re-set UUID for puppet") + self.uuid = uuid + await self.db.execute("UPDATE puppet SET uuid=$1 WHERE number=$2", uuid, self.number) + + async def update(self) -> None: + if self.uuid is None: + q = ("UPDATE puppet SET uuid=$1, name=$3, uuid_registered=$4, number_registered=$5, " + " custom_mxid=$6, access_token=$7, next_batch=$8 " + "WHERE number=$2") + else: + q = ("UPDATE puppet SET number=$2, name=$3, uuid_registered=$4, number_registered=$5, " + " custom_mxid=$6, access_token=$7, next_batch=$8 " + "WHERE uuid=$1") + await self.db.execute(q, self.uuid, self.number, self.name, + self.uuid_registered, self.number_registered, + self.custom_mxid, self.access_token, self.next_batch) + + @classmethod + async def get_by_address(cls, address: Address) -> Optional['Puppet']: + select = ("SELECT uuid, number, name, uuid_registered, " + " number_registered, custom_mxid, access_token, next_batch " + "FROM puppet") + if address.uuid: + if address.number: + row = await cls.db.fetchrow(f"{select} WHERE uuid=$1 OR number=$2", + address.uuid, address.number) + else: + row = await cls.db.fetchrow(f"{select} WHERE uuid=$1", address.uuid) + elif address.number: + row = await cls.db.fetchrow(f"{select} WHERE number=$1", address.number) + else: + raise ValueError("Invalid address") + if not row: + return None + return cls(**row) + + @classmethod + async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']: + q = ("SELECT uuid, number, name, uuid_registered, number_registered," + " custom_mxid, access_token, next_batch " + "FROM puppet WHERE custom_mxid=$1") + row = await cls.db.fetchrow(q, mxid) + if not row: + return None + return cls(**row) + + @classmethod + async def all_with_custom_mxid(cls) -> List['Puppet']: + q = ("SELECT uuid, number, name, uuid_registered, number_registered," + " custom_mxid, access_token, next_batch " + "FROM puppet WHERE custom_mxid IS NOT NULL") + rows = await cls.db.fetch(q) + return [cls(**row) for row in rows] diff --git a/mautrix_signal/db/reaction.py b/mautrix_signal/db/reaction.py new file mode 100644 index 0000000..0fab364 --- /dev/null +++ b/mautrix_signal/db/reaction.py @@ -0,0 +1,91 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Optional, ClassVar, TYPE_CHECKING +from uuid import UUID + +from attr import dataclass +import asyncpg + +from mautrix.types import RoomID, EventID +from mautrix.util.async_db import Database + +fake_db = Database("") if TYPE_CHECKING else None + + +@dataclass +class Reaction: + db: ClassVar[Database] = fake_db + + mxid: EventID + mx_room: RoomID + signal_chat_id: str + signal_receiver: str + msg_author: UUID + msg_timestamp: int + author: UUID + emoji: str + + async def insert(self) -> None: + q = ("INSERT INTO reaction (mxid, mx_room, signal_chat_id, signal_receiver, msg_author," + " msg_timestamp, author, emoji) " + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8)") + await self.db.execute(q, self.mxid, self.mx_room, self.signal_chat_id, + self.signal_receiver, self.msg_author, self.msg_timestamp, + self.author, self.emoji) + + async def edit(self, mx_room: RoomID, mxid: EventID, emoji: str) -> None: + await self.db.execute("UPDATE reaction SET mxid=$1, mx_room=$2, emoji=$3 " + "WHERE signal_chat_id=$4 AND signal_receiver=$5" + " AND msg_author=$6 AND msg_timestamp=$7 AND author=$8", + mxid, mx_room, emoji, self.signal_chat_id, self.signal_receiver, + self.msg_author, self.msg_timestamp, self.author) + + async def delete(self) -> None: + q = ("DELETE FROM reaction WHERE signal_chat_id=$1 AND signal_receiver=$2" + " AND msg_author=$3 AND msg_timestamp=$4 AND author=$5") + await self.db.execute(q, self.signal_chat_id, self.signal_receiver, self.msg_author, + self.msg_timestamp, self.author) + + @classmethod + def _from_row(cls, row: asyncpg.Record) -> 'Reaction': + data = {**row} + if data["signal_receiver"]: + chat_id = UUID(data.pop("signal_chat_id")) + else: + chat_id = data.pop("signal_chat_id") + return cls(signal_chat_id=chat_id, **data) + + @classmethod + async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Reaction']: + q = ("SELECT mxid, mx_room, signal_chat_id, signal_receiver," + " msg_author, msg_timestamp, author, emoji " + "FROM reaction WHERE mxid=$1 AND mx_room=$2") + row = await cls.db.fetchrow(q, mxid, mx_room) + if not row: + return None + return cls(**row) + + @classmethod + async def get_by_signal_id(cls, chat_id: str, receiver: str, msg_author: UUID, + msg_timestamp: int, author: UUID) -> Optional['Reaction']: + q = ("SELECT mxid, mx_room, signal_chat_id, signal_receiver," + " msg_author, msg_timestamp, author, emoji " + "FROM reaction WHERE signal_chat_id=$1 AND signal_receiver=$2" + " AND msg_author=$3 AND msg_timestamp=$4 AND author=$5") + row = await cls.db.fetchrow(q, chat_id, receiver, msg_author, msg_timestamp, author) + if not row: + return None + return cls(**row) diff --git a/mautrix_signal/db/upgrade.py b/mautrix_signal/db/upgrade.py new file mode 100644 index 0000000..f860d1a --- /dev/null +++ b/mautrix_signal/db/upgrade.py @@ -0,0 +1,91 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from asyncpg import Connection + +from mautrix.util.async_db import UpgradeTable + +upgrade_table = UpgradeTable() + + +@upgrade_table.register(description="Initial revision") +async def upgrade_v1(conn: Connection) -> None: + await conn.execute("""CREATE TABLE portal ( + chat_id TEXT, + receiver TEXT, + mxid TEXT, + name TEXT, + encrypted BOOLEAN NOT NULL DEFAULT false, + + PRIMARY KEY (chat_id, receiver) + )""") + await conn.execute("""CREATE TABLE "user" ( + mxid TEXT PRIMARY KEY, + username TEXT, + uuid UUID, + notice_room TEXT + )""") + await conn.execute("""CREATE TABLE puppet ( + uuid UUID UNIQUE, + number TEXT UNIQUE, + name TEXT, + + uuid_registered BOOLEAN NOT NULL DEFAULT false, + number_registered BOOLEAN NOT NULL DEFAULT false, + + custom_mxid TEXT, + access_token TEXT, + next_batch TEXT + )""") + await conn.execute("""CREATE TABLE user_portal ( + "user" TEXT, + portal TEXT, + portal_receiver TEXT, + in_community BOOLEAN NOT NULL DEFAULT false, + + FOREIGN KEY (portal, portal_receiver) REFERENCES portal(chat_id, receiver) + ON UPDATE CASCADE ON DELETE CASCADE + )""") + await conn.execute("""CREATE TABLE message ( + mxid TEXT NOT NULL, + mx_room TEXT NOT NULL, + sender UUID, + timestamp BIGINT, + signal_chat_id TEXT, + signal_receiver TEXT, + + PRIMARY KEY (sender, timestamp, signal_chat_id, signal_receiver), + FOREIGN KEY (signal_chat_id, signal_receiver) REFERENCES portal(chat_id, receiver) + ON UPDATE CASCADE ON DELETE CASCADE, + UNIQUE (mxid, mx_room) + )""") + await conn.execute("""CREATE TABLE reaction ( + mxid TEXT NOT NULL, + mx_room TEXT NOT NULL, + + signal_chat_id TEXT NOT NULL, + signal_receiver TEXT NOT NULL, + msg_author UUID NOT NULL, + msg_timestamp BIGINT NOT NULL, + author UUID NOT NULL, + + emoji TEXT NOT NULL, + + PRIMARY KEY (signal_chat_id, signal_receiver, msg_author, msg_timestamp, author), + FOREIGN KEY (msg_author, msg_timestamp, signal_chat_id, signal_receiver) + REFERENCES message(sender, timestamp, signal_chat_id, signal_receiver) + ON DELETE CASCADE ON UPDATE CASCADE, + UNIQUE (mxid, mx_room) + )""") diff --git a/mautrix_signal/db/user.py b/mautrix_signal/db/user.py new file mode 100644 index 0000000..155bc1d --- /dev/null +++ b/mautrix_signal/db/user.py @@ -0,0 +1,65 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Optional, ClassVar, List, TYPE_CHECKING +from uuid import UUID + +from attr import dataclass + +from mautrix.types import UserID, RoomID +from mautrix.util.async_db import Database + +fake_db = Database("") if TYPE_CHECKING else None + + +@dataclass +class User: + db: ClassVar[Database] = fake_db + + mxid: UserID + username: Optional[str] + uuid: Optional[UUID] + notice_room: Optional[RoomID] + + async def insert(self) -> None: + q = ('INSERT INTO "user" (mxid, username, uuid, notice_room) ' + 'VALUES ($1, $2, $3, $4)') + await self.db.execute(q, self.mxid, self.username, self.uuid, self.notice_room) + + async def update(self) -> None: + await self.db.execute('UPDATE "user" SET username=$2, uuid=$3, notice_room=$4 ' + 'WHERE mxid=$1', self.mxid, self.username, self.uuid, self.notice_room) + + @classmethod + async def get_by_mxid(cls, mxid: UserID) -> Optional['User']: + q = 'SELECT mxid, username, uuid, notice_room FROM "user" WHERE mxid=$1' + row = await cls.db.fetchrow(q, mxid) + if not row: + return None + return cls(**row) + + @classmethod + async def get_by_username(cls, username: str) -> Optional['User']: + q = 'SELECT mxid, username, uuid, notice_room FROM "user" WHERE username=$1' + row = await cls.db.fetchrow(q, username) + if not row: + return None + return cls(**row) + + @classmethod + async def all_logged_in(cls) -> List['User']: + q = 'SELECT mxid, username, uuid, notice_room FROM "user" WHERE username IS NOT NULL' + rows = await cls.db.fetch(q) + return [cls(**row) for row in rows] diff --git a/mautrix_signal/example-config.yaml b/mautrix_signal/example-config.yaml new file mode 100644 index 0000000..55f137b --- /dev/null +++ b/mautrix_signal/example-config.yaml @@ -0,0 +1,189 @@ +# Homeserver details +homeserver: + # The address that this appservice can use to connect to the homeserver. + address: https://example.com + # The domain of the homeserver (for MXIDs, etc). + domain: example.com + # Whether or not to verify the SSL certificate of the homeserver. + # Only applies if address starts with https:// + verify_ssl: true + asmux: false + +# 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:29328 + # When using https:// the TLS certificate and key files for the address. + tls_cert: false + tls_key: false + + # The hostname and port where this appservice should listen. + hostname: 0.0.0.0 + port: 29328 + # The maximum body size of appservice API requests (from the homeserver) in mebibytes + # Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s + max_body_size: 1 + + # The full URI to the database. Only Postgres is currently supported. + database: postgres://username:password@hostname/db + + # Provisioning API part of the web server for automated portal creation and fetching information. + # Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager). + provisioning: + # Whether or not the provisioning API should be enabled. + enabled: true + # The prefix to use in the provisioning API endpoints. + prefix: /_matrix/provision/v1 + # The shared secret to authorize users of the API. + # Set to "generate" to generate and save a new token. + shared_secret: generate + + # The unique ID of this appservice. + id: signal + # Username of the appservice bot. + bot_username: signalbot + # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty + # to leave display name/avatar as-is. + bot_displayname: Signal bridge bot + bot_avatar: mxc://maunium.net/wPJgTQbZOtpBFmDNkiNEMDUp + + # Community ID for bridged users (changes registration file) and rooms. + # Must be created manually. + # + # Example: "+signal:example.com". Set to false to disable. + community_id: false + + # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. + as_token: "This value is generated when generating the registration" + hs_token: "This value is generated when generating the registration" + +# Prometheus telemetry config. Requires prometheus-client to be installed. +metrics: + enabled: false + listen_port: 8000 + +signal: + # Path to signald unix socket + socket_path: /var/run/signald/signald.sock + +# Bridge config +bridge: + # Localpart template of MXIDs for Signal users. + # {userid} is replaced with an identifier for the Signal user. + username_template: "signal_{userid}" + # Displayname template for Signal users. + # {displayname} is replaced with the displayname of the Signal user, which is the first + # available variable in displayname_preference. The variables in displayname_preference + # can also be used here directly. + displayname_template: "{displayname} (Signal)" + # Whether or not contact list displaynames should be used. + # Using this isn't recommended on multi-user instances. + allow_contact_list_name_updates: false + # Available variables: full_name, first_name, last_name, phone, uuid + displayname_preference: + - full name + - phone + + # Whether or not to create portals for all groups on login/connect. + autocreate_group_portal: true + # Whether or not to create portals for all contacts on login/connect. + autocreate_contact_portal: false + # Whether or not to use /sync to get read receipts and typing notifications + # when double puppeting is enabled + sync_with_custom_puppets: true + # Whether or not to update the m.direct account data event when double puppeting is enabled. + # Note that updating the m.direct event is not atomic (except with mautrix-asmux) + # and is therefore prone to race conditions. + sync_direct_chat_list: false + # 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 created rooms should have federation enabled. + # If false, created portal rooms will never be federated. + federate_rooms: true + # End-to-bridge encryption support options. These require matrix-nio to be installed with pip + # and login_shared_secret to be configured in order to get a device for the bridge bot. + # + # Additionally, https://github.com/matrix-org/synapse/pull/5758 is required if using a normal + # application service. + encryption: + # Allow encryption, work in group chat rooms with e2ee enabled + allow: false + # Default to encryption, force-enable encryption in all portals the bridge creates + # This will cause the bridge bot to be in private chats for the encryption to work properly. + default: false + # Options for automatic key sharing. + key_sharing: + # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. + # You must use a client that supports requesting keys from other users to use this feature. + allow: false + # Require the requesting device to have a valid cross-signing signature? + # This doesn't require that the bridge has verified the device, only that the user has verified it. + # Not yet implemented. + require_cross_signing: false + # Require devices to be verified by the bridge? + # Verification by the bridge is not yet implemented. + require_verification: true + # Whether or not to explicitly set the avatar and room name for private + # chat portal rooms. This will be implicitly enabled if encryption.default is true. + private_chat_portal_meta: false + # Whether or not the bridge should send a read receipt from the bridge bot when a message has + # been sent to Signal. + delivery_receipts: false + # Whether or not delivery errors should be reported as messages in the Matrix room. + delivery_error_reports: false + # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. + # This field will automatically be changed back to false after it, + # except if the config file is not writable. + resend_bridge_info: false + + # The prefix for commands. Only required in non-management rooms. + command_prefix: "!signal" + + # Permissions for using the bridge. + # Permitted values: + # user - Use the bridge with puppeting. + # admin - Use and administrate the bridge. + # Permitted keys: + # * - All Matrix users + # domain - All users on that homeserver + # mxid - Specific user + permissions: + "example.com": "user" + "@admin:example.com": "admin" + + +# Python logging configuration. +# +# See section 16.7.2 of the Python documentation for more info: +# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema +logging: + version: 1 + formatters: + colored: + (): mautrix_signal.util.ColorFormatter + format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" + normal: + format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" + handlers: + file: + class: logging.handlers.RotatingFileHandler + formatter: normal + filename: ./mautrix-signal.log + maxBytes: 10485760 + backupCount: 10 + console: + class: logging.StreamHandler + formatter: colored + loggers: + mau: + level: DEBUG + aiohttp: + level: INFO + root: + level: DEBUG + handlers: [file, console] diff --git a/mautrix_signal/get_version.py b/mautrix_signal/get_version.py new file mode 100644 index 0000000..2e1d162 --- /dev/null +++ b/mautrix_signal/get_version.py @@ -0,0 +1,50 @@ +import subprocess +import shutil +import os + +from . import __version__ + +cmd_env = { + "PATH": os.environ["PATH"], + "HOME": os.environ["HOME"], + "LANG": "C", + "LC_ALL": "C", +} + + +def run(cmd): + return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env) + + +if os.path.exists(".git") and shutil.which("git"): + try: + git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii") + git_revision_url = f"https://github.com/tulir/mautrix-signal/commit/{git_revision}" + git_revision = git_revision[:8] + except (subprocess.SubprocessError, OSError): + git_revision = "unknown" + git_revision_url = None + + try: + git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii") + except (subprocess.SubprocessError, OSError): + git_tag = None +else: + git_revision = "unknown" + git_revision_url = None + git_tag = None + +git_tag_url = (f"https://github.com/tulir/mautrix-signal/releases/tag/{git_tag}" + if git_tag else None) + +if git_tag and __version__ == git_tag[1:].replace("-", ""): + version = __version__ + linkified_version = f"[{version}]({git_tag_url})" +else: + if not __version__.endswith("+dev"): + __version__ += "+dev" + version = f"{__version__}.{git_revision}" + if git_revision_url: + linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})" + else: + linkified_version = version diff --git a/mautrix_signal/matrix.py b/mautrix_signal/matrix.py new file mode 100644 index 0000000..7baabfb --- /dev/null +++ b/mautrix_signal/matrix.py @@ -0,0 +1,142 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import List, Union, TYPE_CHECKING + +from mautrix.bridge import BaseMatrixHandler +from mautrix.types import (Event, ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, RoomID, + EventID, UserID, ReactionEventContent, RelationType, EventType, + ReceiptEvent, TypingEvent, PresenceEvent, RedactionEvent) + +from .db import Message as DBMessage +from . import commands as com, puppet as pu, portal as po, user as u + +if TYPE_CHECKING: + from .__main__ import SignalBridge + + +class MatrixHandler(BaseMatrixHandler): + commands: 'com.CommandProcessor' + + def __init__(self, bridge: 'SignalBridge') -> None: + prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":") + homeserver = bridge.config["homeserver.domain"] + self.user_id_prefix = f"@{prefix}" + self.user_id_suffix = f"{suffix}:{homeserver}" + + super().__init__(command_processor=com.CommandProcessor(bridge), bridge=bridge) + + def filter_matrix_event(self, evt: Event) -> bool: + if not isinstance(evt, (ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, + RedactionEvent)): + return True + return (evt.sender == self.az.bot_mxid + or pu.Puppet.get_id_from_mxid(evt.sender) is not None) + + async def send_welcome_message(self, room_id: RoomID, inviter: 'u.User') -> None: + await super().send_welcome_message(room_id, inviter) + if not inviter.notice_room: + inviter.notice_room = room_id + await inviter.update() + await self.az.intent.send_notice(room_id, "This room has been marked as your " + "Signal bridge notice room.") + + async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: + portal = await po.Portal.get_by_mxid(room_id) + if not portal: + return + + user = await u.User.get_by_mxid(user_id, create=False) + if not user: + return + + await portal.handle_matrix_leave(user) + + @staticmethod + async def allow_bridging_message(user: 'u.User', portal: 'po.Portal') -> bool: + return user.is_whitelisted and bool(user.username) + + # @staticmethod + # async def handle_redaction(room_id: RoomID, user_id: UserID, event_id: EventID, + # redaction_event_id: EventID) -> None: + # user = await u.User.get_by_mxid(user_id) + # if not user: + # return + # + # portal = await po.Portal.get_by_mxid(room_id) + # if not portal: + # return + # + # await portal.handle_matrix_redaction(user, event_id, redaction_event_id) + + @classmethod + async def handle_reaction(cls, room_id: RoomID, user_id: UserID, event_id: EventID, + content: ReactionEventContent) -> None: + if content.relates_to.rel_type != RelationType.ANNOTATION: + cls.log.debug(f"Ignoring m.reaction event in {room_id} from {user_id} with unexpected " + f"relation type {content.relates_to.rel_type}") + return + user = await u.User.get_by_mxid(user_id) + if not user: + return + + portal = await po.Portal.get_by_mxid(room_id) + if not portal: + return + + await portal.handle_matrix_reaction(user, event_id, content.relates_to.event_id, + content.relates_to.key) + + @staticmethod + async def handle_receipt(evt: ReceiptEvent) -> None: + # These events come from custom puppet syncing, so there's always only one user. + event_id, receipts = evt.content.popitem() + receipt_type, users = receipts.popitem() + user_id, data = users.popitem() + + user = await u.User.get_by_mxid(user_id, create=False) + if not user or not user.client: + return + + portal = await po.Portal.get_by_mxid(evt.room_id) + if not portal: + return + + message = await DBMessage.get_by_mxid(event_id, portal.mxid) + if not message: + return + + # user.log.debug(f"Marking messages in {portal.twid} read up to {message.twid}") + # await user.client.conversation(portal.twid).mark_read(message.twid) + + @staticmethod + async def handle_typing(room_id: RoomID, typing: List[UserID]) -> None: + # TODO implement + pass + + async def handle_event(self, evt: Event) -> None: + if evt.type == EventType.ROOM_REDACTION: + evt: RedactionEvent + # await self.handle_redaction(evt.room_id, evt.sender, evt.redacts, evt.event_id) + elif evt.type == EventType.REACTION: + evt: ReactionEvent + await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content) + + async def handle_ephemeral_event(self, evt: Union[ReceiptEvent, PresenceEvent, TypingEvent] + ) -> None: + if evt.type == EventType.TYPING: + await self.handle_typing(evt.room_id, evt.content.user_ids) + elif evt.type == EventType.RECEIPT: + await self.handle_receipt(evt) diff --git a/mautrix_signal/portal.py b/mautrix_signal/portal.py new file mode 100644 index 0000000..8bf20b4 --- /dev/null +++ b/mautrix_signal/portal.py @@ -0,0 +1,597 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import (Dict, Tuple, Optional, List, Deque, Set, Any, Union, AsyncGenerator, + Awaitable, TYPE_CHECKING, cast) +from collections import deque +from uuid import UUID +import asyncio +import time + +from mausignald.types import (Address, MessageData, Reaction, Quote, FullGroup, Group, Contact, + Profile) +from mautrix.appservice import AppService, IntentAPI +from mautrix.bridge import BasePortal +from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType, + TextMessageEventContent, MessageEvent, EncryptedEvent) +from mautrix.errors import MatrixError, MForbidden + +from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction +from .config import Config +from . import user as u, puppet as p, matrix as m, signal as s + +if TYPE_CHECKING: + from .__main__ import SignalBridge + +try: + from mautrix.crypto.attachments import encrypt_attachment, decrypt_attachment +except ImportError: + encrypt_attachment = decrypt_attachment = None + +StateBridge = EventType.find("m.bridge", EventType.Class.STATE) +StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE) +ChatInfo = Union[FullGroup, Group, Contact, Profile] + + +class Portal(DBPortal, BasePortal): + by_mxid: Dict[RoomID, 'Portal'] = {} + by_chat_id: Dict[Tuple[Union[str, UUID], str], 'Portal'] = {} + config: Config + matrix: 'm.MatrixHandler' + signal: 's.SignalHandler' + az: AppService + private_chat_portal_meta: bool + + _main_intent: Optional[IntentAPI] + _create_room_lock: asyncio.Lock + _msgts_dedup: Deque[Tuple[UUID, int]] + _reaction_dedup: Deque[Tuple[UUID, int, str]] + _reaction_lock: asyncio.Lock + + def __init__(self, chat_id: Union[str, UUID], receiver: str, mxid: Optional[RoomID] = None, + name: Optional[str] = None, encrypted: bool = False) -> None: + super().__init__(chat_id, receiver, mxid, name, encrypted) + self._create_room_lock = asyncio.Lock() + self.log = self.log.getChild(str(chat_id)) + self._main_intent = None + self._msgts_dedup = deque(maxlen=100) + self._reaction_dedup = deque(maxlen=100) + self._last_participant_update = set() + self._reaction_lock = asyncio.Lock() + + @property + def main_intent(self) -> IntentAPI: + if not self._main_intent: + raise ValueError("Portal must be postinit()ed before main_intent can be used") + return self._main_intent + + @property + def is_direct(self) -> bool: + return isinstance(self.chat_id, UUID) + + @property + def recipient(self) -> Union[str, Address]: + if self.is_direct: + return Address(uuid=self.chat_id) + else: + return self.chat_id + + @classmethod + def init_cls(cls, bridge: 'SignalBridge') -> None: + cls.config = bridge.config + cls.matrix = bridge.matrix + cls.signal = bridge.signal + cls.az = bridge.az + cls.loop = bridge.loop + cls.bridge = bridge + cls.private_chat_portal_meta = cls.config["bridge.private_chat_portal_meta"] + + # region Misc + + async def _send_delivery_receipt(self, event_id: EventID) -> None: + if event_id and self.config["bridge.delivery_receipts"]: + try: + await self.az.intent.mark_read(self.mxid, event_id) + except Exception: + self.log.exception("Failed to send delivery receipt for %s", event_id) + + async def _upsert_reaction(self, existing: DBReaction, intent: IntentAPI, mxid: EventID, + sender: Union['p.Puppet', 'u.User'], message: DBMessage, emoji: str + ) -> None: + if existing: + self.log.debug(f"_upsert_reaction redacting {existing.mxid} and inserting {mxid}" + f" (message: {message.mxid})") + try: + await intent.redact(existing.mx_room, existing.mxid) + except MForbidden: + self.log.debug("Unexpected MForbidden redacting reaction", exc_info=True) + await existing.edit(emoji=emoji, mxid=mxid, mx_room=message.mx_room) + else: + self.log.debug(f"_upsert_reaction inserting {mxid} (message: {message.mxid})") + await DBReaction(mxid=mxid, mx_room=message.mx_room, emoji=emoji, author=sender.uuid, + signal_chat_id=self.chat_id, signal_receiver=self.receiver, + msg_author=message.sender, msg_timestamp=message.timestamp).insert() + + # endregion + # region Matrix event handling + + async def handle_matrix_message(self, sender: 'u.User', message: MessageEventContent, + event_id: EventID) -> None: + if ((message.get(self.bridge.real_user_content_key, False) + and await p.Puppet.get_by_custom_mxid(sender.mxid))): + self.log.debug(f"Ignoring puppet-sent message by confirmed puppet user {sender.mxid}") + return + request_id = int(time.time() * 1000) + self._msgts_dedup.appendleft((sender.uuid, request_id)) + + quote = None + if message.get_reply_to(): + reply = await DBMessage.get_by_mxid(message.get_reply_to(), self.mxid) + # TODO include actual text? either store in db or fetch event from homeserver + quote = Quote(id=reply.timestamp, author=Address(uuid=reply.sender), text="") + + text = message.body + if message.msgtype == MessageType.EMOTE: + text = f"/me {text}" + elif message.msgtype.is_media: + # TODO media support + return + await self.signal.send(username=sender.username, recipient=self.recipient, + body=text, quote=quote, timestamp=request_id) + msg = DBMessage(mxid=event_id, mx_room=self.mxid, sender=sender.uuid, timestamp=request_id, + signal_chat_id=self.chat_id, signal_receiver=self.receiver) + await msg.insert() + await self._send_delivery_receipt(event_id) + self.log.debug(f"Handled Matrix message {event_id} -> {request_id}") + + async def handle_matrix_reaction(self, sender: 'u.User', event_id: EventID, + reacting_to: EventID, emoji: str) -> None: + # Signal doesn't seem to use variation selectors at all + emoji = emoji.rstrip("\ufe0f") + + message = await DBMessage.get_by_mxid(reacting_to, self.mxid) + if not message: + self.log.debug(f"Ignoring reaction to unknown event {reacting_to}") + return + + existing = await DBReaction.get_by_signal_id(self.chat_id, self.receiver, message.sender, + message.timestamp, sender.uuid) + if existing and existing.emoji == emoji: + return + + dedup_id = (message.sender, message.timestamp, emoji) + self._reaction_dedup.appendleft(dedup_id) + async with self._reaction_lock: + reaction = Reaction(emoji=emoji, remove=False, + target_author=Address(uuid=message.sender), + target_sent_timestamp=message.timestamp) + await self.signal.react(username=sender.username, recipient=self.recipient, + reaction=reaction) + await self._upsert_reaction(existing, self.main_intent, event_id, sender, message, + emoji) + self.log.trace(f"{sender.mxid} reacted to {message.timestamp} with {emoji}") + await self._send_delivery_receipt(event_id) + + async def handle_matrix_redaction(self, sender: 'u.User', event_id: EventID, + redaction_event_id: EventID) -> None: + if not self.mxid: + return + + reaction = await DBReaction.get_by_mxid(event_id, self.mxid) + if reaction: + try: + await reaction.delete() + remove_reaction = Reaction(emoji=reaction.emoji, remove=True, + target_author=Address(uuid=reaction.msg_author), + target_sent_timestamp=reaction.msg_timestamp) + await self.signal.react(username=sender.username, recipient=self.recipient, + reaction=remove_reaction) + await self._send_delivery_receipt(redaction_event_id) + self.log.trace(f"Removed {reaction} after Matrix redaction") + except Exception: + self.log.exception("Removing reaction failed") + + async def handle_matrix_leave(self, user: 'u.User') -> None: + if self.is_direct: + self.log.info(f"{user.mxid} left private chat portal with {self.chat_id}") + if user.username == self.receiver: + self.log.info(f"{user.mxid} was the recipient of this portal. " + "Cleaning up and deleting...") + await self.cleanup_and_delete() + else: + self.log.debug(f"{user.mxid} left portal to {self.chat_id}") + # TODO cleanup if empty + + # endregion + # region Signal event handling + + @staticmethod + async def _find_address_uuid(address: Address) -> Optional[UUID]: + if address.uuid: + return address.uuid + puppet = await p.Puppet.get_by_address(address, create=False) + if puppet and puppet.uuid: + return puppet.uuid + return None + + async def _find_quote_event_id(self, quote: Optional[Quote] + ) -> Optional[Union[MessageEvent, EventID]]: + if not quote: + return None + + author_uuid = await self._find_address_uuid(quote.author) + reply_msg = await DBMessage.get_by_signal_id(author_uuid, quote.id, + self.chat_id, self.receiver) + if not reply_msg: + return None + try: + evt = await self.main_intent.get_event(self.mxid, reply_msg.mxid) + if isinstance(evt, EncryptedEvent): + return await self.matrix.e2ee.decrypt(evt, wait_session_timeout=0) + return evt + except MatrixError: + return reply_msg.mxid + + async def handle_signal_message(self, sender: 'p.Puppet', message: MessageData) -> None: + if (sender.uuid, message.timestamp) in self._msgts_dedup: + self.log.debug(f"Ignoring message {message.timestamp} by {sender.uuid}" + " as it was already handled (message.timestamp in dedup queue)") + return + old_message = await DBMessage.get_by_signal_id(sender.uuid, message.timestamp, + self.chat_id, self.receiver) + if old_message is not None: + self.log.debug(f"Ignoring message {message.timestamp} by {sender.uuid}" + " as it was already handled (message.id found in database)") + return + self._msgts_dedup.appendleft((sender.uuid, message.timestamp)) + intent = sender.intent_for(self) + event_id = None + reply_to = await self._find_quote_event_id(message.quote) + # TODO attachments + if message.body: + content = TextMessageEventContent(msgtype=MessageType.TEXT, body=message.body) + if reply_to: + content.set_reply(reply_to) + event_id = await self._send_message(intent, content, timestamp=message.timestamp) + if event_id: + msg = DBMessage(mxid=event_id, mx_room=self.mxid, + sender=sender.uuid, timestamp=message.timestamp, + signal_chat_id=self.chat_id, signal_receiver=self.receiver) + await msg.insert() + await self._send_delivery_receipt(event_id) + self.log.debug(f"Handled Signal message {message.timestamp} -> {event_id}") + + async def handle_signal_reaction(self, sender: 'p.Puppet', reaction: Reaction) -> None: + author_uuid = await self._find_address_uuid(reaction.target_author) + target_id = reaction.target_sent_timestamp + if author_uuid is None: + self.log.warning(f"Failed to handle reaction from {sender.uuid}: " + f"couldn't find UUID of {reaction.target_author}") + return + async with self._reaction_lock: + dedup_id = (author_uuid, target_id, reaction.emoji) + if dedup_id in self._reaction_dedup: + return + self._reaction_dedup.appendleft(dedup_id) + + existing = await DBReaction.get_by_signal_id(self.chat_id, self.receiver, + author_uuid, target_id, sender.uuid) + + if reaction.remove: + if existing: + try: + await sender.intent_for(self).redact(existing.mx_room, existing.mxid) + except MForbidden: + await self.main_intent.redact(existing.mx_room, existing.mxid) + await existing.delete() + self.log.trace(f"Removed {existing} after Signal removal") + return + elif existing and existing.emoji == reaction.emoji: + return + + message = await DBMessage.get_by_signal_id(author_uuid, target_id, + self.chat_id, self.receiver) + if not message: + self.log.debug(f"Ignoring reaction to unknown message {target_id}") + return + + intent = sender.intent_for(self) + # TODO add variation selectors to emoji before sending to Matrix + mxid = await intent.react(message.mx_room, message.mxid, reaction.emoji) + self.log.debug(f"{sender.uuid} reacted to {message.mxid} -> {mxid}") + await self._upsert_reaction(existing, intent, mxid, sender, message, reaction.emoji) + + # endregion + # region Updating portal info + + async def update_info(self, info: ChatInfo) -> None: + if self.is_direct: + # TODO do we need to do something here? + # I think all profile updates should just call puppet.update_info() directly + # if not isinstance(info, (Contact, Profile)): + # raise ValueError(f"Unexpected type for direct chat update_info: {type(info)}") + # puppet = await p.Puppet.get_by_address(Address(uuid=self.chat_id)) + # await puppet.update_info(info) + return + + if not isinstance(info, Group): + raise ValueError(f"Unexpected type for group update_info: {type(info)}") + changed = await self._update_name(info.name) + if isinstance(info, FullGroup): + await self._update_participants(info.members) + if changed: + await self.update_bridge_info() + await self.update() + + async def update_puppet_name(self, name: str) -> None: + if not self.encrypted and not self.private_chat_portal_meta: + return + + changed = await self._update_name(name) + + if changed: + await self.update_bridge_info() + await self.update() + + async def _update_name(self, name: str) -> bool: + if self.name != name: + self.name = name + if self.mxid: + await self.main_intent.set_room_name(self.mxid, name) + return True + return False + + async def _update_participants(self, participants: List[Address]) -> None: + if not self.mxid: + return + + for address in participants: + puppet = await p.Puppet.get_by_address(address) + if not puppet.name: + await puppet._update_name(None) + await puppet.intent_for(self).ensure_joined(self.mxid) + + # endregion + # region Bridge info state event + + @property + def bridge_info_state_key(self) -> str: + return f"net.maunium.signal://signal/{self.chat_id}" + + @property + def bridge_info(self) -> Dict[str, Any]: + return { + "bridgebot": self.az.bot_mxid, + "creator": self.main_intent.mxid, + "protocol": { + "id": "signal", + "displayname": "Signal", + "avatar_url": self.config["appservice.bot_avatar"], + }, + "channel": { + "id": self.chat_id, + "displayname": self.name, + } + } + + async def update_bridge_info(self) -> None: + if not self.mxid: + self.log.debug("Not updating bridge info: no Matrix room created") + return + try: + self.log.debug("Updating bridge info...") + await self.main_intent.send_state_event(self.mxid, StateBridge, + self.bridge_info, self.bridge_info_state_key) + # TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec + await self.main_intent.send_state_event(self.mxid, StateHalfShotBridge, + self.bridge_info, self.bridge_info_state_key) + except Exception: + self.log.warning("Failed to update bridge info", exc_info=True) + + # endregion + # region Creating Matrix rooms + + async def update_matrix_room(self, source: 'u.User', info: ChatInfo) -> None: + if not self.is_direct and not isinstance(info, Group): + raise ValueError(f"Unexpected type for updating group portal: {type(info)}") + elif self.is_direct and not isinstance(info, (Contact, Profile)): + raise ValueError(f"Unexpected type for updating direct chat portal: {type(info)}") + try: + await self._update_matrix_room(source, info) + except Exception: + self.log.exception("Failed to update portal") + + async def create_matrix_room(self, source: 'u.User', info: ChatInfo) -> Optional[RoomID]: + if not self.is_direct and not isinstance(info, Group): + raise ValueError(f"Unexpected type for creating group portal: {type(info)}") + elif self.is_direct and not isinstance(info, (Contact, Profile)): + raise ValueError(f"Unexpected type for creating direct chat portal: {type(info)}") + if self.mxid: + await self.update_matrix_room(source, info) + return self.mxid + async with self._create_room_lock: + return await self._create_matrix_room(source, info) + + async def _update_matrix_room(self, source: 'u.User', info: ChatInfo) -> None: + await self.main_intent.invite_user(self.mxid, source.mxid, check_cache=True) + puppet = await p.Puppet.get_by_custom_mxid(source.mxid) + if puppet: + did_join = await puppet.intent.ensure_joined(self.mxid) + if did_join and self.is_direct: + await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) + + await self.update_info(info) + + # TODO + # up = DBUserPortal.get(source.fbid, self.fbid, self.fb_receiver) + # if not up: + # in_community = await source._community_helper.add_room(source._community_id, self.mxid) + # DBUserPortal(user=source.fbid, portal=self.fbid, portal_receiver=self.fb_receiver, + # in_community=in_community).insert() + # elif not up.in_community: + # in_community = await source._community_helper.add_room(source._community_id, self.mxid) + # up.edit(in_community=in_community) + + async def _create_matrix_room(self, source: 'u.User', info: ChatInfo) -> Optional[RoomID]: + if self.mxid: + await self._update_matrix_room(source, info) + return self.mxid + await self.update_info(info) + self.log.debug("Creating Matrix room") + name: Optional[str] = None + initial_state = [{ + "type": str(StateBridge), + "state_key": self.bridge_info_state_key, + "content": self.bridge_info, + }, { + # TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec + "type": str(StateHalfShotBridge), + "state_key": self.bridge_info_state_key, + "content": self.bridge_info, + }] + invites = [source.mxid] + if self.config["bridge.encryption.default"] and self.matrix.e2ee: + self.encrypted = True + initial_state.append({ + "type": "m.room.encryption", + "content": {"algorithm": "m.megolm.v1.aes-sha2"}, + }) + if self.is_direct: + invites.append(self.az.bot_mxid) + if self.encrypted or self.private_chat_portal_meta or not self.is_direct: + name = self.name + if self.config["appservice.community_id"]: + initial_state.append({ + "type": "m.room.related_groups", + "content": {"groups": [self.config["appservice.community_id"]]}, + }) + + self.mxid = await self.main_intent.create_room(name=name, is_direct=self.is_direct, + initial_state=initial_state, + invitees=invites) + if not self.mxid: + raise Exception("Failed to create room: no mxid returned") + + if self.encrypted and self.matrix.e2ee and self.is_direct: + try: + await self.az.intent.ensure_joined(self.mxid) + except Exception: + self.log.warning("Failed to add bridge bot " + f"to new private chat {self.mxid}") + + await self.update() + self.log.debug(f"Matrix room created: {self.mxid}") + self.by_mxid[self.mxid] = self + if not self.is_direct: + await self._update_participants(info.members) + else: + puppet = await p.Puppet.get_by_custom_mxid(source.mxid) + if puppet: + try: + await puppet.intent.join_room_by_id(self.mxid) + await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) + except MatrixError: + self.log.debug("Failed to join custom puppet into newly created portal", + exc_info=True) + + # TODO + # in_community = await source._community_helper.add_room(source._community_id, self.mxid) + # DBUserPortal(user=source.fbid, portal=self.fbid, portal_receiver=self.fb_receiver, + # in_community=in_community).upsert() + + return self.mxid + + # endregion + # region Database getters + + async def _postinit(self) -> None: + self.by_chat_id[(self.chat_id, self.receiver)] = self + if self.mxid: + self.by_mxid[self.mxid] = self + if self.is_direct: + puppet = await p.Puppet.get_by_address(Address(uuid=self.chat_id)) + self._main_intent = puppet.default_mxid_intent + elif not self.is_direct: + self._main_intent = self.az.intent + + async def delete(self) -> None: + await DBMessage.delete_all(self.mxid) + self.by_mxid.pop(self.mxid, None) + self.mxid = None + self.encrypted = False + await self.update() + + async def save(self) -> None: + await self.update() + + @classmethod + def all_with_room(cls) -> AsyncGenerator['Portal', None]: + return cls._db_to_portals(super().all_with_room()) + + @classmethod + def find_private_chats_with(cls, other_user: UUID) -> AsyncGenerator['Portal', None]: + return cls._db_to_portals(super().find_private_chats_with(other_user)) + + @classmethod + async def _db_to_portals(cls, query: Awaitable[List['Portal']] + ) -> AsyncGenerator['Portal', None]: + portals = await query + for index, portal in enumerate(portals): + try: + yield cls.by_chat_id[(portal.chat_id, portal.receiver)] + except KeyError: + await portal._postinit() + yield portal + + @classmethod + async def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']: + try: + return cls.by_mxid[mxid] + except KeyError: + pass + + portal = cast(cls, await super().get_by_mxid(mxid)) + if portal is not None: + await portal._postinit() + return portal + + return None + + @classmethod + async def get_by_chat_id(cls, chat_id: Union[UUID, str], receiver: str = "", + create: bool = False) -> Optional['Portal']: + if isinstance(chat_id, str): + receiver = "" + elif not receiver: + raise ValueError("Direct chats must have a receiver") + try: + return cls.by_chat_id[(chat_id, receiver)] + except KeyError: + pass + + portal = cast(cls, await super().get_by_chat_id(chat_id, receiver)) + if portal is not None: + await portal._postinit() + return portal + + if create: + portal = cls(chat_id, receiver) + await portal.insert() + await portal._postinit() + return portal + + return None + + # endregion diff --git a/mautrix_signal/puppet.py b/mautrix_signal/puppet.py new file mode 100644 index 0000000..93dbc15 --- /dev/null +++ b/mautrix_signal/puppet.py @@ -0,0 +1,292 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import (Optional, Dict, AsyncIterable, Awaitable, AsyncGenerator, Union, + TYPE_CHECKING, cast) +from uuid import UUID +import asyncio + +from mausignald.types import Address, Contact, Profile +from mautrix.bridge import BasePuppet +from mautrix.appservice import IntentAPI +from mautrix.types import UserID, SyncToken, RoomID +from mautrix.util.simple_template import SimpleTemplate + +from .db import Puppet as DBPuppet +from .config import Config +from . import portal as p + +if TYPE_CHECKING: + from .__main__ import SignalBridge + +try: + import phonenumbers +except ImportError: + phonenumbers = None + + +class Puppet(DBPuppet, BasePuppet): + by_uuid: Dict[UUID, 'Puppet'] = {} + by_number: Dict[str, 'Puppet'] = {} + by_custom_mxid: Dict[UserID, 'Puppet'] = {} + hs_domain: str + mxid_template: SimpleTemplate[str] + + config: Config + + default_mxid_intent: IntentAPI + default_mxid: UserID + + _uuid_lock: asyncio.Lock + _update_info_lock: asyncio.Lock + + def __init__(self, uuid: Optional[UUID], number: Optional[str], + name: Optional[str] = None, uuid_registered: bool = False, + number_registered: bool = False, custom_mxid: Optional[UserID] = None, + access_token: Optional[str] = None, next_batch: Optional[SyncToken] = None + ) -> None: + super().__init__(uuid=uuid, number=number, name=name, uuid_registered=uuid_registered, + number_registered=number_registered, custom_mxid=custom_mxid, + access_token=access_token, next_batch=next_batch) + self.log = self.log.getChild(str(uuid) or number) + + self.default_mxid = self.get_mxid_from_id(self.address) + self.default_mxid_intent = self.az.intent.user(self.default_mxid) + self.intent = self._fresh_intent() + + self._uuid_lock = asyncio.Lock() + self._update_info_lock = asyncio.Lock() + + @classmethod + def init_cls(cls, bridge: 'SignalBridge') -> AsyncIterable[Awaitable[None]]: + cls.config = bridge.config + cls.loop = bridge.loop + cls.mx = bridge.matrix + cls.az = bridge.az + cls.hs_domain = cls.config["homeserver.domain"] + cls.mxid_template = SimpleTemplate(cls.config["bridge.username_template"], "userid", + prefix="@", suffix=f":{cls.hs_domain}", type=str) + cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"] + secret = cls.config["bridge.login_shared_secret"] + cls.login_shared_secret = secret.encode("utf-8") if secret else None + cls.login_device_name = "Signal Bridge" + return (puppet.try_start() async for puppet in cls.all_with_custom_mxid()) + + def intent_for(self, portal: 'p.Portal') -> IntentAPI: + if portal.chat_id == self.uuid: + return self.default_mxid_intent + return self.intent + + @property + def is_registered(self) -> bool: + return (self.uuid is not None and self.uuid_registered) or self.number_registered + + @is_registered.setter + def is_registered(self, value: bool) -> None: + if self.uuid is not None: + self.uuid_registered = value + else: + self.number_registered = value + + @property + def address(self) -> Address: + return Address(uuid=self.uuid, number=self.number) + + async def handle_uuid_receive(self, uuid: UUID) -> None: + async with self._uuid_lock: + if self.uuid: + # Received UUID was handled while this call was waiting + return + await self._handle_uuid_receive(uuid) + + async def _handle_uuid_receive(self, uuid: UUID) -> None: + self.log.debug(f"Found UUID for user: {uuid}") + await self._set_uuid(uuid) + self.by_uuid[self.uuid] = self + prev_intent = self.default_mxid_intent + self.default_mxid = self.get_mxid_from_id(self.address) + self.default_mxid_intent = self.az.intent.user(self.default_mxid) + self.intent = self._fresh_intent() + self.log = self.log.getChild(str(uuid)) + self.log.debug(f"Migrating memberships {prev_intent.mxid} -> {self.default_mxid_intent}") + for room_id in await prev_intent.get_joined_rooms(): + await prev_intent.invite_user(room_id, self.default_mxid) + await self.default_mxid_intent.join_room_by_id(room_id) + await prev_intent.leave_room(room_id) + + async def update_info(self, info: Union[Profile, Contact]) -> None: + if isinstance(info, Contact): + if info.address.uuid and not self.uuid: + await self.handle_uuid_receive(info.address.uuid) + if not self.config["bridge.allow_contact_list_name_updates"] and self.name is not None: + return + + async with self._update_info_lock: + update = False + update = await self._update_name(info.name) or update + if update: + await self.update() + + @staticmethod + def fmt_phone(number: str) -> str: + if phonenumbers is None: + return number + parsed = phonenumbers.parse(number) + fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL + return phonenumbers.format_number(parsed, fmt) + + @classmethod + def _get_displayname(cls, address: Address, name: Optional[str]) -> str: + names = name.split("\x00") if name else [] + data = { + "first_name": names[0] if len(names) > 0 else "", + "last_name": names[-1] if len(names) > 1 else "", + "full_name": " ".join(names), + "phone": cls.fmt_phone(address.number), + "uuid": str(address.uuid) if address.uuid else None, + } + for pref in cls.config["bridge.displayname_preference"]: + value = data.get(pref.replace(" ", "_")) + if value: + data["displayname"] = value + break + + return cls.config["bridge.displayname_template"].format(**data) + + async def _update_name(self, name: Optional[str]) -> bool: + name = self._get_displayname(self.address, name) + if name != self.name: + self.name = name + await self.default_mxid_intent.set_displayname(self.name) + self.loop.create_task(self._update_portal_names()) + return True + return False + + async def _update_portal_names(self) -> None: + async for portal in p.Portal.find_private_chats_with(self.uuid): + await portal.update_puppet_name(self.name) + + async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool: + portal = await p.Portal.get_by_mxid(room_id) + return portal and portal.chat_id != self.uuid + + # region Database getters + + def _add_to_cache(self) -> None: + if self.uuid: + self.by_uuid[self.uuid] = self + if self.number: + self.by_number[self.number] = self + if self.custom_mxid: + self.by_custom_mxid[self.custom_mxid] = self + + async def save(self) -> None: + await self.update() + + @classmethod + async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']: + address = cls.get_id_from_mxid(mxid) + if not address: + return None + return await cls.get_by_address(address, create) + + @classmethod + async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']: + try: + return cls.by_custom_mxid[mxid] + except KeyError: + pass + + puppet = cast(cls, await super().get_by_custom_mxid(mxid)) + if puppet: + puppet._add_to_cache() + return puppet + + return None + + @classmethod + def get_id_from_mxid(cls, mxid: UserID) -> Optional[Address]: + identifier = cls.mxid_template.parse(mxid) + if not identifier: + return None + if identifier.startswith("phone_"): + return Address(number="+" + identifier[len("phone_"):]) + else: + try: + return Address(uuid=UUID(identifier.upper())) + except ValueError: + return None + + @classmethod + def get_mxid_from_id(cls, address: Address) -> UserID: + if address.uuid: + identifier = str(address.uuid).lower() + elif address.number: + identifier = f"phone_{address.number.lstrip('+')}" + else: + raise ValueError("Empty address") + return UserID(cls.mxid_template.format_full(identifier)) + + @classmethod + async def get_by_address(cls, address: Address, create: bool = True) -> Optional['Puppet']: + puppet = await cls._get_by_address(address, create) + if puppet and address.uuid and not puppet.uuid: + # We found a UUID for this user, store it ASAP + await puppet.handle_uuid_receive(address.uuid) + return puppet + + @classmethod + async def _get_by_address(cls, address: Address, create: bool = True) -> Optional['Puppet']: + if not address.is_valid: + raise ValueError("Empty address") + if address.uuid: + try: + return cls.by_uuid[address.uuid] + except KeyError: + pass + if address.number: + try: + return cls.by_number[address.number] + except KeyError: + pass + + puppet = cast(cls, await super().get_by_address(address)) + if puppet is not None: + puppet._add_to_cache() + return puppet + + if create: + puppet = cls(address.uuid, address.number) + await puppet.insert() + puppet._add_to_cache() + return puppet + + return None + + @classmethod + async def all_with_custom_mxid(cls) -> AsyncGenerator['Puppet', None]: + puppets = await super().all_with_custom_mxid() + puppet: cls + for index, puppet in enumerate(puppets): + try: + yield cls.by_uuid[puppet.uuid] + except KeyError: + try: + yield cls.by_number[puppet.number] + except KeyError: + puppet._add_to_cache() + yield puppet + + # endregion diff --git a/mautrix_signal/signal.py b/mautrix_signal/signal.py new file mode 100644 index 0000000..e415dea --- /dev/null +++ b/mautrix_signal/signal.py @@ -0,0 +1,118 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Optional, List, TYPE_CHECKING +import asyncio +import logging + +from mausignald import SignaldClient +from mausignald.types import (Message, MessageData, Receipt, TypingNotification, OwnReadReceipt, + Address, ReceiptType) +from mautrix.util.logging import TraceLogger + +from .db import Message as DBMessage +from . import user as u, portal as po, puppet as pu + +if TYPE_CHECKING: + from .__main__ import SignalBridge + + +class SignalHandler(SignaldClient): + log: TraceLogger = logging.getLogger("mau.signal") + loop: asyncio.AbstractEventLoop + + def __init__(self, bridge: 'SignalBridge') -> None: + super().__init__(bridge.config["signal.socket_path"], loop=bridge.loop) + self.add_event_handler(Message, self.on_message) + + async def on_message(self, evt: Message) -> None: + sender = await pu.Puppet.get_by_address(evt.source) + if not sender.uuid: + self.log.debug("Got message sender puppet with no UUID, not handling message") + self.log.trace("Message content: %s", evt) + return + user = await u.User.get_by_username(evt.username) + # TODO add lots of logging + + if evt.data_message: + await self.handle_message(user, sender, evt.data_message) + if evt.typing: + # Typing notification from someone else + pass + if evt.receipt: + await self.handle_receipt(sender, evt.receipt) + if evt.sync_message: + if evt.sync_message.read_messages: + await self.handle_own_receipts(sender, evt.sync_message.read_messages) + if evt.sync_message.contacts: + # Contact list update? + pass + if evt.sync_message.sent: + await self.handle_message(user, sender, evt.sync_message.sent.message, + recipient_override=evt.sync_message.sent.destination) + if evt.sync_message.typing: + # Typing notification from own device + pass + + @staticmethod + async def handle_message(user: 'u.User', sender: 'pu.Puppet', msg: MessageData, + recipient_override: Optional[Address] = None) -> None: + if msg.group: + portal = await po.Portal.get_by_chat_id(msg.group.group_id, receiver=user.username) + else: + portal = await po.Portal.get_by_chat_id(recipient_override.uuid + if recipient_override else sender.uuid, + receiver=user.username) + if not portal.mxid: + # TODO create room? + # TODO definitely at least log + return + if msg.reaction: + await portal.handle_signal_reaction(sender, msg.reaction) + if msg.body: + await portal.handle_signal_message(sender, msg) + + @staticmethod + async def handle_own_receipts(sender: 'pu.Puppet', receipts: List[OwnReadReceipt]) -> None: + for receipt in receipts: + puppet = await pu.Puppet.get_by_address(receipt.sender, create=False) + if not puppet or not puppet.uuid: + continue + message = await DBMessage.find_by_sender_timestamp(puppet.uuid, receipt.timestamp) + if not message: + continue + portal = await po.Portal.get_by_mxid(message.mx_room) + if not portal: + continue + await sender.intent_for(portal).mark_read(portal.mxid, message.mxid) + + @staticmethod + async def handle_receipt(sender: 'pu.Puppet', receipt: Receipt) -> None: + if receipt.type != ReceiptType.READ: + pass + messages = await DBMessage.find_by_timestamps(receipt.timestamps) + for message in messages: + portal = await po.Portal.get_by_mxid(message.mx_room) + await sender.intent_for(portal).mark_read(portal.mxid, message.mxid) + + async def start(self) -> None: + await self.connect() + async for user in u.User.all_logged_in(): + # TODO handle errors + await self.subscribe(user.username) + self.loop.create_task(user.sync()) + + async def stop(self) -> None: + await self.disconnect() diff --git a/mautrix_signal/user.py b/mautrix_signal/user.py new file mode 100644 index 0000000..f0562c2 --- /dev/null +++ b/mautrix_signal/user.py @@ -0,0 +1,155 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Dict, Optional, AsyncGenerator, TYPE_CHECKING, cast +from uuid import UUID +import asyncio + +from mausignald.types import Account +from mautrix.bridge import BaseUser +from mautrix.types import UserID, RoomID +from mautrix.appservice import AppService + +from .db import User as DBUser +from .config import Config +from . import puppet as pu, portal as po + +if TYPE_CHECKING: + from .__main__ import SignalBridge + + +class User(DBUser, BaseUser): + by_mxid: Dict[UserID, 'User'] = {} + by_username: Dict[str, 'User'] = {} + config: Config + az: AppService + loop: asyncio.AbstractEventLoop + bridge: 'SignalBridge' + + is_admin: bool + permission_level: str + + _notice_room_lock: asyncio.Lock + + def __init__(self, mxid: UserID, username: Optional[str] = None, uuid: Optional[UUID] = None, + notice_room: Optional[RoomID] = None) -> None: + super().__init__(mxid=mxid, username=username, uuid=uuid, notice_room=notice_room) + self._notice_room_lock = asyncio.Lock() + perms = self.config.get_permissions(mxid) + self.is_whitelisted, self.is_admin, self.permission_level = perms + self.log = self.log.getChild(self.mxid) + self.client = None + self.dm_update_lock = asyncio.Lock() + + @classmethod + def init_cls(cls, bridge: 'SignalBridge') -> None: + cls.bridge = bridge + cls.config = bridge.config + cls.az = bridge.az + cls.loop = bridge.loop + + async def on_signin(self, account: Account) -> None: + self.username = account.username + self.uuid = account.uuid + await self.update() + await self.bridge.signal.subscribe(self.username) + self.loop.create_task(self.sync()) + + async def sync(self) -> None: + try: + await self._sync() + except Exception: + self.log.exception("Error while syncing") + + async def _sync(self) -> None: + create_contact_portal = self.config["bridge.autocreate_contact_portal"] + for contact in await self.bridge.signal.list_contacts(self.username): + self.log.trace("Syncing contact %s", contact) + puppet = await pu.Puppet.get_by_address(contact.address) + if not puppet.name: + profile = await self.bridge.signal.get_profile(self.username, contact.address) + if profile: + self.log.trace("Got profile for %s: %s", contact.address, profile) + else: + # get_profile probably does a request to the servers, so let's not do that unless + # necessary, but maybe we could listen for updates? + profile = None + await puppet.update_info(profile or contact) + if puppet.uuid and create_contact_portal: + portal = await po.Portal.get_by_chat_id(puppet.uuid, self.username, create=True) + await portal.create_matrix_room(self, profile or contact) + + create_group_portal = self.config["bridge.autocreate_group_portal"] + for group in await self.bridge.signal.list_groups(self.username): + self.log.trace("Syncing group %s", group) + portal = await po.Portal.get_by_chat_id(group.group_id, create=True) + if create_group_portal: + await portal.create_matrix_room(self, group) + elif portal.mxid: + await portal.update_matrix_room(self, group) + + # region Database getters + + def _add_to_cache(self) -> None: + self.by_mxid[self.mxid] = self + if self.username: + self.by_username[self.username] = self + + @classmethod + async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['User']: + try: + return cls.by_mxid[mxid] + except KeyError: + pass + + user = cast(cls, await super().get_by_mxid(mxid)) + if user is not None: + user._add_to_cache() + return user + + if create: + user = cls(mxid) + await user.insert() + user._add_to_cache() + return user + + return None + + @classmethod + async def get_by_username(cls, username: str) -> Optional['User']: + try: + return cls.by_username[username] + except KeyError: + pass + + user = cast(cls, await super().get_by_username(username)) + if user is not None: + user._add_to_cache() + return user + + return None + + @classmethod + async def all_logged_in(cls) -> AsyncGenerator['User', None]: + users = await super().all_logged_in() + user: cls + for user in users: + try: + yield cls.by_mxid[user.mxid] + except KeyError: + user._add_to_cache() + yield user + + # endregion diff --git a/mautrix_signal/util/__init__.py b/mautrix_signal/util/__init__.py new file mode 100644 index 0000000..85565a7 --- /dev/null +++ b/mautrix_signal/util/__init__.py @@ -0,0 +1 @@ +from .color_log import ColorFormatter diff --git a/mautrix_signal/util/color_log.py b/mautrix_signal/util/color_log.py new file mode 100644 index 0000000..45b7221 --- /dev/null +++ b/mautrix_signal/util/color_log.py @@ -0,0 +1,25 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from mautrix.util.logging.color import ColorFormatter as BaseColorFormatter, PREFIX, RESET + +MAUSIGNALD_COLOR = PREFIX + "35;1m" # magenta + + +class ColorFormatter(BaseColorFormatter): + def _color_name(self, module: str) -> str: + if module.startswith("mausignald"): + return MAUSIGNALD_COLOR + module + RESET + return super()._color_name(module) diff --git a/mautrix_signal/version.py b/mautrix_signal/version.py new file mode 100644 index 0000000..47f7fff --- /dev/null +++ b/mautrix_signal/version.py @@ -0,0 +1 @@ +from .get_version import git_tag, git_revision, version, linkified_version diff --git a/mautrix_signal/web/__init__.py b/mautrix_signal/web/__init__.py new file mode 100644 index 0000000..d263dce --- /dev/null +++ b/mautrix_signal/web/__init__.py @@ -0,0 +1 @@ +from .provisioning_api import ProvisioningAPI diff --git a/mautrix_signal/web/provisioning_api.py b/mautrix_signal/web/provisioning_api.py new file mode 100644 index 0000000..c7ee879 --- /dev/null +++ b/mautrix_signal/web/provisioning_api.py @@ -0,0 +1,114 @@ +# mautrix-signal - A Matrix-Signal puppeting bridge +# Copyright (C) 2020 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 . +from typing import Awaitable, Dict +import logging +import json + +from aiohttp import web + +from mautrix.types import UserID +from mautrix.util.logging import TraceLogger + +from .. import user as u + + +class ProvisioningAPI: + log: TraceLogger = logging.getLogger("mau.web.provisioning") + app: web.Application + + def __init__(self, shared_secret: str) -> None: + self.app = web.Application() + self.shared_secret = shared_secret + self.app.router.add_get("/api/whoami", self.status) + self.app.router.add_options("/api/login", self.login_options) + self.app.router.add_post("/api/login", self.login) + self.app.router.add_post("/api/logout", self.logout) + + @property + def _acao_headers(self) -> Dict[str, str]: + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + } + + @property + def _headers(self) -> Dict[str, str]: + return { + **self._acao_headers, + "Content-Type": "application/json", + } + + async def login_options(self, _: web.Request) -> web.Response: + return web.Response(status=200, headers=self._headers) + + def check_token(self, request: web.Request) -> Awaitable['u.User']: + try: + token = request.headers["Authorization"] + token = token[len("Bearer "):] + except KeyError: + raise web.HTTPBadRequest(body='{"error": "Missing Authorization header"}', + headers=self._headers) + except IndexError: + raise web.HTTPBadRequest(body='{"error": "Malformed Authorization header"}', + headers=self._headers) + if token != self.shared_secret: + raise web.HTTPForbidden(body='{"error": "Invalid token"}', headers=self._headers) + try: + user_id = request.query["user_id"] + except KeyError: + raise web.HTTPBadRequest(body='{"error": "Missing user_id query param"}', + headers=self._headers) + + return u.User.get_by_mxid(UserID(user_id)) + + async def status(self, request: web.Request) -> web.Response: + user = await self.check_token(request) + data = { + "permissions": user.permission_level, + "mxid": user.mxid, + "twitter": None, + } + if await user.is_logged_in(): + data["twitter"] = (await user.get_info()).serialize() + return web.json_response(data, headers=self._acao_headers) + + async def login(self, request: web.Request) -> web.Response: + user = await self.check_token(request) + + try: + data = await request.json() + except json.JSONDecodeError: + raise web.HTTPBadRequest(body='{"error": "Malformed JSON"}', headers=self._headers) + + try: + auth_token = data["auth_token"] + csrf_token = data["csrf_token"] + except KeyError: + raise web.HTTPBadRequest(body='{"error": "Missing keys"}', headers=self._headers) + + try: + await user.connect(auth_token=auth_token, csrf_token=csrf_token) + except Exception: + self.log.debug("Failed to log in", exc_info=True) + raise web.HTTPUnauthorized(body='{"error": "Twitter authorization failed"}', + headers=self._headers) + return web.Response(body='{}', status=200, headers=self._headers) + + async def logout(self, request: web.Request) -> web.Response: + user = await self.check_token(request) + await user.logout() + return web.json_response({}, headers=self._acao_headers) diff --git a/optional-requirements.txt b/optional-requirements.txt new file mode 100644 index 0000000..5814330 --- /dev/null +++ b/optional-requirements.txt @@ -0,0 +1,17 @@ +# Format: #/name defines a new extras_require group called name +# Uncommented lines after the group definition insert things into that group. + +#/e2be +python-olm>=3,<4 +pycryptodome>=3,<4 +unpaddedbase64>=1,<2 + +#/metrics +prometheus_client>=0.6,<0.9 + +#/formattednumbers +phonenumbers>=8,<9 + +#/qrlink +qrcode>=6,<7 +Pillow>=4,<8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a0aded6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +ruamel.yaml>=0.15.35,<0.17 +python-magic>=0.4,<0.5 +commonmark>=0.8,<0.10 +aiohttp>=3,<4 +yarl>=1,<2 +attrs>=19.1 +mautrix>=0.7.9,<0.8 +asyncpg>=0.20,<0.22 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..63e234a --- /dev/null +++ b/setup.py @@ -0,0 +1,69 @@ +import setuptools + +from mautrix_signal.get_version import git_tag, git_revision, version, linkified_version + +with open("requirements.txt") as reqs: + install_requires = reqs.read().splitlines() + +with open("optional-requirements.txt") as reqs: + extras_require = {} + current = [] + for line in reqs.read().splitlines(): + if line.startswith("#/"): + extras_require[line[2:]] = current = [] + elif not line or line.startswith("#"): + continue + else: + current.append(line) + +extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps}) + +try: + long_desc = open("README.md").read() +except IOError: + long_desc = "Failed to read README.md" + +with open("mautrix_signal/version.py", "w") as version_file: + version_file.write(f"""# Generated in setup.py + +git_tag = {git_tag!r} +git_revision = {git_revision!r} +version = {version!r} +linkified_version = {linkified_version!r} +""") + +setuptools.setup( + name="mautrix-signal", + version=version, + url="https://github.com/tulir/mautrix-signal", + + author="Tulir Asokan", + author_email="tulir@maunium.net", + + description="A Matrix-Signal puppeting bridge.", + long_description=long_desc, + long_description_content_type="text/markdown", + + packages=setuptools.find_packages(), + + install_requires=install_requires, + extras_require=extras_require, + python_requires="~=3.7", + + classifiers=[ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", + "Topic :: Communications :: Chat", + "Framework :: AsyncIO", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + ], + package_data={"mautrix_signal": [ + "example-config.yaml", + ]}, + data_files=[ + (".", ["mautrix_signal/example-config.yaml"]), + ], +)