Tulir Asokan 4 лет назад
Сommit
a5a50f43e0
45 измененных файлов с 4196 добавлено и 0 удалено
  1. 8 0
      .dockerignore
  2. 18 0
      .editorconfig
  3. 18 0
      .gitignore
  4. 41 0
      .gitlab-ci.yml
  5. 48 0
      Dockerfile
  6. 661 0
      LICENSE
  7. 4 0
      MANIFEST.in
  8. 14 0
      README.md
  9. 43 0
      ROADMAP.md
  10. 30 0
      docker-run.sh
  11. 2 0
      mausignald/README.md
  12. 1 0
      mausignald/__init__.py
  13. 44 0
      mausignald/errors.py
  14. 164 0
      mausignald/rpc.py
  15. 136 0
      mausignald/signald.py
  16. 183 0
      mausignald/types.py
  17. 2 0
      mautrix_signal/__init__.py
  18. 106 0
      mautrix_signal/__main__.py
  19. 2 0
      mautrix_signal/commands/__init__.py
  20. 92 0
      mautrix_signal/commands/auth.py
  21. 43 0
      mautrix_signal/commands/conn.py
  22. 98 0
      mautrix_signal/commands/handler.py
  23. 101 0
      mautrix_signal/config.py
  24. 16 0
      mautrix_signal/db/__init__.py
  25. 98 0
      mautrix_signal/db/message.py
  26. 92 0
      mautrix_signal/db/portal.py
  27. 105 0
      mautrix_signal/db/puppet.py
  28. 91 0
      mautrix_signal/db/reaction.py
  29. 91 0
      mautrix_signal/db/upgrade.py
  30. 65 0
      mautrix_signal/db/user.py
  31. 189 0
      mautrix_signal/example-config.yaml
  32. 50 0
      mautrix_signal/get_version.py
  33. 142 0
      mautrix_signal/matrix.py
  34. 597 0
      mautrix_signal/portal.py
  35. 292 0
      mautrix_signal/puppet.py
  36. 118 0
      mautrix_signal/signal.py
  37. 155 0
      mautrix_signal/user.py
  38. 1 0
      mautrix_signal/util/__init__.py
  39. 25 0
      mautrix_signal/util/color_log.py
  40. 1 0
      mautrix_signal/version.py
  41. 1 0
      mautrix_signal/web/__init__.py
  42. 114 0
      mautrix_signal/web/provisioning_api.py
  43. 17 0
      optional-requirements.txt
  44. 8 0
      requirements.txt
  45. 69 0
      setup.py

+ 8 - 0
.dockerignore

@@ -0,0 +1,8 @@
+.editorconfig
+logs
+.venv
+start
+config.yaml
+registration.yaml
+*.db
+*.pickle

+ 18 - 0
.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

+ 18 - 0
.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

+ 41 - 0
.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

+ 48 - 0
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"]

+ 661 - 0
LICENSE

@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.

+ 4 - 0
MANIFEST.in

@@ -0,0 +1,4 @@
+include README.md
+include LICENSE
+include requirements.txt
+include optional-requirements.txt

+ 14 - 0
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)

+ 43 - 0
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

+ 30 - 0
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

+ 2 - 0
mausignald/README.md

@@ -0,0 +1,2 @@
+# mausignald
+A Python/Asyncio library to communicate with [signald](https://gitlab.com/thefinn93/signald).

+ 1 - 0
mausignald/__init__.py

@@ -0,0 +1 @@
+from .signald import SignaldClient

+ 44 - 0
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)

+ 164 - 0
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)

+ 136 - 0
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)

+ 183 - 0
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

+ 2 - 0
mautrix_signal/__init__.py

@@ -0,0 +1,2 @@
+__version__ = "0.1.0"
+__author__ = "Tulir Asokan <tulir@maunium.net>"

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

+ 2 - 0
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

+ 92 - 0
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 <https://www.gnu.org/licenses/>.
+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="<phone>")
+async def register(evt: CommandEvent) -> None:
+    if len(evt.args) == 0:
+        await evt.reply("**Usage**: $cmdprefix+sp register <phone>")
+        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)}")

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

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

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

+ 16 - 0
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"]

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

+ 92 - 0
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 <https://www.gnu.org/licenses/>.
+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]

+ 105 - 0
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 <https://www.gnu.org/licenses/>.
+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]

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

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

+ 65 - 0
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 <https://www.gnu.org/licenses/>.
+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]

+ 189 - 0
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]

+ 50 - 0
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

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

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

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

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

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

+ 1 - 0
mautrix_signal/util/__init__.py

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

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

+ 1 - 0
mautrix_signal/version.py

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

+ 1 - 0
mautrix_signal/web/__init__.py

@@ -0,0 +1 @@
+from .provisioning_api import ProvisioningAPI

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

+ 17 - 0
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

+ 8 - 0
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

+ 69 - 0
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"]),
+    ],
+)