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