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

|
||||
[](LICENSE)
|
||||
[](https://github.com/tulir/mautrix-signal/releases)
|
||||
[](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
ROADMAP.md
Normal file
43
ROADMAP.md
Normal file
|
@ -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
docker-run.sh
Executable file
30
docker-run.sh
Executable file
|
@ -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
mausignald/README.md
Normal file
2
mausignald/README.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
# mausignald
|
||||
A Python/Asyncio library to communicate with [signald](https://gitlab.com/thefinn93/signald).
|
1
mausignald/__init__.py
Normal file
1
mausignald/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .signald import SignaldClient
|
44
mausignald/errors.py
Normal file
44
mausignald/errors.py
Normal file
|
@ -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
mausignald/rpc.py
Normal file
164
mausignald/rpc.py
Normal file
|
@ -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
mausignald/signald.py
Normal file
136
mausignald/signald.py
Normal file
|
@ -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
mausignald/types.py
Normal file
183
mausignald/types.py
Normal file
|
@ -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
mautrix_signal/__init__.py
Normal file
2
mautrix_signal/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
__version__ = "0.1.0"
|
||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
106
mautrix_signal/__main__.py
Normal file
106
mautrix_signal/__main__.py
Normal file
|
@ -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
mautrix_signal/commands/__init__.py
Normal file
2
mautrix_signal/commands/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .handler import (CommandProcessor, command_handler, CommandEvent, CommandHandler, SECTION_AUTH, SECTION_CONNECTION)
|
||||
from . import auth, conn
|
92
mautrix_signal/commands/auth.py
Normal file
92
mautrix_signal/commands/auth.py
Normal file
|
@ -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
mautrix_signal/commands/conn.py
Normal file
43
mautrix_signal/commands/conn.py
Normal file
|
@ -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
mautrix_signal/commands/handler.py
Normal file
98
mautrix_signal/commands/handler.py
Normal file
|
@ -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
mautrix_signal/config.py
Normal file
101
mautrix_signal/config.py
Normal file
|
@ -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
mautrix_signal/db/__init__.py
Normal file
16
mautrix_signal/db/__init__.py
Normal file
|
@ -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
mautrix_signal/db/message.py
Normal file
98
mautrix_signal/db/message.py
Normal file
|
@ -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
mautrix_signal/db/portal.py
Normal file
92
mautrix_signal/db/portal.py
Normal file
|
@ -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
mautrix_signal/db/puppet.py
Normal file
105
mautrix_signal/db/puppet.py
Normal file
|
@ -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
mautrix_signal/db/reaction.py
Normal file
91
mautrix_signal/db/reaction.py
Normal file
|
@ -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
mautrix_signal/db/upgrade.py
Normal file
91
mautrix_signal/db/upgrade.py
Normal file
|
@ -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
mautrix_signal/db/user.py
Normal file
65
mautrix_signal/db/user.py
Normal file
|
@ -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
mautrix_signal/example-config.yaml
Normal file
189
mautrix_signal/example-config.yaml
Normal file
|
@ -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
mautrix_signal/get_version.py
Normal file
50
mautrix_signal/get_version.py
Normal file
|
@ -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
mautrix_signal/matrix.py
Normal file
142
mautrix_signal/matrix.py
Normal file
|
@ -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
mautrix_signal/portal.py
Normal file
597
mautrix_signal/portal.py
Normal file
|
@ -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
mautrix_signal/puppet.py
Normal file
292
mautrix_signal/puppet.py
Normal file
|
@ -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
mautrix_signal/signal.py
Normal file
118
mautrix_signal/signal.py
Normal file
|
@ -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
mautrix_signal/user.py
Normal file
155
mautrix_signal/user.py
Normal file
|
@ -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
mautrix_signal/util/__init__.py
Normal file
1
mautrix_signal/util/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .color_log import ColorFormatter
|
25
mautrix_signal/util/color_log.py
Normal file
25
mautrix_signal/util/color_log.py
Normal file
|
@ -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
mautrix_signal/version.py
Normal file
1
mautrix_signal/version.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .get_version import git_tag, git_revision, version, linkified_version
|
1
mautrix_signal/web/__init__.py
Normal file
1
mautrix_signal/web/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .provisioning_api import ProvisioningAPI
|
114
mautrix_signal/web/provisioning_api.py
Normal file
114
mautrix_signal/web/provisioning_api.py
Normal file
|
@ -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
optional-requirements.txt
Normal file
17
optional-requirements.txt
Normal file
|
@ -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
requirements.txt
Normal file
8
requirements.txt
Normal file
|
@ -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
setup.py
Normal file
69
setup.py
Normal file
|
@ -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"]),
|
||||
],
|
||||
)
|
Loading…
Add table
Reference in a new issue