Compare commits
1 commit
master
...
wip_xmpp_a
Author | SHA1 | Date | |
---|---|---|---|
4d16a3e436 |
45 changed files with 1403 additions and 7499 deletions
|
@ -1,7 +0,0 @@
|
|||
pipeline:
|
||||
build:
|
||||
image: golang:stretch
|
||||
commands:
|
||||
- go get -d -v
|
||||
- go build -v
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,4 +2,3 @@ easybridge
|
|||
config.json
|
||||
registration.yaml
|
||||
easybridge.db
|
||||
__pycache__
|
||||
|
|
16
Dockerfile
16
Dockerfile
|
@ -1,16 +0,0 @@
|
|||
FROM archlinux:latest
|
||||
RUN pacman -Sy python-pip --noconfirm; pacman -Scc --noconfirm; find /var/cache/pacman/ -type f -delete; find /var/lib/pacman/sync/ -type f -delete
|
||||
|
||||
#FROM python:3.8.6-buster
|
||||
|
||||
RUN pip install fbchat==1.9.7
|
||||
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
ADD static /app/static
|
||||
ADD easybridge.jpg /app/easybridge.jpg
|
||||
ADD external /app/external
|
||||
ADD easybridge /app/easybridge
|
||||
ADD templates /app/templates
|
||||
|
||||
CMD "/app/easybridge"
|
675
LICENSE
675
LICENSE
|
@ -1,675 +0,0 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 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 General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
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.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
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 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
31
Makefile
31
Makefile
|
@ -1,29 +1,2 @@
|
|||
BIN=easybridge
|
||||
DOCKER=lxpz/easybridge_amd64
|
||||
|
||||
SRC= mxlib/registration.go mxlib/api.go mxlib/mediaobject.go mxlib/client.go \
|
||||
connector/connector.go connector/config.go \
|
||||
connector/mediaobject.go connector/marshal.go \
|
||||
connector/irc/irc.go connector/irc/config.go \
|
||||
connector/xmpp/config.go connector/xmpp/xmpp.go \
|
||||
connector/mattermost/mattermost.go connector/mattermost/config.go \
|
||||
connector/external/external.go connector/external/config.go \
|
||||
web.go account.go main.go server.go db.go util.go
|
||||
|
||||
all: $(BIN)
|
||||
|
||||
$(BIN): $(SRC)
|
||||
go get -d -v
|
||||
go build -v -o $(BIN)
|
||||
|
||||
docker: $(BIN)
|
||||
docker build -t $(DOCKER):$(TAG) .
|
||||
docker push $(DOCKER):$(TAG)
|
||||
docker tag $(DOCKER):$(TAG) $(DOCKER):latest
|
||||
docker push $(DOCKER):latest
|
||||
|
||||
docker_clean: $(BIN)
|
||||
docker build --no-cache -t $(DOCKER):$(TAG) .
|
||||
docker push $(DOCKER):$(TAG)
|
||||
docker tag $(DOCKER):$(TAG) $(DOCKER):latest
|
||||
docker push $(DOCKER):latest
|
||||
all:
|
||||
go build
|
||||
|
|
120
README.md
120
README.md
|
@ -1,120 +0,0 @@
|
|||
# Easybridge
|
||||
|
||||
Easybridge is a Matrix-to-everything (almost) bridge.
|
||||
It acts as a client under your name on all instant messaging networks where
|
||||
you have an account, and presents your private messages as well as group
|
||||
conversations in Matrix in a uniform view.
|
||||
Basically, Matrix becomes your everything chat client!
|
||||
Easybridge is a multi-user bridge for small Matrix servers,
|
||||
with the idea of making it easy for non-technical users to bridge their external accounts into Matrix.
|
||||
Once configured next to a server,
|
||||
users can just go to a dedicated web page and add their accounts with a simple form.
|
||||
|
||||
**WARNING** Easybridge is still very experimental and crashes sometimes.
|
||||
|
||||
Current protocol status:
|
||||
|
||||
- IRC: text messages only (private messages and public channels). Has bugs, most notably: messages will be duplicated if several users connect to the same channel.
|
||||
- XMPP: text messages only (private chat and MUCs), no backlog, no avatars, no file transfer.
|
||||
- Mattermost: in quite good shape. Private & group conversations with text messages and attachments (images or other files). Handles retrieving of message backlog, user avatars and room avatars from the server.
|
||||
- Facebook messenger: in quite good shape, handles private messages and groups, text messages, attachments, stickers, profile pictures (low res only), backlog. Sometimes disconnects from the server and messages stop arriving.
|
||||
|
||||
Adding a backend shouldn't be too hard if a Golang library exists to connect to that protocol.
|
||||
Easybridge can also spawn an external process to communicate using a certain protocol if no Golang library
|
||||
is available (this is currently used by the Facebook Messenger backend which is written in Python using the `fbchat` library).
|
||||
|
||||
Current features:
|
||||
|
||||
- Handles private chats (one-to-one conversations) as well as group chats (sometimes called channels, multi-user chats, or chat rooms)
|
||||
- Automatic setup of Matrix rooms that bridge to outside rooms
|
||||
- Room name and topic synchronization (partially)
|
||||
- Images and file transfers (Mattermost and FB Messenger backends)
|
||||
- Avatar and room pictures (Mattermost backend and FB Messenger backends, one-way only)
|
||||
- Retrieving old messages / missed messages when backend was disconnected (Mattermost and FB Messenger)
|
||||
- Web interface for setting up accounts so that new accounts can be easily
|
||||
added and you don't have to type your credentials in a clear-text Matrix room
|
||||
- Credentials are stored encrypted in the database using users' Matrix passwords
|
||||
|
||||
There is lot to do! See the issues if you want to join us on this project.
|
||||
|
||||
A Docker image is provided on the [Docker hub](https://hub.docker.com/r/lxpz/easybridge_amd64).
|
||||
|
||||
Easybridge is licensed under the terms of the GPLv3.
|
||||
|
||||
Contact me if you have questions or are interested in contributing to the project: [@lx:deuxfleurs.fr](https://matrix.to/#/@lx:deuxfleurs.fr).
|
||||
|
||||
|
||||
## Building Easybridge
|
||||
|
||||
Easybridge requires go 1.13 or later. The Facebook Messenger backend requires Python 3 and the `fbchat` library (tested with v1.9.6).
|
||||
|
||||
To build Easybridge, clone this repository outside of your `$GOPATH`.
|
||||
Then, run `make` in the root of the repo.
|
||||
|
||||
|
||||
## Operating Easybridge
|
||||
|
||||
Easybridge acts as a Matrix application service: in this regard,
|
||||
it requires a registration file to be added to your homeserver.
|
||||
It uses a database to store configuration and state information,
|
||||
which can be any backend supported by [Gorm](https://gorm.io).
|
||||
|
||||
Easybridge takes a single command line argument, `-config <filename>`, which is the
|
||||
path to its config file (defaults to `./config.json`).
|
||||
The configuration file is a JSON file whose contents is described in the following section.
|
||||
|
||||
If the config file does not exist, a template will be created.
|
||||
A template appservice registration file will also be created if necessary.
|
||||
|
||||
|
||||
## Configurating Easybridge
|
||||
|
||||
Easybridge is configured using a single JSON configuration file, which contains
|
||||
a dictionnary whose keys are the following:
|
||||
|
||||
- `log_level`: what log level Easybridge runs with (trace, debug, info, warn, error, fatal, panic). **Warning:** in `trace` level, the content of all calls to the Matrix API and some other information will be dumped, exposing user's credentials and messages. In `debug` level, room join/leave information will be exposed. The `info` level (default) does not expose any user's private information.
|
||||
- `easybridge_avatar`: path to the image that Easybridge uses as an avatar on Matrix
|
||||
|
||||
### Matrix configuration
|
||||
|
||||
- `registration`: path to the YAML appservice registration file
|
||||
- `appservice_bind_addr`: on what IP/port to bind as a Matrix app service (HTTP only, no HTTPS)
|
||||
- `homeserver_url`: HTTP address of the Matrix homeserver
|
||||
- `matrix_domain`: the domain name of the Matrix homeserver (i.e. the domain used in user identifiers, room identifiers, etc)
|
||||
- `name_format`: the format of identifiers that are created on Matrix for users and room aliases. `{}` is replaced by the concatenation of user/room identifier and protocol. Typically you want either `_ezbr_{}` or `{}_ezbr`, the latter having the advantage that the briged user's names are then used as prefixes for the created identifiers.
|
||||
|
||||
### Web interface configuration
|
||||
|
||||
- `web_bind_addr`: on what IP/port to bind for the web interface that allows adding and configuring accounts (HTTP only, no HTTPS, use a reverse proxy for that)
|
||||
- `web_url`: the outside HTTP/HTTPS address at which the web interface is made available. If set, a widget will be added in the Easybridge room so that users can configure the bridge without leaving the Riot client.
|
||||
- `session_key`: a key with which session cookies are encrypted for the web interface
|
||||
|
||||
### Storage configuration
|
||||
|
||||
- `db_type` and `db_path`: the database backend and path to use (see the [Gorm documentation](http://gorm.io/docs/connecting_to_the_database.html))
|
||||
|
||||
|
||||
## Facebook Messenger alternative login procedure
|
||||
|
||||
The default login procedure for the Messenger backend is to log in with your email and password.
|
||||
Unfortunately, if this login procedure happenned to often, you would get rate limited on login
|
||||
attempts quite rapidly.
|
||||
To bypass this issue, once sucessfully logged in Easybridge will store a *client pickle* in your account
|
||||
configuration, i.e. a blob that contains your Facebook cookies so that the username+password procedure
|
||||
doesn't need to be repeated everytime Easybridge restarts.
|
||||
|
||||
If the automated username+password procedure fails, the user can generate a client pickle from the
|
||||
command line and use that in the configuration instead of entering a username and a password.
|
||||
To use this method, **do not enter your password in Easybridge when configuring the Messenger backend (only enter your email address)**.
|
||||
Generate your client pickle by running the following command:
|
||||
|
||||
```
|
||||
./external/messenger.py create_client_pickle
|
||||
```
|
||||
|
||||
This procedure will ask for your email and password and attempt to log you in to Facebook.
|
||||
If it succeeds, it will print several dozen lines of data looking like `eJyVVlt....X9cgyfgY7mJaK`.
|
||||
Then, when configuring the Messenger backend in Easybridge,
|
||||
instead of entering your password,
|
||||
enter the obtained client pickle string in the appropriate field.
|
||||
|
541
account.go
541
account.go
|
@ -1,541 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
|
||||
// Necessary for them to register their protocols
|
||||
_ "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector/external"
|
||||
_ "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector/irc"
|
||||
_ "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector/mattermost"
|
||||
_ "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector/xmpp"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
MatrixUser string
|
||||
AccountName string
|
||||
Protocol string
|
||||
Config map[string]string
|
||||
|
||||
Conn Connector
|
||||
JoinedRooms map[RoomID]bool
|
||||
}
|
||||
|
||||
var accountsLock sync.Mutex
|
||||
var registeredAccounts = map[string]map[string]*Account{}
|
||||
|
||||
func SetAccount(mxid string, name string, protocol string, config map[string]string) error {
|
||||
accountsLock.Lock()
|
||||
defer accountsLock.Unlock()
|
||||
|
||||
if _, ok := registeredAccounts[mxid]; !ok {
|
||||
registeredAccounts[mxid] = make(map[string]*Account)
|
||||
}
|
||||
accounts := registeredAccounts[mxid]
|
||||
|
||||
// Check we can create connector
|
||||
proto, ok := Protocols[protocol]
|
||||
if !ok {
|
||||
return fmt.Errorf("Invalid protocol: %s", protocol)
|
||||
}
|
||||
conn := proto.NewConnector()
|
||||
if conn == nil {
|
||||
return fmt.Errorf("Could not create connector for protocol %s", protocol)
|
||||
}
|
||||
|
||||
// If the account existed already, close and drop connector
|
||||
if prev_acct, ok := accounts[name]; ok {
|
||||
if prev_acct.Protocol == protocol && reflect.DeepEqual(config, prev_acct.Config) {
|
||||
return nil
|
||||
}
|
||||
|
||||
go prev_acct.Conn.Close()
|
||||
delete(accounts, name)
|
||||
}
|
||||
|
||||
// Configure and connect
|
||||
account := &Account{
|
||||
MatrixUser: mxid,
|
||||
AccountName: name,
|
||||
Protocol: protocol,
|
||||
Config: config,
|
||||
Conn: conn,
|
||||
JoinedRooms: map[RoomID]bool{},
|
||||
}
|
||||
conn.SetHandler(account)
|
||||
|
||||
accounts[name] = account
|
||||
go account.connect()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListAccounts(mxUser string) []*Account {
|
||||
accountsLock.Lock()
|
||||
defer accountsLock.Unlock()
|
||||
|
||||
ret := []*Account{}
|
||||
if accts, ok := registeredAccounts[mxUser]; ok {
|
||||
for _, acct := range accts {
|
||||
ret = append(ret, acct)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func FindAccount(mxUser string, name string) *Account {
|
||||
accountsLock.Lock()
|
||||
defer accountsLock.Unlock()
|
||||
|
||||
if u, ok := registeredAccounts[mxUser]; ok {
|
||||
if a, ok := u[name]; ok {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FindJoinedAccount(mxUser string, protocol string, room RoomID) *Account {
|
||||
accountsLock.Lock()
|
||||
defer accountsLock.Unlock()
|
||||
|
||||
if u, ok := registeredAccounts[mxUser]; ok {
|
||||
for _, acct := range u {
|
||||
if acct.Protocol == protocol {
|
||||
if j, ok := acct.JoinedRooms[room]; ok && j {
|
||||
return acct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoveAccount(mxUser string, name string) {
|
||||
accountsLock.Lock()
|
||||
defer accountsLock.Unlock()
|
||||
|
||||
if u, ok := registeredAccounts[mxUser]; ok {
|
||||
if acct, ok := u[name]; ok {
|
||||
acct.Conn.Close()
|
||||
}
|
||||
delete(u, name)
|
||||
}
|
||||
}
|
||||
|
||||
func CloseAllAccountsForShutdown() {
|
||||
accountsLock.Lock()
|
||||
defer accountsLock.Unlock()
|
||||
|
||||
for _, accl := range registeredAccounts {
|
||||
for _, acct := range accl {
|
||||
log.Printf("Closing %s %s (%s)", acct.MatrixUser, acct.AccountName, acct.Protocol)
|
||||
acct.Conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func SaveDbAccounts(mxid string, key *[32]byte) {
|
||||
accountsLock.Lock()
|
||||
defer accountsLock.Unlock()
|
||||
|
||||
if accounts, ok := registeredAccounts[mxid]; ok {
|
||||
for name, acct := range accounts {
|
||||
var entry DbAccountConfig
|
||||
db.Where(&DbAccountConfig{
|
||||
MxUserID: mxid,
|
||||
Name: name,
|
||||
}).Assign(&DbAccountConfig{
|
||||
Protocol: acct.Protocol,
|
||||
Config: encryptAccountConfig(acct.Config, key),
|
||||
}).FirstOrCreate(&entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func LoadDbAccounts(mxid string, key *[32]byte) {
|
||||
var allAccounts []DbAccountConfig
|
||||
db.Where(&DbAccountConfig{MxUserID: mxid}).Find(&allAccounts)
|
||||
for _, acct := range allAccounts {
|
||||
config, err := decryptAccountConfig(acct.Config, key)
|
||||
if err != nil {
|
||||
ezbrSystemSendf("Could not decrypt stored configuration for account %s (%s)", acct.Name, acct.Protocol)
|
||||
continue
|
||||
}
|
||||
|
||||
err = SetAccount(mxid, acct.Name, acct.Protocol, config)
|
||||
if err != nil {
|
||||
ezbrSystemSendf(mxid, "Could not setup account %s: %s", acct.Name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func (a *Account) ezbrMessagef(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
msg = fmt.Sprintf("%s %s: %s", a.Protocol, a.AccountName, msg)
|
||||
ezbrSystemSend(a.MatrixUser, msg)
|
||||
}
|
||||
|
||||
func (a *Account) connect() {
|
||||
log.Printf("Connecting %s %s (%s)", a.MatrixUser, a.AccountName, a.Protocol)
|
||||
ezbrSystemSendf(a.MatrixUser, "Connecting to account %s (%s)", a.AccountName, a.Protocol)
|
||||
|
||||
err := a.Conn.Configure(a.Config)
|
||||
if err != nil {
|
||||
ezbrSystemSendf(a.MatrixUser, "%s (%s) cannot connect: %s", a.AccountName, a.Protocol, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var autojoin []DbJoinedRoom
|
||||
db.Where(&DbJoinedRoom{
|
||||
MxUserID: a.MatrixUser,
|
||||
Protocol: a.Protocol,
|
||||
AccountName: a.AccountName,
|
||||
}).Find(&autojoin)
|
||||
for _, aj := range autojoin {
|
||||
err := a.Conn.Join(aj.RoomID)
|
||||
if err != nil {
|
||||
ezbrSystemSendf(a.MatrixUser, "%s (%s) cannot join %s: %s", a.AccountName, a.Protocol, aj.RoomID, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) addAutojoin(roomId RoomID) {
|
||||
var entry DbJoinedRoom
|
||||
db.Where(&DbJoinedRoom{
|
||||
MxUserID: a.MatrixUser,
|
||||
Protocol: a.Protocol,
|
||||
AccountName: a.AccountName,
|
||||
RoomID: roomId,
|
||||
}).FirstOrCreate(&entry)
|
||||
}
|
||||
|
||||
func (a *Account) delAutojoin(roomId RoomID) {
|
||||
db.Where(&DbJoinedRoom{
|
||||
MxUserID: a.MatrixUser,
|
||||
Protocol: a.Protocol,
|
||||
AccountName: a.AccountName,
|
||||
RoomID: roomId,
|
||||
}).Delete(&DbJoinedRoom{})
|
||||
}
|
||||
|
||||
// ---- Begin event handlers ----
|
||||
|
||||
func (a *Account) SystemMessage(msg string) {
|
||||
a.ezbrMessagef("%s", msg)
|
||||
}
|
||||
|
||||
func (a *Account) SaveConfig(config Configuration) {
|
||||
a.Config = config
|
||||
if key, ok := userKeys[a.MatrixUser]; ok {
|
||||
var entry DbAccountConfig
|
||||
db.Where(&DbAccountConfig{
|
||||
MxUserID: a.MatrixUser,
|
||||
Name: a.AccountName,
|
||||
}).Assign(&DbAccountConfig{
|
||||
Protocol: a.Protocol,
|
||||
Config: encryptAccountConfig(a.Config, key),
|
||||
}).FirstOrCreate(&entry)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) Joined(roomId RoomID) {
|
||||
err := a.joinedInternal(roomId)
|
||||
if err != nil {
|
||||
a.ezbrMessagef("Dropping Account.Joined %s: %s", roomId, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) joinedInternal(roomId RoomID) error {
|
||||
a.JoinedRooms[roomId] = true
|
||||
|
||||
a.addAutojoin(roomId)
|
||||
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, roomId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Tracef("Joined %s (%s)\n", roomId, a.MatrixUser)
|
||||
|
||||
err = mx.RoomInvite(mx_room_id, a.MatrixUser)
|
||||
if err != nil && strings.Contains(err.Error(), "already in the room") {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func (a *Account) Left(roomId RoomID) {
|
||||
err := a.leftInternal(roomId)
|
||||
if err != nil {
|
||||
a.ezbrMessagef("Dropping Account.Left %s: %s", roomId, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) leftInternal(roomId RoomID) error {
|
||||
delete(a.JoinedRooms, roomId)
|
||||
|
||||
a.delAutojoin(roomId)
|
||||
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, roomId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Tracef("Left %s (%s)\n", roomId, a.MatrixUser)
|
||||
|
||||
err = mx.RoomKick(mx_room_id, a.MatrixUser, fmt.Sprintf("got leave room event on %s", a.Protocol))
|
||||
if err != nil && strings.Contains(err.Error(), "not in the room") {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func (a *Account) UserInfoUpdated(user UserID, info *UserInfo) {
|
||||
err := a.userInfoUpdatedInternal(user, info)
|
||||
if err != nil {
|
||||
a.ezbrMessagef("Dropping Account.UserInfoUpdated %s: %s", user, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) userInfoUpdatedInternal(user UserID, info *UserInfo) error {
|
||||
mx_user_id, err := dbGetMxUser(a.Protocol, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.DisplayName != "" {
|
||||
err2 := mx.ProfileDisplayname(mx_user_id, fmt.Sprintf("%s (%s)", info.DisplayName, a.Protocol))
|
||||
if err2 != nil {
|
||||
err = err2
|
||||
}
|
||||
}
|
||||
|
||||
if info.Avatar.MediaObject != nil {
|
||||
cache_key := fmt.Sprintf("%s/user_avatar/%s", a.Protocol, user)
|
||||
cache_val := info.Avatar.Filename()
|
||||
if cache_val == "" || dbKvGet(cache_key) != cache_val {
|
||||
err2 := mx.ProfileAvatar(mx_user_id, info.Avatar)
|
||||
if err2 == nil {
|
||||
dbKvPut(cache_key, cache_val)
|
||||
} else {
|
||||
err = err2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func (a *Account) RoomInfoUpdated(roomId RoomID, author UserID, info *RoomInfo) {
|
||||
err := a.roomInfoUpdatedInternal(roomId, author, info)
|
||||
if err != nil {
|
||||
a.ezbrMessagef("Dropping Account.RoomInfoUpdated %s: %s", roomId, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) roomInfoUpdatedInternal(roomId RoomID, author UserID, info *RoomInfo) error {
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, roomId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
as_mxid := ezbrMxId()
|
||||
if author == a.Conn.User() {
|
||||
as_mxid = a.MatrixUser
|
||||
} else if len(author) > 0 {
|
||||
mx_user_id, err2 := dbGetMxUser(a.Protocol, author)
|
||||
if err2 == nil {
|
||||
as_mxid = mx_user_id
|
||||
}
|
||||
}
|
||||
|
||||
if info.Topic != "" {
|
||||
err2 := mx.RoomTopicAs(mx_room_id, info.Topic, as_mxid)
|
||||
if err2 != nil {
|
||||
err = err2
|
||||
}
|
||||
}
|
||||
|
||||
if info.Name != "" {
|
||||
name := fmt.Sprintf("%s (%s)", info.Name, a.Protocol)
|
||||
err2 := mx.RoomNameAs(mx_room_id, name, as_mxid)
|
||||
if err2 != nil {
|
||||
err = err2
|
||||
}
|
||||
}
|
||||
|
||||
if info.Picture.MediaObject != nil {
|
||||
cache_key := fmt.Sprintf("%s/room_picture/%s", a.Protocol, roomId)
|
||||
cache_val := info.Picture.Filename()
|
||||
if cache_val == "" || dbKvGet(cache_key) != cache_val {
|
||||
err2 := mx.RoomAvatarAs(mx_room_id, info.Picture, as_mxid)
|
||||
if err2 == nil {
|
||||
dbKvPut(cache_key, cache_val)
|
||||
} else {
|
||||
err = err2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func (a *Account) Event(event *Event) {
|
||||
err := a.eventInternal(event)
|
||||
if err != nil {
|
||||
a.ezbrMessagef("Dropping Account.Event %s %s %s: %s", event.Author, event.Recipient, event.Room, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) eventInternal(event *Event) error {
|
||||
// TODO: automatically ignore events that come from one of our bridged matrix users
|
||||
// TODO: deduplicate events if we have several matrix users joined the same room (hard problem)
|
||||
|
||||
mx_user_id, err := dbGetMxUser(a.Protocol, event.Author)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if event.Type == EVENT_JOIN {
|
||||
log.Tracef("%s join %s %s", a.Protocol, event.Author, event.Room)
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, event.Room)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = mx.RoomInvite(mx_room_id, mx_user_id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already in the room") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return mx.RoomJoinAs(mx_room_id, mx_user_id)
|
||||
} else if event.Type == EVENT_LEAVE {
|
||||
log.Tracef("%s join %s %s", a.Protocol, event.Author, event.Room)
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, event.Room)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mx.RoomLeaveAs(mx_room_id, mx_user_id)
|
||||
} else {
|
||||
log.Tracef("%s msg %s %s", a.Protocol, event.Author, event.Room)
|
||||
mx_room_id := ""
|
||||
|
||||
if len(event.Room) > 0 {
|
||||
mx_room_id, err = dbGetMxRoom(a.Protocol, event.Room)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
mx_room_id, err = dbGetMxPmRoom(a.Protocol, event.Author, mx_user_id, a.MatrixUser, a.AccountName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var cache_key string
|
||||
if event.Id != "" {
|
||||
// Use mx_room_id as a lock slot key, because this section is
|
||||
// concurrent with the part that sends events out in server.go,
|
||||
// and at that point we don't know the event's ID yet
|
||||
// since it will be attributed by the backend during the call to send
|
||||
dbLockSlot(mx_room_id)
|
||||
defer dbUnlockSlot(mx_room_id)
|
||||
|
||||
// If the event has an ID, make sure it is processed only once
|
||||
cache_key = fmt.Sprintf("%s/event_seen/%s/%s",
|
||||
a.Protocol, mx_room_id, event.Id)
|
||||
if dbKvGet(cache_key) == "yes" {
|
||||
// false: cache key was not modified, meaning we
|
||||
// already saw the event
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
typ := "m.text"
|
||||
if event.Type == EVENT_ACTION {
|
||||
typ = "m.emote"
|
||||
}
|
||||
|
||||
err = mx.SendMessageAs(mx_room_id, typ, event.Text, mx_user_id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if event.Attachments != nil {
|
||||
for _, file := range event.Attachments {
|
||||
mxfile, err := mx.UploadMedia(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := map[string]interface{}{
|
||||
"body": mxfile.Filename(),
|
||||
"filename": mxfile.Filename(),
|
||||
"url": fmt.Sprintf("mxc://%s/%s", mxfile.MxcServer, mxfile.MxcMediaId),
|
||||
}
|
||||
if sz := mxfile.ImageSize(); sz != nil {
|
||||
content["msgtype"] = "m.image"
|
||||
content["info"] = map[string]interface{}{
|
||||
"mimetype": mxfile.Mimetype(),
|
||||
"size": mxfile.Size(),
|
||||
"w": sz.Width,
|
||||
"h": sz.Height,
|
||||
}
|
||||
} else {
|
||||
content["msgtype"] = "m.file"
|
||||
content["info"] = map[string]interface{}{
|
||||
"mimetype": mxfile.Mimetype(),
|
||||
"size": mxfile.Size(),
|
||||
}
|
||||
}
|
||||
err = mx.SendAs(mx_room_id, "m.room.message", content, mx_user_id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark message as received in db
|
||||
if cache_key != "" {
|
||||
dbKvPutLocked(cache_key, "yes")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func (a *Account) CacheGet(key string) string {
|
||||
cache_key := fmt.Sprintf("%s/account/%s/%s/%s",
|
||||
a.Protocol, a.MatrixUser, a.AccountName, key)
|
||||
return dbKvGet(cache_key)
|
||||
}
|
||||
|
||||
func (a *Account) CachePut(key string, value string) {
|
||||
cache_key := fmt.Sprintf("%s/account/%s/%s/%s",
|
||||
a.Protocol, a.MatrixUser, a.AccountName, key)
|
||||
dbKvPut(cache_key, value)
|
||||
}
|
192
appservice/account.go
Normal file
192
appservice/account.go
Normal file
|
@ -0,0 +1,192 @@
|
|||
package appservice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
MatrixUser string
|
||||
AccountName string
|
||||
Protocol string
|
||||
Conn Connector
|
||||
|
||||
JoinedRooms map[RoomID]bool
|
||||
}
|
||||
|
||||
var registeredAccounts = map[string]map[string]*Account{}
|
||||
|
||||
func AddAccount(a *Account) {
|
||||
if _, ok := registeredAccounts[a.MatrixUser]; !ok {
|
||||
registeredAccounts[a.MatrixUser] = make(map[string]*Account)
|
||||
}
|
||||
registeredAccounts[a.MatrixUser][a.AccountName] = a
|
||||
}
|
||||
|
||||
func FindAccount(mxUser string, name string) *Account {
|
||||
if u, ok := registeredAccounts[mxUser]; ok {
|
||||
if a, ok := u[name]; ok {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FindJoinedAccount(mxUser string, protocol string, room RoomID) *Account {
|
||||
if u, ok := registeredAccounts[mxUser]; ok {
|
||||
for _, acct := range u {
|
||||
if acct.Protocol == protocol {
|
||||
if j, ok := acct.JoinedRooms[room]; ok && j {
|
||||
return acct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoveAccount(mxUser string, name string) {
|
||||
if u, ok := registeredAccounts[mxUser]; ok {
|
||||
delete(u, name)
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func (a *Account) Joined(roomId RoomID) {
|
||||
a.JoinedRooms[roomId] = true
|
||||
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, roomId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Joined %s (%s)\n", roomId, a.MatrixUser)
|
||||
|
||||
err = mxRoomInvite(mx_room_id, a.MatrixUser)
|
||||
if err != nil {
|
||||
log.Printf("Could not invite %s to %s", a.MatrixUser, mx_room_id)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) Left(roomId RoomID) {
|
||||
delete(a.JoinedRooms, roomId)
|
||||
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, roomId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Joined %s (%s)\n", roomId, a.MatrixUser)
|
||||
|
||||
err = mxRoomKick(mx_room_id, a.MatrixUser, fmt.Sprintf("got leave room event on %s", a.Protocol))
|
||||
if err != nil {
|
||||
log.Printf("Could not invite %s to %s", a.MatrixUser, mx_room_id)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) UserInfoUpdated(user UserID, info *UserInfo) {
|
||||
mx_user_id, err := dbGetMxUser(a.Protocol, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if info.DisplayName != "" {
|
||||
mxProfileDisplayname(mx_user_id, fmt.Sprintf("%s (%s)", info.DisplayName, a.Protocol))
|
||||
}
|
||||
if info.Avatar != nil {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) RoomInfoUpdated(roomId RoomID, author UserID, info *RoomInfo) {
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, roomId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
as_mxid := ezbrMxId()
|
||||
if len(author) > 0 {
|
||||
mx_user_id, err := dbGetMxUser(a.Protocol, author)
|
||||
if err == nil {
|
||||
as_mxid = mx_user_id
|
||||
}
|
||||
}
|
||||
|
||||
if info.Topic != "" {
|
||||
mxRoomTopicAs(mx_room_id, info.Topic, as_mxid)
|
||||
}
|
||||
|
||||
if info.Name != "" {
|
||||
mxRoomNameAs(mx_room_id, info.Name, as_mxid)
|
||||
}
|
||||
|
||||
if info.Picture != nil {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) Event(event *Event) {
|
||||
mx_user_id, err := dbGetMxUser(a.Protocol, event.Author)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if event.Type == EVENT_JOIN {
|
||||
log.Printf("%s join %s %s", a.Protocol, event.Author, event.Room)
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, event.Room)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = mxRoomInvite(mx_room_id, mx_user_id)
|
||||
if err != nil {
|
||||
log.Printf("Could not invite %s to %s", a.MatrixUser, mx_room_id)
|
||||
}
|
||||
|
||||
err = mxRoomJoinAs(mx_room_id, mx_user_id)
|
||||
if err != nil {
|
||||
log.Printf("Could not join %s as %s", a.MatrixUser, mx_room_id)
|
||||
}
|
||||
} else if event.Type == EVENT_LEAVE {
|
||||
log.Printf("%s join %s %s", a.Protocol, event.Author, event.Room)
|
||||
mx_room_id, err := dbGetMxRoom(a.Protocol, event.Room)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = mxRoomLeaveAs(mx_room_id, mx_user_id)
|
||||
if err != nil {
|
||||
log.Printf("Could not leave %s as %s", a.MatrixUser, mx_room_id)
|
||||
}
|
||||
} else {
|
||||
log.Printf("%s msg %s %s", a.Protocol, event.Author, event.Room)
|
||||
mx_room_id := ""
|
||||
|
||||
if len(event.Room) > 0 {
|
||||
mx_room_id, err = dbGetMxRoom(a.Protocol, event.Room)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
mx_room_id, err = dbGetMxPmRoom(a.Protocol, event.Author, mx_user_id, a.MatrixUser, a.AccountName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
typ := "m.text"
|
||||
if event.Type == EVENT_ACTION {
|
||||
typ = "m.emote"
|
||||
}
|
||||
|
||||
err = mxSendMessageAs(mx_room_id, typ, event.Text, mx_user_id)
|
||||
if err != nil {
|
||||
log.Printf("Could not send %s as %s", event.Text, mx_user_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
202
appservice/db.go
Normal file
202
appservice/db.go
Normal file
|
@ -0,0 +1,202 @@
|
|||
package appservice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/jinzhu/gorm"
|
||||
_ "github.com/jinzhu/gorm/dialects/mysql"
|
||||
_ "github.com/jinzhu/gorm/dialects/postgres"
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
func InitDb() error {
|
||||
var err error
|
||||
|
||||
db, err = gorm.Open(config.DbType, config.DbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db.AutoMigrate(&DbUserMap{})
|
||||
db.Model(&DbUserMap{}).AddIndex("idx_protocol_user", "protocol", "user_id")
|
||||
|
||||
db.AutoMigrate(&DbRoomMap{})
|
||||
db.Model(&DbRoomMap{}).AddIndex("idx_protocol_room", "protocol", "room_id")
|
||||
|
||||
db.AutoMigrate(&DbPmRoomMap{})
|
||||
db.Model(&DbPmRoomMap{}).AddIndex("idx_protocol_user_account_user", "protocol", "user_id", "mx_user_id", "account_name")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// User mapping between protocol user IDs and puppeted matrix ids
|
||||
type DbUserMap struct {
|
||||
gorm.Model
|
||||
|
||||
Protocol string
|
||||
UserID connector.UserID
|
||||
MxUserID string `gorm:"index:mxuserid"`
|
||||
}
|
||||
|
||||
// Room mapping between Matrix rooms and outside rooms
|
||||
type DbRoomMap struct {
|
||||
gorm.Model
|
||||
|
||||
// Network protocol
|
||||
Protocol string
|
||||
|
||||
// Room id on the bridged network
|
||||
RoomID connector.RoomID
|
||||
|
||||
// Bridged room matrix id
|
||||
MxRoomID string `gorm:"index:mxroomid"`
|
||||
}
|
||||
|
||||
// Room mapping between Matrix rooms and private messages
|
||||
type DbPmRoomMap struct {
|
||||
gorm.Model
|
||||
|
||||
// User id and account name of the local end viewed on Matrix
|
||||
MxUserID string
|
||||
Protocol string
|
||||
AccountName string
|
||||
|
||||
// User id to reach them
|
||||
UserID connector.UserID
|
||||
|
||||
// Bridged room for PMs
|
||||
MxRoomID string `gorm:"index:mxroomoid"`
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func dbGetMxRoom(protocol string, roomId connector.RoomID) (string, error) {
|
||||
var room DbRoomMap
|
||||
|
||||
// Check if room exists in our mapping,
|
||||
// If not create it
|
||||
must_create := db.First(&room, DbRoomMap{
|
||||
Protocol: protocol,
|
||||
RoomID: roomId,
|
||||
}).RecordNotFound()
|
||||
if must_create {
|
||||
alias := roomAlias(protocol, roomId)
|
||||
// Lookup alias
|
||||
mx_room_id, err := mxDirectoryRoom(fmt.Sprintf("#%s:%s", alias, config.MatrixDomain))
|
||||
|
||||
// If no alias found, create room
|
||||
if err != nil {
|
||||
name := fmt.Sprintf("%s (%s)", roomId, protocol)
|
||||
|
||||
mx_room_id, err = mxCreateRoom(name, alias, []string{})
|
||||
if err != nil {
|
||||
log.Printf("Could not create room for %s: %s", name, err)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
room = DbRoomMap{
|
||||
Protocol: protocol,
|
||||
RoomID: roomId,
|
||||
MxRoomID: mx_room_id,
|
||||
}
|
||||
db.Create(&room)
|
||||
}
|
||||
log.Printf("Got room id: %s", room.MxRoomID)
|
||||
|
||||
return room.MxRoomID, nil
|
||||
}
|
||||
|
||||
func dbGetMxPmRoom(protocol string, them connector.UserID, themMxId string, usMxId string, usAccount string) (string, error) {
|
||||
var room DbPmRoomMap
|
||||
|
||||
must_create := db.First(&room, DbPmRoomMap{
|
||||
MxUserID: usMxId,
|
||||
Protocol: protocol,
|
||||
AccountName: usAccount,
|
||||
UserID: them,
|
||||
}).RecordNotFound()
|
||||
if must_create {
|
||||
name := fmt.Sprintf("%s (%s)", them, protocol)
|
||||
|
||||
mx_room_id, err := mxCreateDirectRoomAs(name, []string{usMxId}, themMxId)
|
||||
if err != nil {
|
||||
log.Printf("Could not create room for %s: %s", name, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = mxRoomJoinAs(mx_room_id, themMxId)
|
||||
if err != nil {
|
||||
log.Printf("Could not join %s as %s", mx_room_id, themMxId)
|
||||
return "", err
|
||||
}
|
||||
|
||||
room = DbPmRoomMap{
|
||||
MxUserID: usMxId,
|
||||
Protocol: protocol,
|
||||
AccountName: usAccount,
|
||||
UserID: them,
|
||||
MxRoomID: mx_room_id,
|
||||
}
|
||||
db.Create(&room)
|
||||
}
|
||||
log.Printf("Got PM room id: %s", room.MxRoomID)
|
||||
|
||||
return room.MxRoomID, nil
|
||||
}
|
||||
|
||||
func dbGetMxUser(protocol string, userId connector.UserID) (string, error) {
|
||||
var user DbUserMap
|
||||
|
||||
must_create := db.First(&user, DbUserMap{
|
||||
Protocol: protocol,
|
||||
UserID: userId,
|
||||
}).RecordNotFound()
|
||||
if must_create {
|
||||
username := userMxId(protocol, userId)
|
||||
|
||||
err := mxRegisterUser(username)
|
||||
if err != nil {
|
||||
if mxE, ok := err.(*mxlib.MxError); !ok || mxE.ErrCode != "M_USER_IN_USE" {
|
||||
log.Printf("Could not register %s: %s", username, err)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
mxid := fmt.Sprintf("@%s:%s", username, config.MatrixDomain)
|
||||
mxProfileDisplayname(mxid, fmt.Sprintf("%s (%s)", userId, protocol))
|
||||
|
||||
user = DbUserMap{
|
||||
Protocol: protocol,
|
||||
UserID: userId,
|
||||
MxUserID: mxid,
|
||||
}
|
||||
db.Create(&user)
|
||||
}
|
||||
|
||||
return user.MxUserID, nil
|
||||
}
|
||||
|
||||
func dbIsPmRoom(mxRoomId string) *DbPmRoomMap {
|
||||
var pm_room DbPmRoomMap
|
||||
if db.First(&pm_room, DbPmRoomMap{MxRoomID: mxRoomId}).RecordNotFound() {
|
||||
return nil
|
||||
} else {
|
||||
return &pm_room
|
||||
}
|
||||
}
|
||||
|
||||
func dbIsPublicRoom(mxRoomId string) *DbRoomMap {
|
||||
var room DbRoomMap
|
||||
if db.First(&room, DbRoomMap{MxRoomID: mxRoomId}).RecordNotFound() {
|
||||
return nil
|
||||
} else {
|
||||
return &room
|
||||
}
|
||||
}
|
255
appservice/matrix.go
Normal file
255
appservice/matrix.go
Normal file
|
@ -0,0 +1,255 @@
|
|||
package appservice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"net/http"
|
||||
"time"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib"
|
||||
)
|
||||
|
||||
func ezbrMxId() string {
|
||||
return fmt.Sprintf("@%s:%s", registration.SenderLocalpart, config.MatrixDomain)
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
var httpClient *http.Client
|
||||
|
||||
func init() {
|
||||
tr := &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: true,
|
||||
}
|
||||
httpClient = &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
func mxGetApiCall(endpoint string, response interface{}) error {
|
||||
log.Debugf("Matrix GET request: %s\n", endpoint)
|
||||
|
||||
req, err := http.NewRequest("GET", config.Server + endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mxDoAndParse(req, response)
|
||||
}
|
||||
|
||||
func mxPutApiCall(endpoint string, data interface{}, response interface{}) error {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("Matrix PUT request: %s %s\n", endpoint, string(body))
|
||||
|
||||
req, err := http.NewRequest("PUT", config.Server + endpoint, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
return mxDoAndParse(req, response)
|
||||
}
|
||||
|
||||
func mxPostApiCall(endpoint string, data interface{}, response interface{}) error {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("Matrix POST request: %s %s\n", endpoint, string(body))
|
||||
|
||||
req, err := http.NewRequest("POST", config.Server + endpoint, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
return mxDoAndParse(req, response)
|
||||
}
|
||||
|
||||
func mxDoAndParse(req *http.Request, response interface{}) error {
|
||||
req.Header.Add("Authorization", "Bearer " + registration.AsToken)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var e MxError
|
||||
err = json.NewDecoder(resp.Body).Decode(&e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debugf("Response (%d): %#v\n", resp.StatusCode, e)
|
||||
return &e
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("Response: %#v\n", response)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func mxRegisterUser(username string) error {
|
||||
req := RegisterRequest{
|
||||
Username: username,
|
||||
}
|
||||
var rep RegisterResponse
|
||||
return mxPostApiCall("/_matrix/client/r0/register?kind=user", &req, &rep)
|
||||
}
|
||||
|
||||
func mxProfileDisplayname(userid string, displayname string) error {
|
||||
req := ProfileDisplaynameRequest{
|
||||
Displayname: displayname,
|
||||
}
|
||||
var rep struct{}
|
||||
err := mxPutApiCall(fmt.Sprintf("/_matrix/client/r0/profile/%s/displayname?user_id=%s",
|
||||
url.QueryEscape(userid), url.QueryEscape(userid)),
|
||||
&req, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func mxDirectoryRoom(alias string) (string, error) {
|
||||
var rep DirectoryRoomResponse
|
||||
err := mxGetApiCall("/_matrix/client/r0/directory/room/" + url.QueryEscape(alias), &rep)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rep.RoomId, nil
|
||||
}
|
||||
|
||||
func mxCreateRoom(name string, alias string, invite []string) (string, error) {
|
||||
rq := CreateRoomRequest{
|
||||
Preset: "private_chat",
|
||||
RoomAliasName: alias,
|
||||
Name: name,
|
||||
Topic: "",
|
||||
Invite: invite,
|
||||
CreationContent: map[string]interface{} {
|
||||
"m.federate": false,
|
||||
},
|
||||
PowerLevels: map[string]interface{} {
|
||||
"invite": 100,
|
||||
"events": map[string]interface{} {
|
||||
"m.room.topic": 0,
|
||||
"m.room.avatar": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
var rep CreateRoomResponse
|
||||
err := mxPostApiCall("/_matrix/client/r0/createRoom", &rq, &rep)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rep.RoomId, nil
|
||||
}
|
||||
|
||||
func mxCreateDirectRoomAs(name string, invite []string, as_user string) (string, error) {
|
||||
rq := CreateRoomNoAliasRequest{
|
||||
Preset: "private_chat",
|
||||
Name: name,
|
||||
Topic: "",
|
||||
Invite: invite,
|
||||
CreationContent: map[string]interface{} {
|
||||
"m.federate": false,
|
||||
},
|
||||
PowerLevels: map[string]interface{} {
|
||||
"invite": 100,
|
||||
},
|
||||
IsDirect: true,
|
||||
}
|
||||
var rep CreateRoomResponse
|
||||
err := mxPostApiCall("/_matrix/client/r0/createRoom?user_id=" + url.QueryEscape(as_user), &rq, &rep)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rep.RoomId, nil
|
||||
}
|
||||
|
||||
func mxRoomInvite(room string, user string) error {
|
||||
rq := RoomInviteRequest{
|
||||
UserId: user,
|
||||
}
|
||||
var rep struct{}
|
||||
err := mxPostApiCall("/_matrix/client/r0/rooms/" + url.QueryEscape(room) + "/invite", &rq, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func mxRoomKick(room string, user string, reason string) error {
|
||||
rq := RoomKickRequest{
|
||||
UserId: user,
|
||||
Reason: reason,
|
||||
}
|
||||
var rep struct{}
|
||||
err := mxPostApiCall("/_matrix/client/r0/rooms/" + url.QueryEscape(room) + "/kick", &rq, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func mxRoomJoinAs(room string, user string) error {
|
||||
rq := struct{}{}
|
||||
var rep RoomJoinResponse
|
||||
err := mxPostApiCall("/_matrix/client/r0/rooms/" + url.QueryEscape(room) + "/join?user_id=" + url.QueryEscape(user), &rq, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func mxRoomLeaveAs(room string, user string) error {
|
||||
rq := struct{}{}
|
||||
var rep struct{}
|
||||
err := mxPostApiCall("/_matrix/client/r0/rooms/" + url.QueryEscape(room) + "/leave?user_id=" + url.QueryEscape(user), &rq, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func mxSendAs(room string, event_type string, content map[string]interface{}, user string) error {
|
||||
txn_id := time.Now().UnixNano()
|
||||
var rep RoomSendResponse
|
||||
err := mxPutApiCall(fmt.Sprintf(
|
||||
"/_matrix/client/r0/rooms/%s/send/%s/%d?user_id=%s",
|
||||
url.QueryEscape(room), event_type, txn_id, url.QueryEscape(user)),
|
||||
&content, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func mxSendMessageAs(room string, typ string, body string, user string) error {
|
||||
content := map[string]interface{} {
|
||||
"msgtype": typ,
|
||||
"body": body,
|
||||
}
|
||||
return mxSendAs(room, "m.room.message", content, user)
|
||||
}
|
||||
|
||||
func mxPutStateAs(room string, event_type string, key string, content map[string]interface{}, as_user string) error {
|
||||
var rep RoomSendResponse
|
||||
err := mxPutApiCall(fmt.Sprintf(
|
||||
"/_matrix/client/r0/rooms/%s/state/%s/%s?user_id=%s",
|
||||
url.QueryEscape(room), event_type, key, url.QueryEscape(as_user)),
|
||||
&content, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func mxRoomNameAs(room string, name string, as_user string) error {
|
||||
content := map[string]interface{} {
|
||||
"name": name,
|
||||
}
|
||||
return mxPutStateAs(room, "m.room.name", "", content, as_user)
|
||||
}
|
||||
|
||||
func mxRoomTopicAs(room string, topic string, as_user string) error {
|
||||
content := map[string]interface{} {
|
||||
"topic": topic,
|
||||
}
|
||||
return mxPutStateAs(room, "m.room.topic", "", content, as_user)
|
||||
}
|
21
appservice/names.go
Normal file
21
appservice/names.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
package appservice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
func roomAlias(protocol string, id RoomID) string {
|
||||
id2 := strings.ReplaceAll(string(id), "#", "")
|
||||
id2 = strings.ReplaceAll(id2, "@", "__")
|
||||
|
||||
return fmt.Sprintf("_ezbr__%s__%s", id2, protocol)
|
||||
}
|
||||
|
||||
func userMxId(protocol string, id UserID) string {
|
||||
id2 := strings.ReplaceAll(string(id), "@", "__")
|
||||
|
||||
return fmt.Sprintf("_ezbr__%s__%s", id2, protocol)
|
||||
}
|
160
appservice/server.go
Normal file
160
appservice/server.go
Normal file
|
@ -0,0 +1,160 @@
|
|||
package appservice
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HttpBindAddr string
|
||||
Server string
|
||||
DbType string
|
||||
DbPath string
|
||||
MatrixDomain string
|
||||
}
|
||||
|
||||
|
||||
var registration *mxlib.Registration
|
||||
var config *Config
|
||||
|
||||
func Start(r *mxlib.Registration, c *Config) (chan error, error) {
|
||||
registration = r
|
||||
config = c
|
||||
|
||||
err := InitDb()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = mxRegisterUser(registration.SenderLocalpart)
|
||||
if mxe, ok := err.(*mxlib.MxError); !ok || mxe.ErrCode != "M_USER_IN_USE" {
|
||||
return nil, err
|
||||
}
|
||||
err = mxProfileDisplayname(ezbrMxId(), "Easybridge")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/_matrix/app/v1/transactions/{txnId}", handleTxn)
|
||||
router.HandleFunc("/transactions/{txnId}", handleTxn)
|
||||
|
||||
errch := make(chan error)
|
||||
go func() {
|
||||
log.Printf("Starting HTTP server on %s", config.HttpBindAddr)
|
||||
err := http.ListenAndServe(config.HttpBindAddr, checkTokenAndLog(router))
|
||||
if err != nil {
|
||||
errch <- err
|
||||
}
|
||||
}()
|
||||
|
||||
return errch, nil
|
||||
}
|
||||
|
||||
func checkTokenAndLog(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
if strings.Join(r.Form["access_token"], "") != registration.HsToken {
|
||||
http.Error(w, "Wrong or no token provided", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL)
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func handleTxn(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PUT" {
|
||||
var txn mxlib.Transaction
|
||||
err := json.NewDecoder(r.Body).Decode(&txn)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("JSON decode error: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Got transaction %#v\n", txn)
|
||||
|
||||
for i := range txn.Events {
|
||||
handleTxnEvent(&txn.Events[i])
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "{}\n")
|
||||
} else {
|
||||
http.Error(w, "Expected PUT request", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func handleTxnEvent(e *mxlib.Event) {
|
||||
if e.Type == "m.room.message" {
|
||||
ev := &connector.Event{
|
||||
Type: connector.EVENT_MESSAGE,
|
||||
Text: e.Content["body"].(string),
|
||||
}
|
||||
typ := e.Content["msgtype"].(string)
|
||||
if typ == "m.emote" {
|
||||
ev.Type = connector.EVENT_MESSAGE
|
||||
}
|
||||
|
||||
// Look up if this is a private message room
|
||||
if pm_room := dbIsPmRoom(e.RoomId); pm_room != nil {
|
||||
acct := FindAccount(pm_room.MxUserID, pm_room.AccountName)
|
||||
if acct != nil && e.Sender == pm_room.MxUserID {
|
||||
ev.Author = acct.Conn.User()
|
||||
ev.Recipient = pm_room.UserID
|
||||
acct.Conn.Send(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// Look up if this is a regular room
|
||||
if room := dbIsPublicRoom(e.RoomId); room != nil {
|
||||
acct := FindJoinedAccount(e.Sender, room.Protocol, room.RoomID)
|
||||
if acct != nil {
|
||||
ev.Author = acct.Conn.User()
|
||||
ev.Room = room.RoomID
|
||||
acct.Conn.Send(ev)
|
||||
} else {
|
||||
log.Debugf("Could not find room account for %s %s %s", e.Sender, room.Protocol, room.RoomID)
|
||||
}
|
||||
}
|
||||
} else if e.Type == "m.room.member" {
|
||||
ms := e.Content["membership"].(string)
|
||||
if ms == "leave" {
|
||||
// If leaving a PM room, we must delete it
|
||||
if pm_room := dbIsPmRoom(e.RoomId); pm_room != nil {
|
||||
them_mx := userMxId(pm_room.Protocol, pm_room.UserID)
|
||||
mxRoomLeaveAs(e.RoomId, them_mx)
|
||||
db.Delete(pm_room)
|
||||
}
|
||||
|
||||
// If leaving a public room, leave from server as well
|
||||
if room := dbIsPublicRoom(e.RoomId); room != nil {
|
||||
acct := FindJoinedAccount(e.Sender, room.Protocol, room.RoomID)
|
||||
if acct != nil {
|
||||
acct.Conn.Leave(room.RoomID)
|
||||
// TODO: manage autojoin list, remove this room
|
||||
} else {
|
||||
log.Debugf("Could not find room account for %s %s %s", e.Sender, room.Protocol, room.RoomID)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if e.Type == "m.room.topic" {
|
||||
if room := dbIsPublicRoom(e.RoomId); room != nil {
|
||||
acct := FindJoinedAccount(e.Sender, room.Protocol, room.RoomID)
|
||||
if acct != nil {
|
||||
acct.Conn.SetRoomInfo(room.RoomID, &connector.RoomInfo{
|
||||
Topic: e.Content["topic"].(string),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -43,29 +43,3 @@ func (c Configuration) GetBool(k string, deflt ...bool) (bool, error) {
|
|||
}
|
||||
return false, fmt.Errorf("Missing configuration key: %s", k)
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
type Protocol struct {
|
||||
NewConnector func() Connector
|
||||
Schema ConfigSchema
|
||||
}
|
||||
|
||||
type ConfigSchema []*ConfigEntry
|
||||
|
||||
type ConfigEntry struct {
|
||||
Name string
|
||||
Description string
|
||||
Default string
|
||||
FixedValue string
|
||||
Required bool
|
||||
IsPassword bool
|
||||
IsNumeric bool
|
||||
IsBoolean bool
|
||||
}
|
||||
|
||||
var Protocols = map[string]Protocol{}
|
||||
|
||||
func Register(name string, protocol Protocol) {
|
||||
Protocols[name] = protocol
|
||||
}
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
/*
|
||||
A generic connector framework for instant messaging protocols.
|
||||
|
||||
|
@ -53,39 +49,19 @@ type Connector interface {
|
|||
Join(roomId RoomID) error
|
||||
|
||||
// Try to invite someone to a channel
|
||||
// Or if roomId == "", just try adding them as friends
|
||||
Invite(user UserID, roomId RoomID) error
|
||||
|
||||
// Leave a channel
|
||||
Leave(roomId RoomID)
|
||||
|
||||
// Search for users
|
||||
SearchForUsers(query string) ([]UserSearchResult, error)
|
||||
|
||||
// Send an event. Returns the ID of the created remote message.
|
||||
// This ID is used to deduplicate messages: if it comes back, it should have the same Id
|
||||
// than the one returned here.
|
||||
// For backends that do not implement IDs (e.g. IRC), an empty string is returned.
|
||||
// (FIXME how to deduplicate IRC messages?)
|
||||
// The event that is fed in this function may have its ID already set,
|
||||
// in which case the backend is free to re-use the ID or select a new one.
|
||||
Send(event *Event) (string, error)
|
||||
|
||||
// Used to send user commands directly
|
||||
// (first use case: receive 2-factor authentication codes)
|
||||
UserCommand(string)
|
||||
// Send an event
|
||||
Send(event *Event) error
|
||||
|
||||
// Close the connection
|
||||
Close()
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
// Called to save updated configuration parameters
|
||||
SaveConfig(config Configuration)
|
||||
|
||||
// Called to notify user of stuff going on
|
||||
SystemMessage(message string)
|
||||
|
||||
// Called when a room was joined (automatically or by call to Connector.Join)
|
||||
Joined(roomId RoomID)
|
||||
|
||||
|
@ -103,17 +79,9 @@ type Handler interface {
|
|||
// Called when an event occurs in a room
|
||||
// This must not be called for events authored by the user of the connection
|
||||
Event(event *Event)
|
||||
|
||||
// These two functions enable the connector to access a simple key/value
|
||||
// database to cache some information in order not to generate useless events.
|
||||
// The connector should function when they are not implemented,
|
||||
// in which case CacheGet always returns ""
|
||||
CachePut(key string, value string)
|
||||
CacheGet(key string) string
|
||||
}
|
||||
|
||||
type EventType int
|
||||
|
||||
const (
|
||||
EVENT_JOIN EventType = iota
|
||||
EVENT_LEAVE
|
||||
|
@ -122,76 +90,47 @@ const (
|
|||
)
|
||||
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
|
||||
// If non-empty, the event Id is used to deduplicate events in a channel
|
||||
// This is usefull for backends that provide a backlog of channel messages
|
||||
// when (re-)joining a room
|
||||
Id string `json:"id"`
|
||||
Type EventType
|
||||
|
||||
// UserID of the user that sent the event
|
||||
// If this is a direct message event, this event can only have been authored
|
||||
// by the user we are talking to (and not by ourself)
|
||||
Author UserID `json:"author"`
|
||||
Author UserID
|
||||
|
||||
// UserID of the targetted user in the case of a direct message,
|
||||
// empty if targetting a room
|
||||
Recipient UserID `json:"recipient"`
|
||||
Recipient UserID
|
||||
|
||||
// RoomID of the room where the event happenned or of the targetted room,
|
||||
// or empty string if it happenned by direct message
|
||||
Room RoomID `json:"room"`
|
||||
Room RoomID
|
||||
|
||||
// Message text or action text
|
||||
Text string `json:"text"`
|
||||
Text string
|
||||
|
||||
// Attached files such as images
|
||||
Attachments []SMediaObject `json:"attachments"`
|
||||
Attachements map[string]MediaObject
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
|
||||
// If non-empty, the Filename of the avatar object will be used by Easybridge
|
||||
// to deduplicate the update events and prevent needless reuploads.
|
||||
// Example strategy that works for the mattermost backend: use the update timestamp as fictious file name
|
||||
Avatar SMediaObject `json:"avatar"`
|
||||
DisplayName string
|
||||
Avatar MediaObject
|
||||
}
|
||||
|
||||
type RoomInfo struct {
|
||||
Name string `json:"name"`
|
||||
Topic string `json:"topic"`
|
||||
|
||||
// Same deduplication comment as for UserInfo.Avatar
|
||||
Picture SMediaObject `json:"picture"`
|
||||
}
|
||||
|
||||
type UserSearchResult struct {
|
||||
ID UserID `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string
|
||||
Topic string
|
||||
Picture MediaObject
|
||||
}
|
||||
|
||||
type MediaObject interface {
|
||||
Filename() string
|
||||
Size() int64
|
||||
Mimetype() string
|
||||
Size() int
|
||||
MimeType() string
|
||||
|
||||
// Returns the size of an image if it is an image, otherwise nil
|
||||
ImageSize() *ImageSize
|
||||
// AsBytes: must always be implemented
|
||||
AsBytes() ([]byte, error)
|
||||
|
||||
// Read: must always be implemented
|
||||
Read() (io.ReadCloser, error)
|
||||
|
||||
// URL(): not mandatory, may return an empty string
|
||||
// If so, Read() is the only way to retrieve the object
|
||||
URL() string
|
||||
}
|
||||
|
||||
type SMediaObject struct {
|
||||
MediaObject
|
||||
}
|
||||
|
||||
type ImageSize struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
// AsString: not mandatory, may return an empty string
|
||||
// If so, AsBytes() is the only way to retrieve the object
|
||||
AsURL() string
|
||||
}
|
||||
|
|
43
connector/external/config.go
vendored
43
connector/external/config.go
vendored
|
@ -1,43 +0,0 @@
|
|||
package external
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
const MESSENGER_PROTOCOL = "Messenger"
|
||||
|
||||
func init() {
|
||||
Register(MESSENGER_PROTOCOL, Protocol{
|
||||
NewConnector: func() Connector {
|
||||
return &External{
|
||||
protocol: MESSENGER_PROTOCOL,
|
||||
command: "./external/messenger.py",
|
||||
debug: (os.Getenv("EASYBRIDGE_MESSENGER_DEBUG") == "true"),
|
||||
}
|
||||
},
|
||||
Schema: ConfigSchema{
|
||||
&ConfigEntry{
|
||||
Name: "email",
|
||||
Description: "Email address",
|
||||
Required: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "password",
|
||||
Description: "Password",
|
||||
IsPassword: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "client_pickle",
|
||||
Description: "Client pickle (alternative login method)",
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "initial_backlog",
|
||||
Description: "Maximum number of messages to load when joining a channel",
|
||||
IsNumeric: true,
|
||||
Default: "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
488
connector/external/external.go
vendored
488
connector/external/external.go
vendored
|
@ -1,488 +0,0 @@
|
|||
package external
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
// Serialization protocol
|
||||
|
||||
type extMessage struct {
|
||||
// Header: message type and identifier
|
||||
MsgType string `json:"_type"`
|
||||
MsgId uint64 `json:"_id"`
|
||||
|
||||
// Message fields
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
EventId string `json:"event_id"`
|
||||
Error string `json:"error"`
|
||||
Room RoomID `json:"room"`
|
||||
User UserID `json:"user"`
|
||||
}
|
||||
|
||||
type extMessageWithData struct {
|
||||
extMessage
|
||||
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// Possible values for MsgType
|
||||
const (
|
||||
// ezbr -> external
|
||||
CONFIGURE = "configure"
|
||||
GET_USER = "get_user"
|
||||
SET_USER_INFO = "set_user_info"
|
||||
SET_ROOM_INFO = "set_room_info"
|
||||
JOIN = "join"
|
||||
INVITE = "invite"
|
||||
LEAVE = "leave"
|
||||
SEARCH = "search"
|
||||
SEND = "send"
|
||||
USER_COMMAND = "user_command"
|
||||
CLOSE = "close"
|
||||
|
||||
// external -> ezbr
|
||||
SAVE_CONFIG = "save_config"
|
||||
SYSTEM_MESSAGE = "system_message"
|
||||
JOINED = "joined"
|
||||
LEFT = "left"
|
||||
USER_INFO_UPDATED = "user_info_updated"
|
||||
ROOM_INFO_UPDATED = "room_info_updated"
|
||||
EVENT = "event"
|
||||
CACHE_PUT = "cache_put"
|
||||
CACHE_GET = "cache_get"
|
||||
|
||||
// reply messages
|
||||
// ezbr -> external: all must wait for a reply!
|
||||
// external -> ezbr: only CACHE_GET produces a reply
|
||||
REP_OK = "rep_ok"
|
||||
REP_SEARCH_RESULTS = "rep_search_results"
|
||||
REP_ERROR = "rep_error"
|
||||
)
|
||||
|
||||
// ----
|
||||
|
||||
type External struct {
|
||||
handler Handler
|
||||
|
||||
protocol string
|
||||
command string
|
||||
debug bool
|
||||
|
||||
config Configuration
|
||||
|
||||
recvPipe io.ReadCloser
|
||||
sendPipe io.WriteCloser
|
||||
sendJson *json.Encoder
|
||||
|
||||
generation int
|
||||
proc *exec.Cmd
|
||||
|
||||
handlerChan chan *extMessageWithData
|
||||
counter uint64
|
||||
inflightRequests map[uint64]chan *extMessageWithData
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func (ext *External) SetHandler(h Handler) {
|
||||
ext.handler = h
|
||||
}
|
||||
|
||||
func (ext *External) Protocol() string {
|
||||
return ext.protocol
|
||||
}
|
||||
|
||||
func (ext *External) Configure(c Configuration) error {
|
||||
var err error
|
||||
|
||||
if ext.proc != nil {
|
||||
ext.Close()
|
||||
}
|
||||
|
||||
ext.inflightRequests = map[uint64]chan *extMessageWithData{}
|
||||
|
||||
ext.generation += 1
|
||||
|
||||
ext.handlerChan = make(chan *extMessageWithData, 1000)
|
||||
go ext.handlerLoop(ext.generation)
|
||||
|
||||
err = ext.setupProc(ext.generation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go ext.restartLoop(ext.generation)
|
||||
|
||||
_, err = ext.cmd(extMessage{
|
||||
MsgType: CONFIGURE,
|
||||
}, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Process management and communication logic
|
||||
|
||||
func (ext *External) setupProc(generation int) error {
|
||||
var err error
|
||||
|
||||
ext.proc = exec.Command(ext.command)
|
||||
|
||||
ext.recvPipe, err = ext.proc.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ext.sendPipe, err = ext.proc.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
send := io.Writer(ext.sendPipe)
|
||||
recv := io.Reader(ext.recvPipe)
|
||||
if ext.debug {
|
||||
recv = io.TeeReader(recv, os.Stderr)
|
||||
send = io.MultiWriter(send, os.Stderr)
|
||||
ext.proc.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
ext.sendJson = json.NewEncoder(send)
|
||||
|
||||
err = ext.proc.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go ext.recvLoop(recv, generation)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ext *External) restartLoop(generation int) {
|
||||
for i := 0; i < 2; i++ {
|
||||
if ext.proc == nil {
|
||||
break
|
||||
}
|
||||
ext.proc.Wait()
|
||||
if ext.generation != generation {
|
||||
break
|
||||
}
|
||||
log.Printf("Process %s stopped, restarting.", ext.command)
|
||||
log.Printf("Generation %d vs %d", ext.generation, generation)
|
||||
err := ext.setupProc(generation)
|
||||
if err != nil {
|
||||
ext.proc = nil
|
||||
log.Warnf("Unable to restart %s: %s", ext.command, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
log.Warnf("More than 3 attempts (%s); abandonning.", ext.command)
|
||||
}
|
||||
|
||||
func (m *extMessageWithData) UnmarshalJSON(jj []byte) error {
|
||||
var c extMessage
|
||||
|
||||
err := json.Unmarshal(jj, &c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*m = extMessageWithData{extMessage: c}
|
||||
switch c.MsgType {
|
||||
case SAVE_CONFIG:
|
||||
var cf struct {
|
||||
Data Configuration `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(jj, &cf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Data = cf.Data
|
||||
return nil
|
||||
case USER_INFO_UPDATED:
|
||||
var ui struct {
|
||||
Data UserInfo `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(jj, &ui)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Data = &ui.Data
|
||||
return nil
|
||||
case ROOM_INFO_UPDATED:
|
||||
var ri struct {
|
||||
Data RoomInfo `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(jj, &ri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Data = &ri.Data
|
||||
return nil
|
||||
case EVENT:
|
||||
var ev struct {
|
||||
Data Event `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(jj, &ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Data = &ev.Data
|
||||
return nil
|
||||
case REP_SEARCH_RESULTS:
|
||||
var sr struct {
|
||||
Data []UserSearchResult `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(jj, &sr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Data = sr.Data
|
||||
return nil
|
||||
case SYSTEM_MESSAGE, JOINED, LEFT, CACHE_PUT, CACHE_GET, REP_OK, REP_ERROR:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("Invalid message type for message from external program: '%s'", c.MsgType)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (ext *External) recvLoop(from io.Reader, generation int) {
|
||||
scanner := bufio.NewScanner(from)
|
||||
for scanner.Scan() {
|
||||
var msg extMessageWithData
|
||||
err := json.Unmarshal(scanner.Bytes(), &msg)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to decode from %s: %s. Skipping line.", ext.command, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if scanner.Err() != nil {
|
||||
log.Warnf("Failed to read from %s: %s. Stopping here.", ext.command, scanner.Err().Error())
|
||||
break
|
||||
}
|
||||
|
||||
log.Tracef("GOT MESSAGE: %#v %#v", msg, msg.Data)
|
||||
if strings.HasPrefix(msg.MsgType, "rep_") {
|
||||
func() {
|
||||
ext.lock.Lock()
|
||||
defer ext.lock.Unlock()
|
||||
if ch, ok := ext.inflightRequests[msg.MsgId]; ok {
|
||||
ch <- &msg
|
||||
delete(ext.inflightRequests, msg.MsgId)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
ext.handlerChan <- &msg
|
||||
}
|
||||
|
||||
if ext.generation != generation {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ext *External) handlerLoop(generation int) {
|
||||
for ext.handlerChan != nil && ext.generation == generation {
|
||||
select {
|
||||
case msg := <-ext.handlerChan:
|
||||
ext.handleCmd(msg)
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ext *External) cmd(msg extMessage, data interface{}) (*extMessageWithData, error) {
|
||||
msg_id := atomic.AddUint64(&ext.counter, 1)
|
||||
|
||||
msg.MsgId = msg_id
|
||||
|
||||
fullMsg := extMessageWithData{
|
||||
extMessage: msg,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
ch := make(chan *extMessageWithData)
|
||||
|
||||
func() {
|
||||
ext.lock.Lock()
|
||||
defer ext.lock.Unlock()
|
||||
ext.inflightRequests[msg_id] = ch
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
ext.lock.Lock()
|
||||
defer ext.lock.Unlock()
|
||||
delete(ext.inflightRequests, msg_id)
|
||||
}()
|
||||
|
||||
err := ext.sendJson.Encode(&fullMsg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case rep := <-ch:
|
||||
if rep.MsgType == REP_ERROR {
|
||||
return nil, fmt.Errorf("%s: %s", msg.MsgType, rep.Error)
|
||||
} else {
|
||||
return rep, nil
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
return nil, fmt.Errorf("(%s) timeout", msg.MsgType)
|
||||
}
|
||||
}
|
||||
|
||||
func (ext *External) Close() {
|
||||
ext.generation += 1
|
||||
|
||||
ext.sendJson.Encode(&extMessage{
|
||||
MsgType: CLOSE,
|
||||
})
|
||||
ext.proc.Process.Signal(os.Interrupt)
|
||||
|
||||
ext.recvPipe.Close()
|
||||
ext.sendPipe.Close()
|
||||
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
if ext.proc != nil {
|
||||
log.Info("Sending SIGKILL to external process (did not terminate within 1 second)")
|
||||
ext.proc.Process.Kill()
|
||||
}
|
||||
}()
|
||||
ext.proc.Wait()
|
||||
log.Info("External process exited")
|
||||
|
||||
ext.proc = nil
|
||||
ext.recvPipe = nil
|
||||
ext.sendPipe = nil
|
||||
ext.sendJson = nil
|
||||
ext.handlerChan = nil
|
||||
}
|
||||
|
||||
// ---- Actual message handling :)
|
||||
|
||||
func (ext *External) handleCmd(msg *extMessageWithData) {
|
||||
switch msg.MsgType {
|
||||
case SAVE_CONFIG:
|
||||
ext.handler.SaveConfig(msg.Data.(Configuration))
|
||||
case SYSTEM_MESSAGE:
|
||||
ext.handler.SystemMessage(msg.Value)
|
||||
case JOINED:
|
||||
ext.handler.Joined(msg.Room)
|
||||
case LEFT:
|
||||
ext.handler.Left(msg.Room)
|
||||
case USER_INFO_UPDATED:
|
||||
ext.handler.UserInfoUpdated(msg.User, msg.Data.(*UserInfo))
|
||||
case ROOM_INFO_UPDATED:
|
||||
ext.handler.RoomInfoUpdated(msg.Room, msg.User, msg.Data.(*RoomInfo))
|
||||
case EVENT:
|
||||
ext.handler.Event(msg.Data.(*Event))
|
||||
case CACHE_PUT:
|
||||
ext.handler.CachePut(msg.Key, msg.Value)
|
||||
case CACHE_GET:
|
||||
value := ext.handler.CacheGet(msg.Key)
|
||||
ext.sendJson.Encode(&extMessage{
|
||||
MsgType: REP_OK,
|
||||
MsgId: msg.MsgId,
|
||||
Key: msg.Key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (ext *External) User() UserID {
|
||||
rep, err := ext.cmd(extMessage{
|
||||
MsgType: GET_USER,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
log.Warnf("Unable to get user! %s", err.Error())
|
||||
return ""
|
||||
}
|
||||
return rep.User
|
||||
}
|
||||
|
||||
func (ext *External) SetUserInfo(info *UserInfo) error {
|
||||
_, err := ext.cmd(extMessage{
|
||||
MsgType: SET_USER_INFO,
|
||||
}, info)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ext *External) SetRoomInfo(room RoomID, info *RoomInfo) error {
|
||||
_, err := ext.cmd(extMessage{
|
||||
MsgType: SET_ROOM_INFO,
|
||||
Room: room,
|
||||
}, info)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ext *External) Join(room RoomID) error {
|
||||
_, err := ext.cmd(extMessage{
|
||||
MsgType: JOIN,
|
||||
Room: room,
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ext *External) Invite(user UserID, room RoomID) error {
|
||||
_, err := ext.cmd(extMessage{
|
||||
MsgType: INVITE,
|
||||
User: user,
|
||||
Room: room,
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ext *External) Leave(room RoomID) {
|
||||
_, err := ext.cmd(extMessage{
|
||||
MsgType: LEAVE,
|
||||
Room: room,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
log.Warnf("Could not leave %s: %s", room, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (ext *External) SearchForUsers(query string) ([]UserSearchResult, error) {
|
||||
rep, err := ext.cmd(extMessage{
|
||||
MsgType: SEARCH,
|
||||
}, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rep.MsgType != REP_SEARCH_RESULTS {
|
||||
return nil, fmt.Errorf("Invalid result type from external: %s", rep.MsgType)
|
||||
}
|
||||
return rep.Data.([]UserSearchResult), nil
|
||||
}
|
||||
|
||||
func (ext *External) Send(event *Event) (string, error) {
|
||||
rep, err := ext.cmd(extMessage{
|
||||
MsgType: SEND,
|
||||
}, event)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rep.EventId, nil
|
||||
}
|
||||
|
||||
func (ext *External) UserCommand(cm string) {
|
||||
ext.cmd(extMessage{
|
||||
MsgType: USER_COMMAND,
|
||||
Value: cm,
|
||||
}, nil)
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
package irc
|
||||
|
||||
import (
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
const IRC_PROTOCOL = "IRC"
|
||||
|
||||
func init() {
|
||||
Register(IRC_PROTOCOL, Protocol{
|
||||
NewConnector: func() Connector { return &IRC{} },
|
||||
Schema: ConfigSchema{
|
||||
&ConfigEntry{
|
||||
Name: "nick",
|
||||
Description: "Nickname",
|
||||
Required: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "server",
|
||||
Description: "Server",
|
||||
Required: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "port",
|
||||
Description: "Port",
|
||||
IsNumeric: true,
|
||||
Default: "6667",
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "ssl",
|
||||
Description: "Use SSL",
|
||||
IsBoolean: true,
|
||||
Default: "false",
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "server_pass",
|
||||
Description: "Server password (authenticate with PASS command)",
|
||||
IsPassword: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "sasl_user",
|
||||
Description: "Username for SASL authentication",
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "sasl_pass",
|
||||
Description: "Password for SASL authentication",
|
||||
IsPassword: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
package irc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
_ "os"
|
||||
"strings"
|
||||
"time"
|
||||
"fmt"
|
||||
|
||||
"github.com/lrstanley/girc"
|
||||
|
||||
|
@ -19,22 +19,20 @@ type IRC struct {
|
|||
handler Handler
|
||||
|
||||
connected bool
|
||||
timeout int
|
||||
timeout int
|
||||
|
||||
nick string
|
||||
name string
|
||||
nick string
|
||||
name string
|
||||
server string
|
||||
conn *girc.Client
|
||||
|
||||
joinedRooms map[string]bool
|
||||
conn *girc.Client
|
||||
}
|
||||
|
||||
func (irc *IRC) SetHandler(h Handler) {
|
||||
irc.handler = h
|
||||
}
|
||||
|
||||
func (irc *IRC) Protocol() string {
|
||||
return IRC_PROTOCOL
|
||||
func(irc *IRC) Protocol() string {
|
||||
return "irc"
|
||||
}
|
||||
|
||||
func (irc *IRC) Configure(c Configuration) error {
|
||||
|
@ -64,25 +62,11 @@ func (irc *IRC) Configure(c Configuration) error {
|
|||
return err
|
||||
}
|
||||
|
||||
server_pass, _ := c.GetString("server_pass", "")
|
||||
sasl_user, _ := c.GetString("sasl_user", "")
|
||||
sasl_pass, _ := c.GetString("sasl_pass", "")
|
||||
|
||||
var sasl girc.SASLMech
|
||||
if sasl_user != "" && sasl_pass != "" {
|
||||
sasl = &girc.SASLPlain{
|
||||
User: sasl_user,
|
||||
Pass: sasl_pass,
|
||||
}
|
||||
}
|
||||
|
||||
client := girc.New(girc.Config{
|
||||
Server: irc.server,
|
||||
ServerPass: server_pass,
|
||||
Port: port,
|
||||
Nick: irc.nick,
|
||||
User: irc.nick,
|
||||
SASL: sasl,
|
||||
Server: irc.server,
|
||||
Port: port,
|
||||
Nick: irc.nick,
|
||||
User: irc.nick,
|
||||
//Out: os.Stderr,
|
||||
SSL: ssl,
|
||||
})
|
||||
|
@ -97,13 +81,11 @@ func (irc *IRC) Configure(c Configuration) error {
|
|||
client.Handlers.Add(girc.TOPIC, irc.ircTopic)
|
||||
client.Handlers.Add(girc.RPL_TOPIC, irc.ircRplTopic)
|
||||
|
||||
irc.joinedRooms = make(map[string]bool)
|
||||
|
||||
irc.conn = client
|
||||
go irc.connectLoop(client)
|
||||
|
||||
for i := 0; i < 42; i++ {
|
||||
time.Sleep(time.Duration(1) * time.Second)
|
||||
time.Sleep(time.Duration(1)*time.Second)
|
||||
if irc.conn != client {
|
||||
break
|
||||
}
|
||||
|
@ -120,10 +102,7 @@ func (irc *IRC) User() UserID {
|
|||
|
||||
func (irc *IRC) checkRoomId(id RoomID) (string, error) {
|
||||
x := strings.Split(string(id), "@")
|
||||
if len(x) == 1 {
|
||||
return "", fmt.Errorf("Please write whole room ID with server: %s@%s", id, irc.server)
|
||||
}
|
||||
if x[0][0] != '#' || len(x) != 2 || x[1] != irc.server {
|
||||
if len(x) != 2 || x[1] != irc.server || x[0][0] != '#' {
|
||||
return "", fmt.Errorf("Invalid room ID: %s", id)
|
||||
}
|
||||
return x[0], nil
|
||||
|
@ -131,10 +110,7 @@ func (irc *IRC) checkRoomId(id RoomID) (string, error) {
|
|||
|
||||
func (irc *IRC) checkUserId(id UserID) (string, error) {
|
||||
x := strings.Split(string(id), "@")
|
||||
if len(x) == 1 {
|
||||
return "", fmt.Errorf("Please write whole user ID with server: %s@%s", id, irc.server)
|
||||
}
|
||||
if x[0][0] == '#' || len(x) != 2 || x[1] != irc.server {
|
||||
if len(x) != 2 || x[1] != irc.server || x[0][0] == '#' {
|
||||
return "", fmt.Errorf("Invalid user ID: %s", id)
|
||||
}
|
||||
return x[0], nil
|
||||
|
@ -145,32 +121,24 @@ func (irc *IRC) SetUserInfo(info *UserInfo) error {
|
|||
}
|
||||
|
||||
func (irc *IRC) SetRoomInfo(roomId RoomID, info *RoomInfo) error {
|
||||
if irc.conn == nil {
|
||||
return fmt.Errorf("Not connected")
|
||||
}
|
||||
|
||||
ch, err := irc.checkRoomId(roomId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Topic != "" {
|
||||
irc.conn.Cmd.Topic(ch, info.Topic)
|
||||
}
|
||||
if info.Name != "" && info.Name != ch {
|
||||
return fmt.Errorf("May not change IRC room name to other than %s", ch)
|
||||
}
|
||||
if info.Picture.MediaObject != nil {
|
||||
if info.Picture != nil {
|
||||
return fmt.Errorf("Room picture not supported on IRC")
|
||||
}
|
||||
if info.Topic != "" {
|
||||
irc.conn.Cmd.Topic(ch, info.Topic)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (irc *IRC) Join(roomId RoomID) error {
|
||||
if irc.conn == nil {
|
||||
return fmt.Errorf("Not connected")
|
||||
}
|
||||
|
||||
ch, err := irc.checkRoomId(roomId)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -181,20 +149,12 @@ func (irc *IRC) Join(roomId RoomID) error {
|
|||
}
|
||||
|
||||
func (irc *IRC) Invite(userId UserID, roomId RoomID) error {
|
||||
if irc.conn == nil {
|
||||
return fmt.Errorf("Not connected")
|
||||
}
|
||||
|
||||
who, err := irc.checkUserId(userId)
|
||||
ch, err := irc.checkRoomId(roomId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if roomId == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ch, err := irc.checkRoomId(roomId)
|
||||
who, err := irc.checkUserId(userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -204,10 +164,6 @@ func (irc *IRC) Invite(userId UserID, roomId RoomID) error {
|
|||
}
|
||||
|
||||
func (irc *IRC) Leave(roomId RoomID) {
|
||||
if irc.conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := irc.checkRoomId(roomId)
|
||||
if err != nil {
|
||||
return
|
||||
|
@ -216,16 +172,7 @@ func (irc *IRC) Leave(roomId RoomID) {
|
|||
irc.conn.Cmd.Part(ch)
|
||||
}
|
||||
|
||||
func (irc *IRC) SearchForUsers(query string) ([]UserSearchResult, error) {
|
||||
// TODO
|
||||
return nil, fmt.Errorf("Not implemented")
|
||||
}
|
||||
|
||||
func (irc *IRC) Send(event *Event) (string, error) {
|
||||
if irc.conn == nil {
|
||||
return "", fmt.Errorf("Not connected")
|
||||
}
|
||||
|
||||
func (irc *IRC) Send(event *Event) error {
|
||||
// Workaround girc bug
|
||||
if event.Text[0] == ':' {
|
||||
event.Text = " " + event.Text
|
||||
|
@ -235,30 +182,22 @@ func (irc *IRC) Send(event *Event) (string, error) {
|
|||
if event.Room != "" {
|
||||
ch, err := irc.checkRoomId(event.Room)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
dest = ch
|
||||
} else if event.Recipient != "" {
|
||||
ui, err := irc.checkUserId(event.Recipient)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
dest = ui
|
||||
} else {
|
||||
return "", fmt.Errorf("Invalid target")
|
||||
return fmt.Errorf("Invalid target")
|
||||
}
|
||||
|
||||
if event.Attachments != nil && len(event.Attachments) > 0 {
|
||||
for _, at := range event.Attachments {
|
||||
url := at.URL()
|
||||
if url == "" {
|
||||
// TODO find a way to send them using some hosing of some kind
|
||||
return "", fmt.Errorf("Attachment without URL sent to IRC")
|
||||
} else {
|
||||
irc.conn.Cmd.Message(dest, fmt.Sprintf("%s (%s, %dkb)",
|
||||
url, at.Mimetype(), at.Size()/1024))
|
||||
}
|
||||
}
|
||||
if event.Attachements != nil && len(event.Attachements) > 0 {
|
||||
// TODO find a way to send them using some hosing of some kind
|
||||
return fmt.Errorf("Attachements not supported on IRC")
|
||||
}
|
||||
|
||||
if event.Type == EVENT_MESSAGE {
|
||||
|
@ -266,22 +205,15 @@ func (irc *IRC) Send(event *Event) (string, error) {
|
|||
} else if event.Type == EVENT_ACTION {
|
||||
irc.conn.Cmd.Action(dest, event.Text)
|
||||
} else {
|
||||
return "", fmt.Errorf("Invalid event type")
|
||||
return fmt.Errorf("Invalid event type")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (irc *IRC) UserCommand(cm string) {
|
||||
irc.handler.SystemMessage("Command not supported.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (irc *IRC) Close() {
|
||||
conn := irc.conn
|
||||
irc.conn = nil
|
||||
irc.connected = false
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func (irc *IRC) connectLoop(c *girc.Client) {
|
||||
|
@ -292,7 +224,8 @@ func (irc *IRC) connectLoop(c *girc.Client) {
|
|||
}
|
||||
if err := c.Connect(); err != nil {
|
||||
irc.connected = false
|
||||
irc.handler.SystemMessage(fmt.Sprintf("IRC failed to connect / disconnected (%s), reconnecting in %ds", err, irc.timeout))
|
||||
fmt.Printf("IRC failed to connect / disconnected: %s\n", err)
|
||||
fmt.Printf("Retrying in %ds\n", irc.timeout)
|
||||
time.Sleep(time.Duration(irc.timeout) * time.Second)
|
||||
irc.timeout *= 2
|
||||
if irc.timeout > 600 {
|
||||
|
@ -305,22 +238,16 @@ func (irc *IRC) connectLoop(c *girc.Client) {
|
|||
}
|
||||
|
||||
func (irc *IRC) ircConnected(c *girc.Client, e girc.Event) {
|
||||
irc.handler.SystemMessage("Connected to IRC.")
|
||||
fmt.Printf("ircConnected ^^^^\n")
|
||||
irc.timeout = 10
|
||||
irc.connected = true
|
||||
|
||||
for room, joined := range irc.joinedRooms {
|
||||
if joined {
|
||||
irc.conn.Cmd.Join(room)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (irc *IRC) ircPrivmsg(c *girc.Client, e girc.Event) {
|
||||
ev := &Event{
|
||||
Type: EVENT_MESSAGE,
|
||||
Type: EVENT_MESSAGE,
|
||||
Author: UserID(e.Source.Name + "@" + irc.server),
|
||||
Text: e.Last(),
|
||||
Text: e.Last(),
|
||||
}
|
||||
if e.IsFromChannel() {
|
||||
ev.Room = RoomID(e.Params[0] + "@" + irc.server)
|
||||
|
@ -335,13 +262,12 @@ func (irc *IRC) ircJoin(c *girc.Client, e girc.Event) {
|
|||
room := RoomID(e.Params[0] + "@" + irc.server)
|
||||
if e.Source.Name == irc.nick {
|
||||
irc.handler.Joined(room)
|
||||
irc.joinedRooms[e.Params[0]] = true
|
||||
} else {
|
||||
user := UserID(e.Source.Name + "@" + irc.server)
|
||||
ev := &Event{
|
||||
Type: EVENT_JOIN,
|
||||
Type: EVENT_JOIN,
|
||||
Author: user,
|
||||
Room: room,
|
||||
Room: room,
|
||||
}
|
||||
irc.handler.Event(ev)
|
||||
irc.handler.UserInfoUpdated(user, &UserInfo{
|
||||
|
@ -354,13 +280,12 @@ func (irc *IRC) ircPart(c *girc.Client, e girc.Event) {
|
|||
room := RoomID(e.Params[0] + "@" + irc.server)
|
||||
if e.Source.Name == irc.nick {
|
||||
irc.handler.Left(room)
|
||||
delete(irc.joinedRooms, e.Params[0])
|
||||
} else {
|
||||
user := UserID(e.Source.Name + "@" + irc.server)
|
||||
ev := &Event{
|
||||
Type: EVENT_LEAVE,
|
||||
Type: EVENT_LEAVE,
|
||||
Author: user,
|
||||
Room: room,
|
||||
Room: room,
|
||||
}
|
||||
irc.handler.Event(ev)
|
||||
irc.handler.UserInfoUpdated(user, &UserInfo{
|
||||
|
@ -379,9 +304,9 @@ func (irc *IRC) ircNamreply(c *girc.Client, e girc.Event) {
|
|||
src := girc.ParseSource(name)
|
||||
if src.Name != irc.nick {
|
||||
irc.handler.Event(&Event{
|
||||
Type: EVENT_JOIN,
|
||||
Type: EVENT_JOIN,
|
||||
Author: UserID(src.Name + "@" + irc.server),
|
||||
Room: room,
|
||||
Room: room,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,139 +0,0 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
S_EVENT_JOIN = "join"
|
||||
S_EVENT_LEAVE = "leave"
|
||||
S_EVENT_MESSAGE = "message"
|
||||
S_EVENT_ACTION = "action"
|
||||
)
|
||||
|
||||
func (t EventType) MarshalText() ([]byte, error) {
|
||||
switch t {
|
||||
case EVENT_JOIN:
|
||||
return []byte(S_EVENT_JOIN), nil
|
||||
case EVENT_LEAVE:
|
||||
return []byte(S_EVENT_LEAVE), nil
|
||||
case EVENT_MESSAGE:
|
||||
return []byte(S_EVENT_MESSAGE), nil
|
||||
case EVENT_ACTION:
|
||||
return []byte(S_EVENT_ACTION), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("Invalid event type: %d", t)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *EventType) UnmarshalText(text []byte) error {
|
||||
switch string(text) {
|
||||
case S_EVENT_JOIN:
|
||||
*t = EVENT_JOIN
|
||||
return nil
|
||||
case S_EVENT_LEAVE:
|
||||
*t = EVENT_LEAVE
|
||||
return nil
|
||||
case S_EVENT_MESSAGE:
|
||||
*t = EVENT_MESSAGE
|
||||
return nil
|
||||
case S_EVENT_ACTION:
|
||||
*t = EVENT_ACTION
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("Invalid event type: %s", string(text))
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
type MediaObjectJSON struct {
|
||||
Filename string `json:"filename"`
|
||||
Mimetype string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
ImageSize *ImageSize `json:"image_size"`
|
||||
Data string `json:"data"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (mo SMediaObject) MarshalJSON() ([]byte, error) {
|
||||
if mo.MediaObject == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
|
||||
mod := MediaObjectJSON{
|
||||
Filename: mo.Filename(),
|
||||
Mimetype: mo.Mimetype(),
|
||||
Size: mo.Size(),
|
||||
ImageSize: mo.ImageSize(),
|
||||
URL: mo.URL(),
|
||||
}
|
||||
|
||||
if mod.URL == "" {
|
||||
// If we don't have a URL, the only way is to pass the blob itself
|
||||
rd, err := mo.Read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rd.Close()
|
||||
buf := bytes.NewBuffer([]byte{})
|
||||
_, err = io.Copy(buf, rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mod.Data = base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
|
||||
return json.Marshal(&mod)
|
||||
}
|
||||
|
||||
func (mo *SMediaObject) UnmarshalJSON(jdata []byte) error {
|
||||
if string(jdata) == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var d MediaObjectJSON
|
||||
err := json.Unmarshal(jdata, &d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.URL != "" {
|
||||
*mo = SMediaObject{&LazyBlobMediaObject{
|
||||
ObjectFilename: d.Filename,
|
||||
ObjectMimetype: d.Mimetype,
|
||||
ObjectImageSize: d.ImageSize,
|
||||
GetFn: func(o *LazyBlobMediaObject) error {
|
||||
resp, err := http.Get(d.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o.ObjectMimetype == "" {
|
||||
o.ObjectMimetype = strings.Join(resp.Header["Content-Type"], "")
|
||||
}
|
||||
o.ObjectData, err = ioutil.ReadAll(resp.Body)
|
||||
return err
|
||||
},
|
||||
}}
|
||||
return nil
|
||||
}
|
||||
|
||||
bytes, err := base64.StdEncoding.DecodeString(d.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*mo = SMediaObject{&BlobMediaObject{
|
||||
ObjectFilename: d.Filename,
|
||||
ObjectMimetype: d.Mimetype,
|
||||
ObjectImageSize: d.ImageSize,
|
||||
ObjectData: bytes,
|
||||
}}
|
||||
return nil
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
package mattermost
|
||||
|
||||
import (
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
const MATTERMOST_PROTOCOL = "Mattermost"
|
||||
|
||||
func init() {
|
||||
Register(MATTERMOST_PROTOCOL, Protocol{
|
||||
NewConnector: func() Connector { return &Mattermost{} },
|
||||
Schema: ConfigSchema{
|
||||
&ConfigEntry{
|
||||
Name: "server",
|
||||
Description: "Server",
|
||||
Required: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "username",
|
||||
Description: "Username",
|
||||
Required: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "password",
|
||||
Description: "Password",
|
||||
IsPassword: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "token",
|
||||
Description: "Authentification token (replaces password if set)",
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "teams",
|
||||
Description: "Comma-separated list of teams to follow",
|
||||
Required: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "no_tls",
|
||||
Description: "Disable SSL/TLS",
|
||||
IsBoolean: true,
|
||||
Default: "false",
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "initial_backlog",
|
||||
Description: "Maximum number of messages to load when joining a channel",
|
||||
IsNumeric: true,
|
||||
Default: "1000",
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "initial_members",
|
||||
Description: "Maximum number of members to load when joining a channel",
|
||||
IsNumeric: true,
|
||||
Default: "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
|
@ -1,676 +0,0 @@
|
|||
package mattermost
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
_ "os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/matterclient"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
// User id format: nickname@server
|
||||
// Room id format: room_name@team@server
|
||||
|
||||
type Mattermost struct {
|
||||
handler Handler
|
||||
|
||||
server string
|
||||
username string
|
||||
teams map[string]bool
|
||||
|
||||
initial_members int // How many room members (maximum) to load when first joining a channel
|
||||
initial_backlog int // How many previous messages (maximum) to load when first joining a channel
|
||||
|
||||
conn *matterclient.MMClient
|
||||
handlerStopChan chan struct{}
|
||||
|
||||
caches mmCaches
|
||||
}
|
||||
|
||||
type mmCaches struct {
|
||||
sync.Mutex
|
||||
|
||||
mmusers map[string]string // map mm username to mm user id
|
||||
sentjoined map[string]bool // map username/room name to bool
|
||||
displayname map[UserID]string // map username to last displayname
|
||||
initsynced map[string]bool // chans for which init sync has been done
|
||||
}
|
||||
|
||||
func (mm *Mattermost) SetHandler(h Handler) {
|
||||
mm.handler = h
|
||||
}
|
||||
|
||||
func (mm *Mattermost) Protocol() string {
|
||||
return MATTERMOST_PROTOCOL
|
||||
}
|
||||
|
||||
func (mm *Mattermost) Configure(c Configuration) error {
|
||||
if mm.conn != nil {
|
||||
mm.Close()
|
||||
}
|
||||
|
||||
// Reinitialize shared data structures
|
||||
mm.handlerStopChan = make(chan struct{})
|
||||
|
||||
mm.caches.mmusers = make(map[string]string)
|
||||
mm.caches.sentjoined = make(map[string]bool)
|
||||
mm.caches.displayname = make(map[UserID]string)
|
||||
mm.caches.initsynced = make(map[string]bool)
|
||||
|
||||
// Read config
|
||||
var err error
|
||||
|
||||
mm.server, err = c.GetString("server")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mm.username, err = c.GetString("username")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mm.initial_members, err = c.GetInt("initial_members", 100)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mm.initial_backlog, err = c.GetInt("initial_backlog", 1000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
teams, err := c.GetString("teams")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mm.teams = map[string]bool{}
|
||||
anyteam := ""
|
||||
for _, team := range strings.Split(teams, ",") {
|
||||
anyteam = strings.TrimSpace(team)
|
||||
mm.teams[anyteam] = true
|
||||
}
|
||||
|
||||
notls, err := c.GetBool("no_tls", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password, _ := c.GetString("password", "")
|
||||
token, _ := c.GetString("token", "")
|
||||
if token != "" {
|
||||
password = "token=" + token
|
||||
}
|
||||
|
||||
// Try to log in
|
||||
mm.conn = matterclient.New(mm.username, password, anyteam, mm.server)
|
||||
mm.conn.Credentials.NoTLS = notls
|
||||
err = mm.conn.Login()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try to start listening for messages
|
||||
// Everytime the listener reconnects, mm.handleConnected does a sync of room status
|
||||
mm.conn.OnWsConnect = mm.handleConnected
|
||||
go mm.conn.WsReceiver()
|
||||
go mm.conn.StatusLoop()
|
||||
go mm.handleLoop(mm.conn.MessageChan, mm.handlerStopChan)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mm *Mattermost) User() UserID {
|
||||
return UserID(mm.username + "@" + mm.server)
|
||||
}
|
||||
|
||||
func (mm *Mattermost) getTeamIdByName(name string) string {
|
||||
for _, team := range mm.conn.OtherTeams {
|
||||
if team.Team.Name == name {
|
||||
return team.Id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (mm *Mattermost) checkRoomId(id RoomID) (string, error) {
|
||||
x := strings.Split(string(id), "@")
|
||||
if len(x) == 1 {
|
||||
return "", fmt.Errorf("Please write whole room ID with team and server: %s@<team>@%s", id, mm.server)
|
||||
}
|
||||
if len(x) == 2 {
|
||||
return x[0], nil
|
||||
}
|
||||
if len(x) != 3 || x[2] != mm.server {
|
||||
return "", fmt.Errorf("Invalid room ID: %s", id)
|
||||
}
|
||||
|
||||
team_id := mm.getTeamIdByName(x[1])
|
||||
if team_id == "" {
|
||||
return "", fmt.Errorf("Team not found: %s", id)
|
||||
}
|
||||
|
||||
ch_id := mm.conn.GetChannelId(x[0], team_id)
|
||||
if ch_id == "" {
|
||||
return "", fmt.Errorf("Channel not found: %s", id)
|
||||
}
|
||||
return ch_id, nil
|
||||
}
|
||||
|
||||
func (mm *Mattermost) reverseRoomId(id string) (bool, RoomID) {
|
||||
team := mm.conn.GetChannelTeamId(id)
|
||||
if team == "" {
|
||||
return true, RoomID(fmt.Sprintf("%s@%s", id, mm.server))
|
||||
} else {
|
||||
teamName := mm.conn.GetTeamName(team)
|
||||
if u, ok := mm.teams[teamName]; ok && u {
|
||||
name := mm.conn.GetChannelName(id)
|
||||
return true, RoomID(fmt.Sprintf("%s@%s@%s", name, teamName, mm.server))
|
||||
} else {
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *Mattermost) checkUserId(id UserID) (string, error) {
|
||||
x := strings.Split(string(id), "@")
|
||||
if len(x) == 1 {
|
||||
return "", fmt.Errorf("Please write whole user ID with server: %s@%s", id, mm.server)
|
||||
}
|
||||
if len(x) != 2 || x[1] != mm.server {
|
||||
return "", fmt.Errorf("Invalid user ID: %s", id)
|
||||
}
|
||||
|
||||
mm.caches.Lock()
|
||||
defer mm.caches.Unlock()
|
||||
|
||||
if user_id, ok := mm.caches.mmusers[x[0]]; ok {
|
||||
return user_id, nil
|
||||
}
|
||||
|
||||
u, resp := mm.conn.Client.GetUserByUsername(x[0], "")
|
||||
if u == nil || resp.Error != nil {
|
||||
return "", fmt.Errorf("Not found: %s (%s)", x[0], resp.Error)
|
||||
}
|
||||
mm.caches.mmusers[x[0]] = u.Id
|
||||
|
||||
return u.Id, nil
|
||||
}
|
||||
|
||||
func (mm *Mattermost) SetUserInfo(info *UserInfo) error {
|
||||
return fmt.Errorf("Not implemented")
|
||||
}
|
||||
|
||||
func (mm *Mattermost) SetRoomInfo(roomId RoomID, info *RoomInfo) error {
|
||||
ch, err := mm.checkRoomId(roomId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Topic != "" {
|
||||
mm.conn.UpdateChannelHeader(ch, info.Topic)
|
||||
}
|
||||
|
||||
if info.Picture.MediaObject != nil {
|
||||
err = fmt.Errorf("Not supported: channel picture on mattermost")
|
||||
}
|
||||
|
||||
if info.Name != "" {
|
||||
err = fmt.Errorf("Not supported: channel name on mattermost")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (mm *Mattermost) Join(roomId RoomID) error {
|
||||
ch, err := mm.checkRoomId(roomId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mm.conn.JoinChannel(ch)
|
||||
}
|
||||
|
||||
func (mm *Mattermost) Invite(userId UserID, roomId RoomID) error {
|
||||
if roomId == "" {
|
||||
_, err := mm.checkUserId(userId)
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("Not supported: invite on mattermost")
|
||||
}
|
||||
|
||||
func (mm *Mattermost) Leave(roomId RoomID) {
|
||||
// Not supported? TODO
|
||||
}
|
||||
|
||||
func (mm *Mattermost) SearchForUsers(query string) ([]UserSearchResult, error) {
|
||||
query = strings.ToLower(query)
|
||||
ret := []UserSearchResult{}
|
||||
for _, user := range mm.conn.Users {
|
||||
if strings.Contains(strings.ToLower(user.Username), query) ||
|
||||
strings.Contains(strings.ToLower(user.Nickname), query) ||
|
||||
strings.Contains(strings.ToLower(user.GetDisplayName(model.SHOW_NICKNAME_FULLNAME)), query) {
|
||||
ret = append(ret, UserSearchResult{
|
||||
ID: UserID(fmt.Sprintf("%s@%s", user.Username, mm.server)),
|
||||
DisplayName: user.GetDisplayName(model.SHOW_NICKNAME_FULLNAME),
|
||||
})
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (mm *Mattermost) Send(event *Event) (string, error) {
|
||||
post := &model.Post{
|
||||
Message: event.Text,
|
||||
}
|
||||
if event.Type == EVENT_ACTION {
|
||||
post.Type = "me"
|
||||
}
|
||||
|
||||
if event.Room != "" {
|
||||
ch, err := mm.checkRoomId(event.Room)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
post.ChannelId = ch
|
||||
} else if event.Recipient != "" {
|
||||
ui, err := mm.checkUserId(event.Recipient)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, resp := mm.conn.Client.CreateDirectChannel(mm.conn.User.Id, ui)
|
||||
if resp.Error != nil {
|
||||
return "", resp.Error
|
||||
}
|
||||
channelName := model.GetDMNameFromIds(ui, mm.conn.User.Id)
|
||||
|
||||
err = mm.conn.UpdateChannels()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
post.ChannelId = mm.conn.GetChannelId(channelName, "")
|
||||
} else {
|
||||
return "", fmt.Errorf("Invalid target")
|
||||
}
|
||||
|
||||
if event.Attachments != nil {
|
||||
post.FileIds = []string{}
|
||||
for _, file := range event.Attachments {
|
||||
rdr, err := file.Read()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rdr.Close()
|
||||
data, err := ioutil.ReadAll(rdr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
up_file, err := mm.conn.UploadFile(data, post.ChannelId, file.Filename())
|
||||
if err != nil {
|
||||
log.Warnf("UploadFile error: %s", err)
|
||||
return "", err
|
||||
}
|
||||
post.FileIds = append(post.FileIds, up_file)
|
||||
}
|
||||
}
|
||||
|
||||
created_post, resp := mm.conn.Client.CreatePost(post)
|
||||
if resp.Error != nil {
|
||||
log.Warnf("CreatePost error: %s", resp.Error)
|
||||
return "", resp.Error
|
||||
}
|
||||
return created_post.Id, nil
|
||||
}
|
||||
|
||||
func (mm *Mattermost) UserCommand(cm string) {
|
||||
mm.handler.SystemMessage("Command not supported.")
|
||||
}
|
||||
|
||||
func (mm *Mattermost) Close() {
|
||||
if mm.conn != nil {
|
||||
mm.conn.WsQuit = true
|
||||
}
|
||||
if mm.handlerStopChan != nil {
|
||||
close(mm.handlerStopChan)
|
||||
mm.handlerStopChan = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *Mattermost) handleConnected() {
|
||||
mm.handler.SystemMessage(fmt.Sprintf("(Re-)connected to mattermost (%s@%s), doing channel sync", mm.username, mm.server))
|
||||
|
||||
// Initial channel sync
|
||||
chans := mm.conn.GetChannels()
|
||||
doneCh := make(map[string]bool)
|
||||
for _, ch := range chans {
|
||||
if _, ok := doneCh[ch.Id]; !ok {
|
||||
doneCh[ch.Id] = true
|
||||
go mm.syncChannel(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *Mattermost) syncChannel(ch *model.Channel) {
|
||||
// The first time we see a chan, sync everything (member list, names, profile pictures)
|
||||
must_initsync := func() bool {
|
||||
mm.caches.Lock()
|
||||
defer mm.caches.Unlock()
|
||||
|
||||
if x, ok := mm.caches.initsynced[ch.Id]; ok && x {
|
||||
return false
|
||||
}
|
||||
mm.caches.initsynced[ch.Id] = true
|
||||
return true
|
||||
}()
|
||||
|
||||
if must_initsync {
|
||||
mm.initSyncChannel(ch)
|
||||
}
|
||||
|
||||
// The following times, only sync missing messages
|
||||
mm.backlogChannel(ch)
|
||||
}
|
||||
|
||||
func (mm *Mattermost) initSyncChannel(ch *model.Channel) {
|
||||
if len(strings.Split(ch.Name, "__")) == 2 {
|
||||
// DM channel
|
||||
// Update remote user info
|
||||
users := strings.Split(ch.Name, "__")
|
||||
for _, uid := range users {
|
||||
if uid != mm.conn.User.Id {
|
||||
user := mm.conn.GetUser(uid)
|
||||
if user != nil {
|
||||
mm.updateUserInfo(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
interested, id := mm.reverseRoomId(ch.Id)
|
||||
if !interested {
|
||||
// Skip channels that are not in teams we want to bridge
|
||||
return
|
||||
}
|
||||
mm.handler.Joined(id)
|
||||
|
||||
// Update room info
|
||||
room_info := &RoomInfo{
|
||||
Name: ch.DisplayName,
|
||||
Topic: ch.Header,
|
||||
}
|
||||
for _, t := range mm.conn.OtherTeams {
|
||||
if t.Id == ch.TeamId {
|
||||
if t.Team.DisplayName != "" {
|
||||
room_info.Name = t.Team.DisplayName + " / " + room_info.Name
|
||||
} else {
|
||||
room_info.Name = t.Team.Name + " / " + room_info.Name
|
||||
}
|
||||
if t.Team.LastTeamIconUpdate > 0 {
|
||||
room_info.Picture = SMediaObject{&LazyBlobMediaObject{
|
||||
ObjectFilename: fmt.Sprintf("%s-%d",
|
||||
t.Team.Name,
|
||||
t.Team.LastTeamIconUpdate),
|
||||
GetFn: func(o *LazyBlobMediaObject) error {
|
||||
team_img, resp := mm.conn.Client.GetTeamIcon(t.Id, "")
|
||||
if resp.Error != nil {
|
||||
log.Warnf("Could not get team image: %s", resp.Error.Error())
|
||||
return resp.Error
|
||||
}
|
||||
o.ObjectData = team_img
|
||||
o.ObjectMimetype = http.DetectContentType(team_img)
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
mm.handler.RoomInfoUpdated(id, UserID(""), room_info)
|
||||
|
||||
// Update member list
|
||||
// TODO (when this will be slow, i.e. hundreds of members): do only a diff
|
||||
members, resp := mm.conn.Client.GetChannelMembers(ch.Id, 0, mm.initial_members, "")
|
||||
if resp.Error == nil {
|
||||
for _, mem := range *members {
|
||||
if mem.UserId == mm.conn.User.Id {
|
||||
continue
|
||||
}
|
||||
user := mm.conn.GetUser(mem.UserId)
|
||||
if user != nil {
|
||||
mm.ensureJoined(user, id)
|
||||
mm.updateUserInfo(user)
|
||||
} else {
|
||||
log.Warnf("Could not find joined user: %s", mem.UserId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Warnf("Could not get channel members: %s", resp.Error.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *Mattermost) backlogChannel(ch *model.Channel) {
|
||||
// Read backlog
|
||||
last_seen_post := mm.handler.CacheGet(fmt.Sprintf("last_seen_%s", ch.Id))
|
||||
if last_seen_post != "" {
|
||||
const NUM_PER_PAGE = 100
|
||||
page := 0
|
||||
backlogs := []*model.PostList{}
|
||||
for {
|
||||
backlog, resp := mm.conn.Client.GetPostsAfter(ch.Id, last_seen_post, page, NUM_PER_PAGE, "")
|
||||
if resp.Error == nil {
|
||||
backlogs = append(backlogs, backlog)
|
||||
if len(backlog.Order) == NUM_PER_PAGE {
|
||||
page += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(backlogs); i++ {
|
||||
mm.processBacklog(ch, backlogs[i])
|
||||
}
|
||||
} else {
|
||||
backlog, resp := mm.conn.Client.GetPostsForChannel(ch.Id, 0, mm.initial_backlog, "")
|
||||
if resp.Error == nil {
|
||||
mm.processBacklog(ch, backlog)
|
||||
} else {
|
||||
log.Warnf("Could not get channel backlog: %s", resp.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *Mattermost) processBacklog(ch *model.Channel, backlog *model.PostList) {
|
||||
for i := 0; i < len(backlog.Order); i++ {
|
||||
post_id := backlog.Order[len(backlog.Order)-i-1]
|
||||
post := backlog.Posts[post_id]
|
||||
post_time := time.Unix(post.CreateAt/1000, 0)
|
||||
post.Message = fmt.Sprintf("[%s] %s",
|
||||
post_time.Format("2006-01-02 15:04 MST"), post.Message)
|
||||
mm.handlePost(ch.Name, post, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *Mattermost) handleLoop(msgCh chan *matterclient.Message, quitCh chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
case <-quitCh:
|
||||
break
|
||||
case msg := <-msgCh:
|
||||
log.Tracef("Mattermost: %#v\n", msg)
|
||||
log.Tracef("Mattermost raw: %#v\n", msg.Raw)
|
||||
err := mm.handlePosted(msg.Raw)
|
||||
if err != nil {
|
||||
log.Warnf("Mattermost error: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *Mattermost) updateUserInfo(user *model.User) {
|
||||
userId := UserID(fmt.Sprintf("%s@%s", user.Username, mm.server))
|
||||
userDisp := user.GetDisplayName(model.SHOW_NICKNAME_FULLNAME)
|
||||
|
||||
mm.caches.Lock()
|
||||
defer mm.caches.Unlock()
|
||||
|
||||
if lastdn, ok := mm.caches.displayname[userId]; !ok || lastdn != userDisp {
|
||||
ui := &UserInfo{
|
||||
DisplayName: userDisp,
|
||||
}
|
||||
if user.LastPictureUpdate > 0 {
|
||||
ui.Avatar = SMediaObject{&LazyBlobMediaObject{
|
||||
ObjectFilename: fmt.Sprintf("%s-%d",
|
||||
user.Username,
|
||||
user.LastPictureUpdate),
|
||||
GetFn: func(o *LazyBlobMediaObject) error {
|
||||
img, resp := mm.conn.Client.GetProfileImage(user.Id, "")
|
||||
if resp.Error != nil {
|
||||
log.Warnf("Could not get profile picture: %s", resp.Error.Error())
|
||||
return resp.Error
|
||||
}
|
||||
o.ObjectData = img
|
||||
o.ObjectMimetype = http.DetectContentType(img)
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
mm.handler.UserInfoUpdated(userId, ui)
|
||||
mm.caches.displayname[userId] = userDisp
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *Mattermost) ensureJoined(user *model.User, roomId RoomID) {
|
||||
userId := UserID(fmt.Sprintf("%s@%s", user.Username, mm.server))
|
||||
cache_key := fmt.Sprintf("%s / %s", userId, roomId)
|
||||
|
||||
mm.caches.Lock()
|
||||
defer mm.caches.Unlock()
|
||||
|
||||
if _, ok := mm.caches.sentjoined[cache_key]; !ok {
|
||||
mm.handler.Event(&Event{
|
||||
Author: userId,
|
||||
Room: roomId,
|
||||
Type: EVENT_JOIN,
|
||||
})
|
||||
mm.caches.sentjoined[cache_key] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *Mattermost) handlePosted(msg *model.WebSocketEvent) error {
|
||||
channel_name, ok := msg.Data["channel_name"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
post_str := msg.Data["post"].(string)
|
||||
var post model.Post
|
||||
err := json.Unmarshal([]byte(post_str), &post)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mm.handlePost(channel_name, &post, false)
|
||||
}
|
||||
|
||||
func (mm *Mattermost) handlePost(channel_name string, post *model.Post, only_messages bool) error {
|
||||
// Skip self messages
|
||||
if post.UserId == mm.conn.User.Id {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find sending user
|
||||
user := mm.conn.GetUser(post.UserId)
|
||||
if user == nil {
|
||||
return fmt.Errorf("Invalid user")
|
||||
}
|
||||
userId := UserID(fmt.Sprintf("%s@%s", user.Username, mm.server))
|
||||
mm.updateUserInfo(user)
|
||||
|
||||
// Build message event
|
||||
msg_ev := &Event{
|
||||
Id: post.Id,
|
||||
Author: userId,
|
||||
Text: post.Message,
|
||||
Type: EVENT_MESSAGE,
|
||||
}
|
||||
if post.Type == "me" {
|
||||
msg_ev.Type = EVENT_ACTION
|
||||
}
|
||||
|
||||
// Handle files
|
||||
if post.Metadata != nil && post.Metadata.Files != nil {
|
||||
msg_ev.Attachments = []SMediaObject{}
|
||||
for _, file := range post.Metadata.Files {
|
||||
media_object := &LazyBlobMediaObject{
|
||||
ObjectFilename: file.Name,
|
||||
ObjectMimetype: file.MimeType,
|
||||
GetFn: func(o *LazyBlobMediaObject) error {
|
||||
blob, resp := mm.conn.Client.GetFile(file.Id)
|
||||
if resp.Error != nil {
|
||||
return resp.Error
|
||||
}
|
||||
o.ObjectData = blob
|
||||
return nil
|
||||
},
|
||||
}
|
||||
if file.Width > 0 {
|
||||
media_object.ObjectImageSize = &ImageSize{
|
||||
Width: file.Width,
|
||||
Height: file.Height,
|
||||
}
|
||||
}
|
||||
msg_ev.Attachments = append(msg_ev.Attachments, SMediaObject{media_object})
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch as PM or as room message
|
||||
if len(strings.Split(channel_name, "__")) == 2 {
|
||||
// Private message, no need to find room id
|
||||
if user.Id == mm.conn.User.Id {
|
||||
// Skip self sent messages
|
||||
return nil
|
||||
}
|
||||
|
||||
mm.handler.Event(msg_ev)
|
||||
mm.handler.CachePut(fmt.Sprintf("last_seen_%s", post.ChannelId), post.Id)
|
||||
} else {
|
||||
interested, roomId := mm.reverseRoomId(post.ChannelId)
|
||||
if !interested {
|
||||
return nil
|
||||
}
|
||||
if roomId == "" {
|
||||
return fmt.Errorf("Invalid channel id")
|
||||
}
|
||||
|
||||
mm.ensureJoined(user, roomId)
|
||||
|
||||
if post.Type == "system_header_change" {
|
||||
if !only_messages {
|
||||
new_header := post.Props["new_header"].(string)
|
||||
mm.handler.RoomInfoUpdated(roomId, userId, &RoomInfo{
|
||||
Topic: new_header,
|
||||
})
|
||||
}
|
||||
} else if post.Type == "" || post.Type == "me" {
|
||||
msg_ev.Room = roomId
|
||||
mm.handler.Event(msg_ev)
|
||||
mm.handler.CachePut(fmt.Sprintf("last_seen_%s", post.ChannelId), post.Id)
|
||||
} else {
|
||||
return fmt.Errorf("Unhandled post type: %s", post.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -1,147 +0,0 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type FileMediaObject struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (m *FileMediaObject) Filename() string {
|
||||
return filepath.Base(m.Path)
|
||||
}
|
||||
|
||||
func (m *FileMediaObject) Size() int64 {
|
||||
fi, err := os.Stat(m.Path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return fi.Size()
|
||||
}
|
||||
|
||||
func (m *FileMediaObject) Mimetype() string {
|
||||
f, err := os.Open(m.Path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buffer := make([]byte, 512)
|
||||
_, err = f.Read(buffer)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return http.DetectContentType(buffer)
|
||||
}
|
||||
|
||||
func (m *FileMediaObject) ImageSize() *ImageSize {
|
||||
// TODO but not really usefull
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *FileMediaObject) Read() (io.ReadCloser, error) {
|
||||
return os.Open(m.Path)
|
||||
}
|
||||
|
||||
func (m *FileMediaObject) URL() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
type BlobMediaObject struct {
|
||||
ObjectFilename string
|
||||
ObjectMimetype string
|
||||
ObjectImageSize *ImageSize
|
||||
ObjectData []byte
|
||||
}
|
||||
|
||||
func (m *BlobMediaObject) Filename() string {
|
||||
return m.ObjectFilename
|
||||
}
|
||||
|
||||
func (m *BlobMediaObject) Size() int64 {
|
||||
return int64(len(m.ObjectData))
|
||||
}
|
||||
|
||||
func (m *BlobMediaObject) Mimetype() string {
|
||||
return m.ObjectMimetype
|
||||
}
|
||||
|
||||
func (m *BlobMediaObject) ImageSize() *ImageSize {
|
||||
return m.ObjectImageSize
|
||||
}
|
||||
|
||||
func (m *BlobMediaObject) Read() (io.ReadCloser, error) {
|
||||
return nullCloseReader{bytes.NewBuffer(m.ObjectData)}, nil
|
||||
}
|
||||
|
||||
func (m *BlobMediaObject) URL() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type nullCloseReader struct {
|
||||
io.Reader
|
||||
}
|
||||
|
||||
func (ncr nullCloseReader) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
type LazyBlobMediaObject struct {
|
||||
ObjectFilename string
|
||||
ObjectMimetype string
|
||||
ObjectImageSize *ImageSize
|
||||
ObjectData []byte
|
||||
|
||||
GetFn func(o *LazyBlobMediaObject) error
|
||||
}
|
||||
|
||||
func (m *LazyBlobMediaObject) Filename() string {
|
||||
return m.ObjectFilename
|
||||
}
|
||||
|
||||
func (m *LazyBlobMediaObject) Size() int64 {
|
||||
if m.ObjectData == nil {
|
||||
m.GetFn(m)
|
||||
}
|
||||
return int64(len(m.ObjectData))
|
||||
}
|
||||
|
||||
func (m *LazyBlobMediaObject) Mimetype() string {
|
||||
if m.ObjectData == nil {
|
||||
m.GetFn(m)
|
||||
}
|
||||
return m.ObjectMimetype
|
||||
}
|
||||
|
||||
func (m *LazyBlobMediaObject) ImageSize() *ImageSize {
|
||||
if m.ObjectData == nil {
|
||||
m.GetFn(m)
|
||||
}
|
||||
return m.ObjectImageSize
|
||||
}
|
||||
|
||||
func (m *LazyBlobMediaObject) Read() (io.ReadCloser, error) {
|
||||
if m.ObjectData == nil {
|
||||
err := m.GetFn(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nullCloseReader{bytes.NewBuffer(m.ObjectData)}, nil
|
||||
}
|
||||
|
||||
func (m *LazyBlobMediaObject) URL() string {
|
||||
return ""
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
package xmpp
|
||||
|
||||
import (
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
const XMPP_PROTOCOL = "XMPP"
|
||||
|
||||
func init() {
|
||||
Register(XMPP_PROTOCOL, Protocol{
|
||||
NewConnector: func() Connector { return &XMPP{} },
|
||||
Schema: ConfigSchema{
|
||||
&ConfigEntry{
|
||||
Name: "jid",
|
||||
Description: "JID",
|
||||
Required: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "password",
|
||||
Description: "Password",
|
||||
Required: true,
|
||||
IsPassword: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "nickname",
|
||||
Description: "Nickname in MUCs",
|
||||
Required: true,
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "port",
|
||||
Description: "Port",
|
||||
IsNumeric: true,
|
||||
Default: "5222",
|
||||
},
|
||||
&ConfigEntry{
|
||||
Name: "ssl",
|
||||
Description: "Use SSL",
|
||||
IsBoolean: true,
|
||||
Default: "true",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
51
connector/xmpp/iq.go
Normal file
51
connector/xmpp/iq.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
package xmpp
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
)
|
||||
|
||||
type DiscoQuery struct {
|
||||
XMLName xml.Name `xml:"http://jabber.org/protocol/disco#items query"`
|
||||
Items []Node `xml:"item"`
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
XMLName xml.Name `xml:"item"`
|
||||
Jid string `xml:"jid,attr"`
|
||||
Node string `xml:"node,attr"`
|
||||
}
|
||||
|
||||
type PubSub struct {
|
||||
XMLName xml.Name `xml:"http://jabber.org/protocol/pubsub pubsub"`
|
||||
Subscribe []Subscribe `xml:"subscribe"`
|
||||
Subscription []Subscription `xml:"subscription"`
|
||||
Subscriptions []Subscription `xml:"subscriptions"`
|
||||
Publish *Publish `xml:"publish"`
|
||||
}
|
||||
|
||||
type Subscribe struct {
|
||||
XMLName xml.Name `xml:"subscribe"`
|
||||
Jid string `xml:"jid,attr"`
|
||||
Node string `xml:"node,attr"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
XMLName xml.Name `xml:"subscription"`
|
||||
Jid string `xml:"jid,attr"`
|
||||
Node string `xml:"node,attr"`
|
||||
SubID string `xml:"subid,attr"`
|
||||
Subscription string `xml:"subscription,attr"`
|
||||
}
|
||||
|
||||
type Publish struct {
|
||||
XMLName xml.Name `xml:"publish"`
|
||||
Node string `xml:"node,attr"`
|
||||
Item []Item `xml:"item"`
|
||||
Items []Item `xml:"items"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
XMLName xml.Name `xml:"publish"`
|
||||
Id string `xml:"id,attr"`
|
||||
Data string `xml:",innerxml"`
|
||||
}
|
|
@ -1,15 +1,16 @@
|
|||
package xmpp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
//"os"
|
||||
"strings"
|
||||
"fmt"
|
||||
"crypto/tls"
|
||||
"encoding/xml"
|
||||
|
||||
gxmpp "github.com/matterbridge/go-xmpp"
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
//gxmpp "github.com/mattn/go-xmpp"
|
||||
gxmpp "git.deuxfleurs.fr/lx/go-xmpp"
|
||||
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
@ -23,35 +24,28 @@ type XMPP struct {
|
|||
handler Handler
|
||||
|
||||
connectorLoopNum int
|
||||
connected bool
|
||||
timeout int
|
||||
connected bool
|
||||
timeout int
|
||||
|
||||
server string
|
||||
port int
|
||||
ssl bool
|
||||
jid string
|
||||
server string
|
||||
port int
|
||||
ssl bool
|
||||
jid string
|
||||
jid_localpart string
|
||||
password string
|
||||
nickname string
|
||||
password string
|
||||
nickname string
|
||||
|
||||
conn *gxmpp.Client
|
||||
|
||||
stateLock sync.Mutex
|
||||
muc map[RoomID]*mucInfo
|
||||
}
|
||||
|
||||
type mucInfo struct {
|
||||
joined bool
|
||||
pendingJoins map[UserID]string
|
||||
pendingLeaves map[UserID]struct{}
|
||||
isMUC map[string]bool
|
||||
}
|
||||
|
||||
func (xm *XMPP) SetHandler(h Handler) {
|
||||
xm.handler = h
|
||||
}
|
||||
|
||||
func (xm *XMPP) Protocol() string {
|
||||
return XMPP_PROTOCOL
|
||||
func(xm *XMPP) Protocol() string {
|
||||
return "xmpp"
|
||||
}
|
||||
|
||||
func (xm *XMPP) Configure(c Configuration) error {
|
||||
|
@ -62,6 +56,11 @@ func (xm *XMPP) Configure(c Configuration) error {
|
|||
// Parse and validate configuration
|
||||
var err error
|
||||
|
||||
xm.server, err = c.GetString("server")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
xm.port, err = c.GetInt("port", 5222)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -80,10 +79,11 @@ func (xm *XMPP) Configure(c Configuration) error {
|
|||
if len(jid_parts) != 2 {
|
||||
return fmt.Errorf("Invalid JID: %s", xm.jid)
|
||||
}
|
||||
xm.server = jid_parts[1]
|
||||
if jid_parts[1] != xm.server {
|
||||
return fmt.Errorf("JID %s not on server %s", xm.jid, xm.server)
|
||||
}
|
||||
xm.jid_localpart = jid_parts[0]
|
||||
|
||||
xm.nickname, _ = c.GetString("nickname", xm.jid_localpart)
|
||||
xm.nickname = xm.jid_localpart
|
||||
|
||||
xm.password, err = c.GetString("password")
|
||||
if err != nil {
|
||||
|
@ -91,13 +91,15 @@ func (xm *XMPP) Configure(c Configuration) error {
|
|||
}
|
||||
|
||||
// Try to connect
|
||||
xm.muc = make(map[RoomID]*mucInfo)
|
||||
if xm.isMUC == nil {
|
||||
xm.isMUC = make(map[string]bool)
|
||||
}
|
||||
|
||||
xm.connectorLoopNum += 1
|
||||
go xm.connectLoop(xm.connectorLoopNum)
|
||||
|
||||
for i := 0; i < 42; i++ {
|
||||
time.Sleep(time.Duration(1) * time.Second)
|
||||
time.Sleep(time.Duration(1)*time.Second)
|
||||
if xm.connected {
|
||||
return nil
|
||||
}
|
||||
|
@ -112,23 +114,24 @@ func (xm *XMPP) connectLoop(num int) {
|
|||
return
|
||||
}
|
||||
tc := &tls.Config{
|
||||
ServerName: xm.server,
|
||||
ServerName: strings.Split(xm.jid, "@")[1],
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
options := gxmpp.Options{
|
||||
Host: xm.server,
|
||||
User: xm.jid,
|
||||
Password: xm.password,
|
||||
NoTLS: true,
|
||||
StartTLS: xm.ssl,
|
||||
Session: true,
|
||||
Host: xm.server,
|
||||
User: xm.jid,
|
||||
Password: xm.password,
|
||||
NoTLS: true,
|
||||
StartTLS: xm.ssl,
|
||||
Session: true,
|
||||
TLSConfig: tc,
|
||||
}
|
||||
var err error
|
||||
xm.conn, err = options.NewClient()
|
||||
if err != nil {
|
||||
xm.connected = false
|
||||
xm.handler.SystemMessage(fmt.Sprintf("XMPP failed to connect (%s). Retrying in %ds", err, xm.timeout))
|
||||
fmt.Printf("XMPP failed to connect / disconnected: %s\n", err)
|
||||
fmt.Printf("Retrying in %ds\n", xm.timeout)
|
||||
time.Sleep(time.Duration(xm.timeout) * time.Second)
|
||||
xm.timeout *= 2
|
||||
if xm.timeout > 600 {
|
||||
|
@ -137,22 +140,12 @@ func (xm *XMPP) connectLoop(num int) {
|
|||
} else {
|
||||
xm.connected = true
|
||||
xm.timeout = 10
|
||||
|
||||
for muc, mucInfo := range xm.muc {
|
||||
if mucInfo.joined {
|
||||
_, err := xm.conn.JoinMUCNoHistory(string(muc), xm.nickname)
|
||||
if err != nil {
|
||||
xm.handler.SystemMessage(fmt.Sprintf("Could not rejoin MUC %s after reconnection: %s", muc, err))
|
||||
xm.handler.Left(RoomID(muc))
|
||||
mucInfo.joined = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = xm.handleXMPP()
|
||||
|
||||
xm.connected = false
|
||||
xm.handler.SystemMessage(fmt.Sprintf("XMPP disconnected (%s), reconnecting)", err))
|
||||
if err != nil {
|
||||
xm.connected = false
|
||||
fmt.Printf("XMPP disconnected: %s\n", err)
|
||||
fmt.Printf("Reconnecting.\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -166,7 +159,7 @@ func (xm *XMPP) xmppKeepAlive() chan bool {
|
|||
select {
|
||||
case <-ticker.C:
|
||||
if err := xm.conn.PingC2S("", ""); err != nil {
|
||||
log.Debugf("PING failed %#v\n", err)
|
||||
log.Printf("PING failed %#v\n", err)
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
|
@ -186,125 +179,136 @@ func (xm *XMPP) handleXMPP() error {
|
|||
return err
|
||||
}
|
||||
|
||||
log.Tracef("XMPP: %#v\n", m)
|
||||
|
||||
xm.handleXMPPStance(m)
|
||||
}
|
||||
}
|
||||
switch v := m.(type) {
|
||||
case gxmpp.Chat:
|
||||
fmt.Printf("XMPP chat: %#v\n", v)
|
||||
remote_sp := strings.Split(v.Remote, "/")
|
||||
|
||||
func (xm *XMPP) handleXMPPStance(m interface{}) {
|
||||
xm.stateLock.Lock()
|
||||
defer xm.stateLock.Unlock()
|
||||
// Skip self-sent events
|
||||
if v.Remote == xm.jid || (v.Type == "groupchat" && len(remote_sp) == 2 && remote_sp[1] == xm.nickname) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := m.(type) {
|
||||
case gxmpp.Chat:
|
||||
remote_sp := strings.Split(v.Remote, "/")
|
||||
// If empty text, make sure we joined the room
|
||||
// We would do this at every incoming message if it were not so costly
|
||||
if v.Text == "" && v.Type == "groupchat" {
|
||||
xm.handler.Joined(RoomID(remote_sp[0]))
|
||||
}
|
||||
|
||||
// Skip self-sent events
|
||||
if v.Remote == xm.jid || (v.Type == "groupchat" && len(remote_sp) == 2 && remote_sp[1] == xm.nickname) {
|
||||
return
|
||||
}
|
||||
|
||||
// If empty text, make sure we joined the room
|
||||
// We would do this at every incoming message if it were not so costly
|
||||
if v.Text == "" && v.Type == "groupchat" {
|
||||
xm.handler.Joined(RoomID(remote_sp[0]))
|
||||
}
|
||||
|
||||
// Handle subject change in group chats
|
||||
if v.Subject != "" && v.Type == "groupchat" {
|
||||
author := UserID("")
|
||||
if len(remote_sp) == 2 {
|
||||
if remote_sp[1] == xm.nickname {
|
||||
author = xm.User()
|
||||
} else {
|
||||
// Handle subject change in group chats
|
||||
if v.Subject != "" && v.Type == "groupchat" {
|
||||
author := UserID("")
|
||||
if len(remote_sp) == 2 {
|
||||
author = UserID(remote_sp[1] + "@" + remote_sp[0])
|
||||
}
|
||||
}
|
||||
xm.handler.RoomInfoUpdated(RoomID(remote_sp[0]), author, &RoomInfo{
|
||||
Topic: v.Subject,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle text message
|
||||
if v.Text != "" {
|
||||
event := &Event{
|
||||
Type: EVENT_MESSAGE,
|
||||
Text: v.Text,
|
||||
xm.handler.RoomInfoUpdated(RoomID(remote_sp[0]), author, &RoomInfo{
|
||||
Topic: v.Subject,
|
||||
})
|
||||
}
|
||||
|
||||
if strings.HasPrefix(event.Text, "/me ") {
|
||||
event.Type = EVENT_ACTION
|
||||
event.Text = strings.Replace(event.Text, "/me ", "", 1)
|
||||
}
|
||||
|
||||
if v.Type == "chat" {
|
||||
event.Author = UserID(remote_sp[0])
|
||||
xm.handler.Event(event)
|
||||
}
|
||||
if v.Type == "groupchat" && len(remote_sp) == 2 {
|
||||
// First flush pending leaves and joins
|
||||
room_id := RoomID(remote_sp[0])
|
||||
if muc, ok := xm.muc[room_id]; ok {
|
||||
muc.flushLeavesJoins(room_id, xm.handler)
|
||||
// Handle text message
|
||||
if v.Text != "" {
|
||||
event := &Event{
|
||||
Type: EVENT_MESSAGE,
|
||||
Text: v.Text,
|
||||
}
|
||||
|
||||
// Now send event
|
||||
event.Room = room_id
|
||||
event.Author = UserID(remote_sp[1] + "@" + remote_sp[0])
|
||||
event.Id = v.ID
|
||||
xm.handler.Event(event)
|
||||
}
|
||||
}
|
||||
case gxmpp.Presence:
|
||||
remote := strings.Split(v.From, "/")
|
||||
room := RoomID(remote[0])
|
||||
if mucInfo, ok := xm.muc[room]; ok {
|
||||
// skip presence with no user and self-presence
|
||||
if len(remote) < 2 || remote[1] == xm.nickname {
|
||||
return
|
||||
}
|
||||
|
||||
user := UserID(remote[1] + "@" + remote[0])
|
||||
if v.Type != "unavailable" {
|
||||
if _, ok := mucInfo.pendingLeaves[user]; ok {
|
||||
delete(mucInfo.pendingLeaves, user)
|
||||
} else {
|
||||
mucInfo.pendingJoins[user] = remote[1]
|
||||
if strings.HasPrefix(event.Text, "/me ") {
|
||||
event.Type = EVENT_ACTION
|
||||
event.Text = strings.Replace(event.Text, "/me ", "", 1)
|
||||
}
|
||||
|
||||
if v.Type == "chat" {
|
||||
event.Author = UserID(remote_sp[0])
|
||||
xm.handler.Event(event)
|
||||
}
|
||||
if v.Type == "groupchat" && len(remote_sp) == 2 {
|
||||
event.Room = RoomID(remote_sp[0])
|
||||
event.Author = UserID(remote_sp[1] + "@" + remote_sp[0])
|
||||
xm.handler.Event(event)
|
||||
}
|
||||
}
|
||||
case gxmpp.Presence:
|
||||
fmt.Printf("XMPP presence: %#v\n", v)
|
||||
|
||||
remote := strings.Split(v.From, "/")
|
||||
if ismuc, ok := xm.isMUC[remote[0]]; ok && ismuc {
|
||||
// skip presence with no user and self-presence
|
||||
if len(remote) < 2 || remote[1] == xm.nickname {
|
||||
continue
|
||||
}
|
||||
|
||||
user := UserID(remote[1] + "@" + remote[0])
|
||||
event := &Event{
|
||||
Type: EVENT_JOIN,
|
||||
Room: RoomID(remote[0]),
|
||||
Author: user,
|
||||
}
|
||||
if v.Type == "unavailable" {
|
||||
event.Type = EVENT_LEAVE
|
||||
}
|
||||
xm.handler.Event(event)
|
||||
xm.handler.UserInfoUpdated(user, &UserInfo{
|
||||
DisplayName: remote[1],
|
||||
})
|
||||
} else {
|
||||
if _, ok := mucInfo.pendingJoins[user]; ok {
|
||||
delete(mucInfo.pendingJoins, user)
|
||||
// Send discovery query
|
||||
iq, err := xml.Marshal(&DiscoQuery{})
|
||||
if err != nil {
|
||||
fmt.Printf("XML marshall error: %s\n", err)
|
||||
} else {
|
||||
mucInfo.pendingLeaves[user] = struct{}{}
|
||||
xm.conn.SendIQ(gxmpp.IQ{
|
||||
Type: "get",
|
||||
To: remote[0],
|
||||
ID: "items1",
|
||||
Query: iq,
|
||||
})
|
||||
}
|
||||
}
|
||||
case gxmpp.IQ:
|
||||
fmt.Printf("XMPP iq: from=%s to=%s id=%s type=%s\n", v.From, v.To, v.ID, v.Type)
|
||||
if len(v.Query) > 0 {
|
||||
fmt.Printf("Query data: %s\n", string(v.Query))
|
||||
}
|
||||
|
||||
if v.Type == "result" && v.ID == "items1" {
|
||||
var q DiscoQuery
|
||||
err := xml.Unmarshal(v.Query, &q)
|
||||
if err != nil {
|
||||
fmt.Printf("XML unmarshall error: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range q.Items {
|
||||
if item.Node == "urn:xmpp:avatar:metadata" || item.Node == "urn:xmpp:avatar:data" {
|
||||
sub := &PubSub{
|
||||
Subscribe: []Subscribe{
|
||||
Subscribe{
|
||||
Jid: xm.jid,
|
||||
Node: item.Node,
|
||||
},
|
||||
},
|
||||
}
|
||||
iq, err := xml.Marshal(sub)
|
||||
if err != nil {
|
||||
fmt.Printf("XML marshall error: %s\n", err)
|
||||
} else {
|
||||
fmt.Printf("IQ AVATAR SUB: %s\n", iq)
|
||||
xm.conn.SendIQ(gxmpp.IQ{
|
||||
To: v.From,
|
||||
Type: "set",
|
||||
ID: "sub1",
|
||||
Query: iq,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (muc *mucInfo) flushLeavesJoins(room RoomID, handler Handler) {
|
||||
for user, display_name := range muc.pendingJoins {
|
||||
handler.Event(&Event{
|
||||
Type: EVENT_JOIN,
|
||||
Room: room,
|
||||
Author: user,
|
||||
})
|
||||
handler.UserInfoUpdated(user, &UserInfo{
|
||||
DisplayName: display_name,
|
||||
})
|
||||
}
|
||||
for user, _ := range muc.pendingLeaves {
|
||||
handler.Event(&Event{
|
||||
Type: EVENT_LEAVE,
|
||||
Room: room,
|
||||
Author: user,
|
||||
})
|
||||
}
|
||||
muc.pendingJoins = make(map[UserID]string)
|
||||
muc.pendingLeaves = make(map[UserID]struct{})
|
||||
}
|
||||
|
||||
func (xm *XMPP) User() UserID {
|
||||
return UserID(xm.jid)
|
||||
}
|
||||
|
@ -315,17 +319,14 @@ func (xm *XMPP) SetUserInfo(info *UserInfo) error {
|
|||
|
||||
func (xm *XMPP) SetRoomInfo(roomId RoomID, info *RoomInfo) error {
|
||||
if info.Topic != "" {
|
||||
_, err := xm.conn.Send(gxmpp.Chat{
|
||||
Type: "groupchat",
|
||||
Remote: string(roomId),
|
||||
xm.conn.Send(gxmpp.Chat{
|
||||
Type: "groupchat",
|
||||
Remote: string(roomId),
|
||||
Subject: info.Topic,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if info.Picture.MediaObject != nil {
|
||||
if info.Picture != nil {
|
||||
// TODO
|
||||
return fmt.Errorf("Room picture change not implemented on xmpp")
|
||||
}
|
||||
|
@ -338,107 +339,46 @@ func (xm *XMPP) SetRoomInfo(roomId RoomID, info *RoomInfo) error {
|
|||
}
|
||||
|
||||
func (xm *XMPP) Join(roomId RoomID) error {
|
||||
xm.stateLock.Lock()
|
||||
defer xm.stateLock.Unlock()
|
||||
xm.isMUC[string(roomId)] = true
|
||||
|
||||
xm.muc[roomId] = &mucInfo{
|
||||
pendingJoins: make(map[UserID]string),
|
||||
pendingLeaves: make(map[UserID]struct{}),
|
||||
}
|
||||
|
||||
log.Tracef("Join %s with nick %s\n", roomId, xm.nickname)
|
||||
fmt.Printf("Join %s with nick %s\n", roomId, xm.nickname)
|
||||
_, err := xm.conn.JoinMUCNoHistory(string(roomId), xm.nickname)
|
||||
|
||||
if err == nil {
|
||||
xm.muc[roomId].joined = true
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (xm *XMPP) Invite(userId UserID, roomId RoomID) error {
|
||||
if roomId == "" {
|
||||
xm.conn.RequestSubscription(string(userId))
|
||||
xm.conn.ApproveSubscription(string(userId))
|
||||
return nil
|
||||
}
|
||||
// TODO
|
||||
return fmt.Errorf("Not implemented")
|
||||
}
|
||||
|
||||
func (xm *XMPP) Leave(roomId RoomID) {
|
||||
xm.stateLock.Lock()
|
||||
defer xm.stateLock.Unlock()
|
||||
|
||||
xm.conn.LeaveMUC(string(roomId))
|
||||
|
||||
if muc, ok := xm.muc[roomId]; ok {
|
||||
muc.joined = false
|
||||
}
|
||||
}
|
||||
|
||||
func (xm *XMPP) SearchForUsers(query string) ([]UserSearchResult, error) {
|
||||
// TODO: search roster
|
||||
return nil, fmt.Errorf("Not implemented")
|
||||
}
|
||||
|
||||
func (xm *XMPP) Send(event *Event) (string, error) {
|
||||
xm.stateLock.Lock()
|
||||
defer xm.stateLock.Unlock()
|
||||
|
||||
if event.Attachments != nil && len(event.Attachments) > 0 {
|
||||
for _, at := range event.Attachments {
|
||||
url := at.URL()
|
||||
if url == "" {
|
||||
// TODO find a way to send them using some hosing of some kind
|
||||
return "", fmt.Errorf("Attachment without URL sent to XMPP")
|
||||
} else {
|
||||
event.Text += fmt.Sprintf("\n%s (%s, %dkb)",
|
||||
url, at.Mimetype(), at.Size()/1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event.Id == "" {
|
||||
event.Id = xid.New().String()
|
||||
}
|
||||
|
||||
log.Tracef("xm *XMPP Send %#v\n", event)
|
||||
func (xm *XMPP) Send(event *Event) error {
|
||||
fmt.Printf("xm *XMPP Send %#v\n", event)
|
||||
if len(event.Recipient) > 0 {
|
||||
_, err := xm.conn.Send(gxmpp.Chat{
|
||||
Type: "chat",
|
||||
xm.conn.Send(gxmpp.Chat{
|
||||
Type: "chat",
|
||||
Remote: string(event.Recipient),
|
||||
Text: event.Text,
|
||||
Text: event.Text,
|
||||
})
|
||||
return event.Id, err
|
||||
return nil
|
||||
} else if len(event.Room) > 0 {
|
||||
if muc, ok := xm.muc[event.Room]; ok {
|
||||
muc.flushLeavesJoins(event.Room, xm.handler)
|
||||
}
|
||||
_, err := xm.conn.Send(gxmpp.Chat{
|
||||
Type: "groupchat",
|
||||
xm.conn.Send(gxmpp.Chat{
|
||||
Type: "groupchat",
|
||||
Remote: string(event.Room),
|
||||
Text: event.Text,
|
||||
ID: event.Id,
|
||||
Text: event.Text,
|
||||
})
|
||||
return event.Id, err
|
||||
return nil
|
||||
} else {
|
||||
return "", fmt.Errorf("Invalid event")
|
||||
return fmt.Errorf("Invalid event")
|
||||
}
|
||||
}
|
||||
|
||||
func (xm *XMPP) UserCommand(cmd string) {
|
||||
xm.handler.SystemMessage("Command not supported.")
|
||||
}
|
||||
|
||||
func (xm *XMPP) Close() {
|
||||
xm.stateLock.Lock()
|
||||
defer xm.stateLock.Unlock()
|
||||
|
||||
if xm.conn != nil {
|
||||
xm.conn.Close()
|
||||
}
|
||||
xm.conn.Close()
|
||||
xm.conn = nil
|
||||
xm.connectorLoopNum += 1
|
||||
xm.connected = false
|
||||
}
|
||||
|
||||
|
|
320
db.go
320
db.go
|
@ -1,320 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
_ "github.com/jinzhu/gorm/dialects/mysql"
|
||||
_ "github.com/jinzhu/gorm/dialects/postgres"
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/blake2b"
|
||||
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
func InitDb() error {
|
||||
var err error
|
||||
|
||||
db, err = gorm.Open(config.DbType, config.DbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db.AutoMigrate(&DbAccountConfig{})
|
||||
|
||||
db.AutoMigrate(&DbJoinedRoom{})
|
||||
db.Model(&DbJoinedRoom{}).AddIndex("idx_joined_room_user_protocol_account", "mx_user_id", "protocol", "account_name")
|
||||
|
||||
db.AutoMigrate(&DbUserMap{})
|
||||
db.Model(&DbUserMap{}).AddIndex("idx_user_map_protocol_user", "protocol", "user_id")
|
||||
|
||||
db.AutoMigrate(&DbRoomMap{})
|
||||
db.Model(&DbRoomMap{}).AddIndex("idx_room_map_protocol_room", "protocol", "room_id")
|
||||
|
||||
db.AutoMigrate(&DbPmRoomMap{})
|
||||
db.Model(&DbPmRoomMap{}).AddIndex("idx_pm_room_map_protocol_user_account_user", "protocol", "mx_user_id", "account_name", "user_id")
|
||||
|
||||
db.AutoMigrate(&DbKv{})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Account configuration
|
||||
type DbAccountConfig struct {
|
||||
gorm.Model
|
||||
|
||||
MxUserID string `gorm:"index"`
|
||||
Name string
|
||||
Protocol string
|
||||
Config string
|
||||
}
|
||||
|
||||
// List of joined channels to be re-joined on reconnect
|
||||
type DbJoinedRoom struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement:true"`
|
||||
|
||||
// User id and account name
|
||||
MxUserID string
|
||||
Protocol string
|
||||
AccountName string
|
||||
|
||||
// Room ID
|
||||
RoomID connector.RoomID
|
||||
}
|
||||
|
||||
// User mapping between protocol user IDs and puppeted matrix ids
|
||||
type DbUserMap struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement:true"`
|
||||
|
||||
// Protocol and user ID on the bridged network
|
||||
Protocol string
|
||||
UserID connector.UserID
|
||||
|
||||
// Puppetted Matrix ID
|
||||
MxUserID string `gorm:"index"`
|
||||
}
|
||||
|
||||
// Room mapping between Matrix rooms and outside rooms
|
||||
type DbRoomMap struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement:true"`
|
||||
|
||||
// Network protocol and room ID on the bridged network
|
||||
Protocol string
|
||||
RoomID connector.RoomID
|
||||
|
||||
// Bridged room matrix id
|
||||
MxRoomID string `gorm:"index"`
|
||||
}
|
||||
|
||||
// Room mapping between Matrix rooms and private messages
|
||||
type DbPmRoomMap struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement:true"`
|
||||
|
||||
// User id and account name of the local end viewed on Matrix
|
||||
MxUserID string
|
||||
Protocol string
|
||||
AccountName string
|
||||
|
||||
// User id to reach them on the bridged network
|
||||
UserID connector.UserID
|
||||
|
||||
// Bridged room for PMs
|
||||
MxRoomID string `gorm:"index"`
|
||||
}
|
||||
|
||||
// Key-value store for various things
|
||||
type DbKv struct {
|
||||
Key string `gorm:"primaryKey"`
|
||||
Value string
|
||||
}
|
||||
|
||||
// ---- Simple locking mechanism
|
||||
// Slot keys are strings that identify the object we are acting upon
|
||||
// They define which lock to lock for a certain operation
|
||||
|
||||
var dbLocks [256]sync.Mutex
|
||||
|
||||
func dbLockSlot(key string) {
|
||||
slot := blake2b.Sum512([]byte(key))[0]
|
||||
dbLocks[slot].Lock()
|
||||
}
|
||||
|
||||
func dbUnlockSlot(key string) {
|
||||
slot := blake2b.Sum512([]byte(key))[0]
|
||||
dbLocks[slot].Unlock()
|
||||
}
|
||||
|
||||
// ---- Key-value store supporting atomic test-and-set
|
||||
|
||||
func dbKvGet(key string) string {
|
||||
var entry DbKv
|
||||
if db.Where(&DbKv{Key: key}).First(&entry).RecordNotFound() {
|
||||
return ""
|
||||
} else {
|
||||
return entry.Value
|
||||
}
|
||||
}
|
||||
|
||||
func dbKvPut(key string, value string) {
|
||||
dbLockSlot(key)
|
||||
defer dbUnlockSlot(key)
|
||||
|
||||
dbKvPutLocked(key, value)
|
||||
}
|
||||
|
||||
// Variant of dbKvPut that does not take a lock,
|
||||
// use this if the slot is already locked
|
||||
func dbKvPutLocked(key string, value string) {
|
||||
var entry DbKv
|
||||
db.Where(&DbKv{Key: key}).Assign(&DbKv{Value: value}).FirstOrCreate(&entry)
|
||||
}
|
||||
|
||||
func dbKvTestAndSet(key string, value string) bool {
|
||||
dbLockSlot(key)
|
||||
defer dbUnlockSlot(key)
|
||||
|
||||
// True if value was changed, false if was already set
|
||||
if dbKvGet(key) == value {
|
||||
return false
|
||||
}
|
||||
|
||||
dbKvPutLocked(key, value)
|
||||
return true
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func dbGetMxRoom(protocol string, roomId connector.RoomID) (string, error) {
|
||||
slot_key := fmt.Sprintf("room:%s/%s", protocol, roomId)
|
||||
|
||||
dbLockSlot(slot_key)
|
||||
defer dbUnlockSlot(slot_key)
|
||||
|
||||
// Check if room exists in our mapping,
|
||||
// If not create it
|
||||
var room DbRoomMap
|
||||
must_create := db.First(&room, DbRoomMap{
|
||||
Protocol: protocol,
|
||||
RoomID: roomId,
|
||||
}).RecordNotFound()
|
||||
|
||||
if must_create {
|
||||
alias := roomAlias(protocol, roomId)
|
||||
|
||||
// Delete previous alias if it existed
|
||||
prev_full_alias := fmt.Sprintf("#%s:%s", alias, config.MatrixDomain)
|
||||
mx_room_id, err := mx.DirectoryRoom(prev_full_alias)
|
||||
if err == nil {
|
||||
mx.DirectoryDeleteRoom(prev_full_alias)
|
||||
}
|
||||
|
||||
// Create room
|
||||
name := fmt.Sprintf("%s (%s)", roomId, protocol)
|
||||
|
||||
mx_room_id, err = mx.CreateRoom(name, alias, []string{})
|
||||
if err != nil {
|
||||
log.Warnf("Could not create room for %s: %s", name, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
room = DbRoomMap{
|
||||
Protocol: protocol,
|
||||
RoomID: roomId,
|
||||
MxRoomID: mx_room_id,
|
||||
}
|
||||
db.Create(&room)
|
||||
}
|
||||
|
||||
log.Tracef("%s -> %s", slot_key, room.MxRoomID)
|
||||
|
||||
return room.MxRoomID, nil
|
||||
}
|
||||
|
||||
func dbGetMxPmRoom(protocol string, them connector.UserID, themMxId string, usMxId string, usAccount string) (string, error) {
|
||||
map_key := &DbPmRoomMap{
|
||||
MxUserID: usMxId,
|
||||
Protocol: protocol,
|
||||
AccountName: usAccount,
|
||||
UserID: them,
|
||||
}
|
||||
slot_key := fmt.Sprintf("pmroom:%s/%s/%s/%s", protocol, usMxId, usAccount, them)
|
||||
|
||||
dbLockSlot(slot_key)
|
||||
defer dbUnlockSlot(slot_key)
|
||||
|
||||
var room DbPmRoomMap
|
||||
must_create := db.First(&room, map_key).RecordNotFound()
|
||||
|
||||
if must_create {
|
||||
name := fmt.Sprintf("%s (%s)", them, protocol)
|
||||
|
||||
mx_room_id, err := mx.CreateDirectRoomAs([]string{usMxId}, themMxId)
|
||||
if err != nil {
|
||||
log.Warnf("Could not create room for %s: %s", name, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
room = DbPmRoomMap{
|
||||
MxUserID: usMxId,
|
||||
Protocol: protocol,
|
||||
AccountName: usAccount,
|
||||
UserID: them,
|
||||
MxRoomID: mx_room_id,
|
||||
}
|
||||
db.Create(&room)
|
||||
}
|
||||
|
||||
log.Tracef("%s -> %s", slot_key, room.MxRoomID)
|
||||
|
||||
return room.MxRoomID, nil
|
||||
}
|
||||
|
||||
func dbDeletePmRoom(room *DbPmRoomMap) {
|
||||
if room.ID != 0 {
|
||||
db.Delete(room)
|
||||
} else {
|
||||
log.Warnf("In dbDeletePmRoom: %#v (not deleting since primary key is zero)", room)
|
||||
}
|
||||
}
|
||||
|
||||
func dbGetMxUser(protocol string, userId connector.UserID) (string, error) {
|
||||
slot_key := fmt.Sprintf("user:%s/%s", protocol, userId)
|
||||
|
||||
dbLockSlot(slot_key)
|
||||
defer dbUnlockSlot(slot_key)
|
||||
|
||||
var user DbUserMap
|
||||
|
||||
must_create := db.First(&user, DbUserMap{
|
||||
Protocol: protocol,
|
||||
UserID: userId,
|
||||
}).RecordNotFound()
|
||||
if must_create {
|
||||
username := userMxId(protocol, userId)
|
||||
|
||||
err := mx.RegisterUser(username)
|
||||
if err != nil {
|
||||
if mxE, ok := err.(*mxlib.MxError); !ok || mxE.ErrCode != "M_USER_IN_USE" {
|
||||
log.Warnf("Could not register %s: %s", username, err)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
mxid := fmt.Sprintf("@%s:%s", username, config.MatrixDomain)
|
||||
mx.ProfileDisplayname(mxid, fmt.Sprintf("%s (%s)", userId, protocol))
|
||||
|
||||
user = DbUserMap{
|
||||
Protocol: protocol,
|
||||
UserID: userId,
|
||||
MxUserID: mxid,
|
||||
}
|
||||
db.Create(&user)
|
||||
}
|
||||
|
||||
log.Tracef("%s -> %s", slot_key, user.MxUserID)
|
||||
|
||||
return user.MxUserID, nil
|
||||
}
|
||||
|
||||
func dbIsPmRoom(mxRoomId string) *DbPmRoomMap {
|
||||
var pm_room DbPmRoomMap
|
||||
if db.First(&pm_room, DbPmRoomMap{MxRoomID: mxRoomId}).RecordNotFound() {
|
||||
return nil
|
||||
} else {
|
||||
return &pm_room
|
||||
}
|
||||
}
|
||||
|
||||
func dbIsPublicRoom(mxRoomId string) *DbRoomMap {
|
||||
var room DbRoomMap
|
||||
if db.First(&room, DbRoomMap{MxRoomID: mxRoomId}).RecordNotFound() {
|
||||
return nil
|
||||
} else {
|
||||
return &room
|
||||
}
|
||||
}
|
BIN
easybridge.jpg
BIN
easybridge.jpg
Binary file not shown.
Before Width: | Height: | Size: 39 KiB |
652
external/messenger.py
vendored
652
external/messenger.py
vendored
|
@ -1,652 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import json
|
||||
import signal
|
||||
import threading
|
||||
import queue
|
||||
import pickle
|
||||
import time
|
||||
import traceback
|
||||
from urllib.parse import unquote as UrlUnquote
|
||||
|
||||
import base64
|
||||
import getpass
|
||||
import zlib
|
||||
|
||||
import fbchat
|
||||
from fbchat.models import *
|
||||
|
||||
# ---- MESSAGE TYPES ----
|
||||
|
||||
# ezbr -> external
|
||||
CONFIGURE = "configure"
|
||||
GET_USER = "get_user"
|
||||
SET_USER_INFO = "set_user_info"
|
||||
SET_ROOM_INFO = "set_room_info"
|
||||
JOIN = "join"
|
||||
INVITE = "invite"
|
||||
LEAVE = "leave"
|
||||
SEARCH = "search"
|
||||
SEND = "send"
|
||||
USER_COMMAND = "user_command"
|
||||
CLOSE = "close"
|
||||
|
||||
# external -> ezbr
|
||||
SAVE_CONFIG = "save_config"
|
||||
SYSTEM_MESSAGE = "system_message"
|
||||
JOINED = "joined"
|
||||
LEFT = "left"
|
||||
USER_INFO_UPDATED = "user_info_updated"
|
||||
ROOM_INFO_UPDATED = "room_info_updated"
|
||||
EVENT = "event"
|
||||
CACHE_PUT = "cache_put"
|
||||
CACHE_GET = "cache_get"
|
||||
|
||||
# reply messages
|
||||
# ezbr -> external: all must wait for a reply!
|
||||
# external -> ezbr: only CACHE_GET produces a reply
|
||||
REP_OK = "rep_ok"
|
||||
REP_SEARCH_RESULTS = "rep_search_results"
|
||||
REP_ERROR = "rep_error"
|
||||
|
||||
# Event types
|
||||
EVENT_JOIN = "join"
|
||||
EVENT_LEAVE = "leave"
|
||||
EVENT_MESSAGE = "message"
|
||||
EVENT_ACTION = "action"
|
||||
|
||||
|
||||
def mediaObjectOfURL(url):
|
||||
return {
|
||||
"filename": url.split("?")[0].split("/")[-1],
|
||||
"url": url,
|
||||
}
|
||||
|
||||
def stripFbLinkPrefix(url):
|
||||
PREFIX = "https://l.facebook.com/l.php?u="
|
||||
if url[:len(PREFIX)] == PREFIX:
|
||||
return UrlUnquote(url[len(PREFIX):].split('&')[0])
|
||||
else:
|
||||
return url
|
||||
|
||||
# ---- MESSENGER CLIENT CLASS THAT HANDLES EVENTS ----
|
||||
|
||||
class MessengerBridgeClient(fbchat.Client):
|
||||
def __init__(self, bridge, *args, **kwargs):
|
||||
self.bridge = bridge
|
||||
super(MessengerBridgeClient, self).__init__(*args, **kwargs)
|
||||
|
||||
def setBridge(self, bridge):
|
||||
self.bridge = bridge
|
||||
|
||||
## Redirect all interesting events to Bridge
|
||||
def onMessage(self, *args, **kwargs):
|
||||
self.bridge.onMessage(*args, **kwargs)
|
||||
def onPeopleAdded(self, *args, **kwargs):
|
||||
self.bridge.onPeopleAdded(*args, **kwargs)
|
||||
def onPersonRemoved(self, *args, **kwargs):
|
||||
self.bridge.onPersonRemoved(*args, **kwargs)
|
||||
def onTitleChange(self, *args, **kwargs):
|
||||
self.bridge.onTitleChange(*args, **kwargs)
|
||||
def on2FACode(self, *args, **kwargs):
|
||||
return self.bridge.on2FACode(*args, **kwargs)
|
||||
|
||||
# ---- SEPARATE THREADS FOR INITIAL SYNC & CLIENT LISTEN ----
|
||||
|
||||
class LoginThread(threading.Thread):
|
||||
def __init__(self, bridge, *args, **kwargs):
|
||||
super(LoginThread, self).__init__(*args, **kwargs)
|
||||
|
||||
self.bridge = bridge
|
||||
|
||||
def run(self):
|
||||
self.bridge.processLogin()
|
||||
|
||||
class SyncerThread(threading.Thread):
|
||||
def __init__(self, bridge, thread_queue, *args, **kwargs):
|
||||
super(SyncerThread, self).__init__(*args, **kwargs)
|
||||
|
||||
self.bridge = bridge
|
||||
self.thread_queue = thread_queue
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
thread = self.thread_queue.get(block=True)
|
||||
sys.stderr.write("(python) fb thread: {}\n".format(thread))
|
||||
self.bridge.setup_joined_thread(thread)
|
||||
|
||||
|
||||
class ClientListenThread(threading.Thread):
|
||||
def __init__(self, client, *args, **kwargs):
|
||||
super(ClientListenThread, self).__init__(*args, **kwargs)
|
||||
|
||||
self.client = client
|
||||
|
||||
def run(self):
|
||||
sys.stderr.write("(python messenger) Start client.listen()\n")
|
||||
self.client.listen()
|
||||
|
||||
|
||||
# ---- MAIN LOOP THAT HANDLES REQUESTS FROM BRIDGE ----
|
||||
|
||||
class MessengerBridge:
|
||||
def __init__(self):
|
||||
self.init_backlog_length = 100
|
||||
|
||||
self.config = None
|
||||
self.login_in_progress = None
|
||||
|
||||
# We cache maps between two kinds of identifiers:
|
||||
# - facebook uids of users
|
||||
# - identifiers for the bridge, which are the username when defined (otherwise equal to above)
|
||||
# Generally speaking, the first is referred to as uid whereas the second is just id
|
||||
# THESE MAPS SHOULD NOT BE USED DIRECTLY, instead functions getUserId, getUserIdFromUid and revUserId should be used
|
||||
self.uid_map = {} # map from fb user uid to bridge id
|
||||
self.rev_uid = {} # map fro bridge id to fb user uid
|
||||
|
||||
# caches the room we (the user of the bridge) have joined (map keys = room uid)
|
||||
self.my_joined_rooms = {}
|
||||
|
||||
# caches for the people that are in rooms so that we don't send JOINED every time (map keys = "<userId>--<threadId>")
|
||||
self.others_joined_map = {}
|
||||
|
||||
# queue for thread syncing
|
||||
self.sync_thread_queue = queue.Queue(100)
|
||||
|
||||
def getUserId(self, user):
|
||||
retval = None
|
||||
if user.url is not None and not "?" in user.url:
|
||||
retval = user.url.split("/")[-1]
|
||||
else:
|
||||
retval = user.uid
|
||||
|
||||
if user.uid not in self.uid_map:
|
||||
self.uid_map[user.uid] = retval
|
||||
self.rev_uid[retval] = user.uid
|
||||
|
||||
user_info = {
|
||||
"display_name": user.name,
|
||||
}
|
||||
if user.photo is not None:
|
||||
user_info["avatar"] = mediaObjectOfURL(user.photo)
|
||||
self.write({
|
||||
"_type": USER_INFO_UPDATED,
|
||||
"user": retval,
|
||||
"data": user_info,
|
||||
})
|
||||
|
||||
return retval
|
||||
|
||||
def getUserIdFromUid(self, uid):
|
||||
if uid in self.uid_map:
|
||||
return self.uid_map[uid]
|
||||
else:
|
||||
user = self.client.fetchUserInfo(uid)[uid]
|
||||
return self.getUserId(user)
|
||||
|
||||
def revUserId(self, user_id):
|
||||
if user_id not in self.rev_uid:
|
||||
for user in self.client.searchForUsers(user_id):
|
||||
self.getUserId(user)
|
||||
|
||||
if user_id not in self.rev_uid:
|
||||
raise ValueError("User not found: {}".format(user_id))
|
||||
|
||||
return self.rev_uid[user_id]
|
||||
|
||||
def getUserShortName(self, user):
|
||||
if user.first_name != None:
|
||||
return user.first_name
|
||||
else:
|
||||
return user.name
|
||||
|
||||
def run(self):
|
||||
self.client = None
|
||||
self.keep_running = True
|
||||
self.cache_gets = {}
|
||||
self.num = 0
|
||||
self.my_user_id = ""
|
||||
|
||||
while self.keep_running:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
except KeyboardInterrupt:
|
||||
sys.stderr.write("(python messenger) shutting down")
|
||||
self.close()
|
||||
break
|
||||
|
||||
sys.stderr.write("(python) reading {}\n".format(line.strip()))
|
||||
cmd = json.loads(line)
|
||||
|
||||
try:
|
||||
rep = self.handle_cmd(cmd)
|
||||
if rep is None:
|
||||
rep = {}
|
||||
if "_type" not in rep:
|
||||
rep["_type"] = REP_OK
|
||||
except Exception as e:
|
||||
sys.stderr.write("(python) {}\n".format(traceback.format_exc()))
|
||||
rep = {
|
||||
"_type": REP_ERROR,
|
||||
"error": "{}".format(e)
|
||||
}
|
||||
|
||||
rep["_id"] = cmd["_id"]
|
||||
self.write(rep)
|
||||
|
||||
def write(self, msg):
|
||||
msgstr = json.dumps(msg)
|
||||
sys.stderr.write("(python) writing {}\n".format(msgstr))
|
||||
sys.stdout.write(msgstr + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def system_message(self, msg):
|
||||
self.write({
|
||||
"_type": SYSTEM_MESSAGE,
|
||||
"value": msg,
|
||||
})
|
||||
|
||||
def handle_cmd(self, cmd):
|
||||
ty = cmd["_type"]
|
||||
if ty == CONFIGURE:
|
||||
if self.login_in_progress is None:
|
||||
self.config = cmd["data"]
|
||||
self.login_in_progress = queue.Queue(1)
|
||||
LoginThread(self).start()
|
||||
else:
|
||||
return {"_type": REP_ERROR, "error": "Already logging in (CONFIGURE sent twice)"}
|
||||
|
||||
elif ty == CLOSE:
|
||||
self.close()
|
||||
|
||||
elif ty == GET_USER:
|
||||
return {"_type": REP_OK, "user": self.my_user_id}
|
||||
|
||||
elif ty == JOIN:
|
||||
if self.client is None:
|
||||
pass
|
||||
else:
|
||||
self.ensure_i_joined(cmd["room"])
|
||||
|
||||
elif ty == LEAVE:
|
||||
thread_id = cmd["room"]
|
||||
self.client.removeUserFromGroup(self.client.uid, thread_id)
|
||||
if thread_id in self.my_joined_rooms:
|
||||
del self.my_joined_rooms[thread_id]
|
||||
|
||||
elif ty == INVITE:
|
||||
if cmd["room"] != "":
|
||||
uid = self.revUserId(cmd["user"])
|
||||
self.client.addUsersToGroup([uid], cmd["room"])
|
||||
|
||||
elif ty == SEARCH:
|
||||
users = self.client.searchForUsers(cmd["data"])
|
||||
rep = []
|
||||
for user in users:
|
||||
rep.append({
|
||||
"id": self.getUserId(user),
|
||||
"display_name": user.name,
|
||||
})
|
||||
return {"_type": REP_SEARCH_RESULTS, "data": rep}
|
||||
|
||||
elif ty == SEND:
|
||||
event = cmd["data"]
|
||||
if event["type"] in [EVENT_MESSAGE, EVENT_ACTION]:
|
||||
attachments = []
|
||||
if "attachments" in event and isinstance(event["attachments"], list):
|
||||
for at in event["attachments"]:
|
||||
if "url" in at:
|
||||
attachments.append(at["url"])
|
||||
else:
|
||||
# TODO
|
||||
sys.stdout.write("Unhandled: attachment without URL")
|
||||
|
||||
msg = Message(event["text"])
|
||||
if event["type"] == EVENT_ACTION:
|
||||
msg.text = "* " + event["text"]
|
||||
|
||||
if event["room"] != "":
|
||||
if len(attachments) > 0:
|
||||
msg_id = self.client.sendRemoteFiles(attachments, message=msg, thread_id=event["room"], thread_type=ThreadType.GROUP)
|
||||
else:
|
||||
msg_id = self.client.send(msg, thread_id=event["room"], thread_type=ThreadType.GROUP)
|
||||
elif event["recipient"] != "":
|
||||
uid = self.revUserId(event["recipient"])
|
||||
sys.stderr.write("(python) Sending to {}\n".format(uid))
|
||||
if len(attachments) > 0:
|
||||
msg_id = self.client.sendRemoteFiles(attachments, message=msg, thread_id=uid, thread_type=ThreadType.USER)
|
||||
else:
|
||||
msg_id = self.client.send(msg, thread_id=uid, thread_type=ThreadType.USER)
|
||||
else:
|
||||
return {"_type": REP_ERROR, "error": "Invalid message"}
|
||||
|
||||
return {"_type": REP_OK, "event_id": msg_id}
|
||||
|
||||
elif ty == REP_OK and cmd["_id"] in self.cache_gets:
|
||||
self.cache_gets[cmd["_id"]].put(cmd["value"])
|
||||
|
||||
elif ty == USER_COMMAND:
|
||||
self.handleUserCommand(cmd["value"])
|
||||
|
||||
else:
|
||||
return {"_type": REP_ERROR, "error": "Not implemented"}
|
||||
|
||||
def close(self):
|
||||
self.keep_running = False
|
||||
self.client.stopListening()
|
||||
|
||||
def cache_get(self, key):
|
||||
self.num += 1
|
||||
num = self.num
|
||||
q = queue.Queue(1)
|
||||
self.cache_gets[num] = q
|
||||
self.write({"_type": CACHE_GET, "_id": num, "key": key})
|
||||
try:
|
||||
rep = q.get(block=True, timeout=30)
|
||||
except queue.Empty:
|
||||
rep = ""
|
||||
del self.cache_gets[num]
|
||||
return rep
|
||||
|
||||
def cache_put(self, key, value):
|
||||
self.write({"_type": CACHE_PUT, "key": key, "value": value})
|
||||
|
||||
# ---- Process login (called from separate thread) ----
|
||||
|
||||
def processLogin(self):
|
||||
self.init_backlog_length = int(self.config["initial_backlog"])
|
||||
|
||||
has_pickle = "client_pickle" in self.config and len(self.config["client_pickle"]) > 0
|
||||
if has_pickle:
|
||||
data = base64.b64decode(self.config["client_pickle"])
|
||||
data = zlib.decompress(data)
|
||||
self.client = pickle.loads(data)
|
||||
else:
|
||||
email, password = self.config["email"], self.config["password"]
|
||||
self.client = MessengerBridgeClient(bridge=self, email=email, password=password, max_tries=1)
|
||||
|
||||
if not self.client.isLoggedIn():
|
||||
self.system_message("Unable to login (invalid pickle? dunno)")
|
||||
else:
|
||||
self.system_message("Login complete, will now sync threads.")
|
||||
|
||||
if not has_pickle:
|
||||
self.client.setBridge(None)
|
||||
data = pickle.dumps(self.client)
|
||||
data = zlib.compress(data)
|
||||
self.config["client_pickle"] = base64.b64encode(data).decode('ascii')
|
||||
self.write({"_type": SAVE_CONFIG, "data": self.config})
|
||||
|
||||
self.client.setBridge(self)
|
||||
|
||||
self.my_user_id = self.getUserIdFromUid(self.client.uid)
|
||||
|
||||
threads = self.client.fetchThreadList(limit=10)
|
||||
# ensure we have a correct mapping for bridged user IDs to fb uids
|
||||
# (this should be fast)
|
||||
for thread in threads:
|
||||
if thread.type == ThreadType.USER:
|
||||
self.getUserId(thread)
|
||||
|
||||
SyncerThread(self, self.sync_thread_queue).start()
|
||||
for thread in reversed(threads):
|
||||
self.sync_thread_queue.put(thread)
|
||||
|
||||
ClientListenThread(self.client).start()
|
||||
|
||||
self.login_in_progress = None
|
||||
|
||||
# ---- Info sync ----
|
||||
|
||||
def ensure_i_joined(self, thread_id):
|
||||
if thread_id not in self.my_joined_rooms:
|
||||
self.my_joined_rooms[thread_id] = True
|
||||
|
||||
thread = self.client.fetchThreadInfo(thread_id)[thread_id]
|
||||
self.sync_thread_queue.put(thread)
|
||||
|
||||
def setup_joined_thread(self, thread):
|
||||
sys.stderr.write("(python) setup_joined_thread {}".format(thread))
|
||||
if thread.type == ThreadType.GROUP:
|
||||
members = self.client.fetchAllUsersFromThreads([thread])
|
||||
|
||||
self.write({
|
||||
"_type": JOINED,
|
||||
"room": thread.uid,
|
||||
})
|
||||
|
||||
self.send_room_info(thread, members)
|
||||
self.send_room_members(thread, members)
|
||||
|
||||
self.backlog_room(thread)
|
||||
|
||||
|
||||
def send_room_info(self, thread, members):
|
||||
members.sort(key=lambda m: m.uid)
|
||||
|
||||
room_info = {}
|
||||
if thread.name is not None:
|
||||
room_info["name"] = thread.name
|
||||
else:
|
||||
who = [m for m in members if m.uid != self.client.uid]
|
||||
if len(who) > 3:
|
||||
room_info["name"] = ", ".join([self.getUserShortName(m) for m in who[:3]] + ["..."])
|
||||
else:
|
||||
room_info["name"] = ", ".join([self.getUserShortName(m) for m in who])
|
||||
|
||||
if thread.photo is not None:
|
||||
room_info["picture"] = mediaObjectOfURL(thread.photo)
|
||||
else:
|
||||
for m in members:
|
||||
if m.uid != self.client.uid and m.photo is not None:
|
||||
room_info["picture"] = mediaObjectOfURL(m.photo)
|
||||
break
|
||||
|
||||
self.write({
|
||||
"_type": ROOM_INFO_UPDATED,
|
||||
"room": thread.uid,
|
||||
"data": room_info,
|
||||
})
|
||||
|
||||
def send_room_members(self, thread, members):
|
||||
for member in members:
|
||||
sys.stderr.write("(python) fb thread member: {}\n".format(member))
|
||||
self.ensureJoined(self.getUserId(member), thread.uid)
|
||||
|
||||
def backlog_room(self, thread):
|
||||
prev_last_seen = self.cache_get("last_seen_%s"%thread.uid)
|
||||
if prev_last_seen == "":
|
||||
prev_last_seen = None
|
||||
|
||||
messages = []
|
||||
found = False
|
||||
while not found:
|
||||
before = None
|
||||
if len(messages) > 0:
|
||||
before = messages[-1].timestamp
|
||||
page = self.client.fetchThreadMessages(thread.uid, before=before, limit=20)
|
||||
for m in page:
|
||||
if m.uid == prev_last_seen or len(messages) > self.init_backlog_length:
|
||||
found = True
|
||||
break
|
||||
else:
|
||||
messages.append(m)
|
||||
|
||||
for m in reversed(messages):
|
||||
if m.text is None:
|
||||
m.text = ""
|
||||
m.text = "[{}] {}".format(
|
||||
time.strftime("%Y-%m-%d %H:%M %Z", time.localtime(float(m.timestamp)/1000)).strip(),
|
||||
m.text)
|
||||
self.onMessage(thread_id=thread.uid,
|
||||
thread_type=thread.type,
|
||||
message_object=m)
|
||||
|
||||
def ensureJoined(self, userId, room):
|
||||
key = "{}--{}".format(userId, room)
|
||||
if not key in self.others_joined_map:
|
||||
self.write({
|
||||
"_type": EVENT,
|
||||
"data": {
|
||||
"type": EVENT_JOIN,
|
||||
"author": userId,
|
||||
"room": room,
|
||||
}
|
||||
})
|
||||
self.others_joined_map[key] = True
|
||||
|
||||
# ---- Event handlers ----
|
||||
|
||||
def onMessage(self, thread_id, thread_type, message_object, **kwargs):
|
||||
self.ensure_i_joined(thread_id)
|
||||
|
||||
if message_object.author == self.client.uid:
|
||||
# Ignore our own messages
|
||||
return
|
||||
|
||||
sys.stderr.write("(python messenger) Got message: {}\n".format(message_object))
|
||||
|
||||
author = self.getUserIdFromUid(message_object.author)
|
||||
|
||||
event = {
|
||||
"id": message_object.uid,
|
||||
"type": EVENT_MESSAGE,
|
||||
"author": author,
|
||||
"text": message_object.text,
|
||||
"attachments": []
|
||||
}
|
||||
if event["text"] is None:
|
||||
event["text"] = ""
|
||||
|
||||
for at in message_object.attachments:
|
||||
if isinstance(at, ImageAttachment):
|
||||
try:
|
||||
full_url = self.client.fetchImageUrl(at.uid)
|
||||
except:
|
||||
time.sleep(1)
|
||||
full_url = self.client.fetchImageUrl(at.uid)
|
||||
event["attachments"].append({
|
||||
"filename": full_url.split("?")[0].split("/")[-1],
|
||||
"url": full_url,
|
||||
"image_size": {
|
||||
"width": at.width,
|
||||
"height": at.height,
|
||||
},
|
||||
})
|
||||
elif isinstance(at, FileAttachment):
|
||||
url = stripFbLinkPrefix(at.url)
|
||||
event["attachments"].append({
|
||||
"filename": at.name,
|
||||
"url": url,
|
||||
})
|
||||
elif isinstance(at, AudioAttachment):
|
||||
url = stripFbLinkPrefix(at.url)
|
||||
event["attachments"].append({
|
||||
"filename": at.filename,
|
||||
"url": url,
|
||||
})
|
||||
elif isinstance(at, ShareAttachment):
|
||||
event["text"] += "\n{}\n{}".format(at.description, at.url)
|
||||
else:
|
||||
event["text"] += "\nUnhandled attachment: {}".format(at)
|
||||
|
||||
if isinstance(message_object.sticker, Sticker):
|
||||
stk = message_object.sticker
|
||||
event["attachments"].append({
|
||||
"filename": stk.label,
|
||||
"url": stk.url,
|
||||
"image_size": {
|
||||
"width": stk.width,
|
||||
"height": stk.height,
|
||||
},
|
||||
})
|
||||
|
||||
if thread_type == ThreadType.GROUP:
|
||||
event["room"] = thread_id
|
||||
self.ensureJoined(author, thread_id)
|
||||
|
||||
if event["text"] != "" or len(event["attachments"]) > 0:
|
||||
self.write({"_type": EVENT, "data": event})
|
||||
|
||||
self.cache_put("last_seen_%s"%thread_id, message_object.uid)
|
||||
|
||||
def onPeopleAdded(self, added_ids, thread_id, *args, **kwargs):
|
||||
for user_id in added_ids:
|
||||
if user_id == self.client.uid:
|
||||
self.ensure_i_joined(thread_id)
|
||||
else:
|
||||
self.ensureJoined(self.getUserIdFromUid(user_id), thread_id)
|
||||
|
||||
def onPersonRemoved(self, removed_id, thread_id, *args, **kwargs):
|
||||
if removed_id == self.client.uid:
|
||||
self.write({
|
||||
"_type": LEFT,
|
||||
"room": thread_id,
|
||||
})
|
||||
if thread_id in self.my_joined_rooms:
|
||||
del self.my_joined_rooms[thread_id]
|
||||
else:
|
||||
userId = self.getUserIdFromUid(removed_id),
|
||||
self.write({
|
||||
"_type": EVENT,
|
||||
"data": {
|
||||
"type": EVENT_JOIN,
|
||||
"author": userId,
|
||||
"room": thread_id,
|
||||
}
|
||||
})
|
||||
map_key = "{}--{}".format(userId, thread_id)
|
||||
if map_key in self.others_joined_map:
|
||||
del self.others_joined_map[map_key]
|
||||
|
||||
def onTitleChange(self, author_id, new_title, thread_id, thread_type, *args, **kwargs):
|
||||
self.ensure_i_joined(thread_id)
|
||||
if thread_type == ThreadType.GROUP:
|
||||
self.write({
|
||||
"_type": ROOM_INFO_UPDATED,
|
||||
"room": thread_id,
|
||||
"data": {"name": new_title},
|
||||
})
|
||||
|
||||
def on2FACode(self, *args, **kwargs):
|
||||
if self.login_in_progress is None:
|
||||
self.system_message("Facebook messenger requests 2 factor authentication, but we have a bug so that won't work.")
|
||||
return None
|
||||
else:
|
||||
self.system_message("Facebook messenger requests 2 factor authentication. Enter it by saying: cmd messenger 2fa <your code> (replace messenger by your account name if you have several messenger accounts)")
|
||||
uc = self.login_in_progress.get(block=True)
|
||||
return uc["2fa_code"]
|
||||
|
||||
def handleUserCommand(self, cmd):
|
||||
cmd = cmd.split(' ')
|
||||
if cmd[0] == "2fa":
|
||||
if self.login_in_progress is not None:
|
||||
self.login_in_progress.put({"2fa_code": cmd[1]})
|
||||
else:
|
||||
self.system_message("2FA code not required at this point.")
|
||||
else:
|
||||
self.system_message("Invalid user command.")
|
||||
|
||||
# ---- CLI ----
|
||||
|
||||
def createClientPickle():
|
||||
email = input("Email address of Facebook account: ")
|
||||
password = getpass.getpass()
|
||||
client = MessengerBridgeClient(email, password, max_tries=1)
|
||||
if not client.isLoggedIn():
|
||||
print("Could not log in (why???)")
|
||||
print("Still creating pickle though, maybe it will work after login was authorized?")
|
||||
print("")
|
||||
data = pickle.dumps(client)
|
||||
data = zlib.compress(data)
|
||||
data = base64.b64encode(data).decode('ascii')
|
||||
print(data)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "create_client_pickle" in sys.argv:
|
||||
createClientPickle()
|
||||
else:
|
||||
bridge = MessengerBridge()
|
||||
bridge.run()
|
||||
|
61
go.mod
61
go.mod
|
@ -3,58 +3,15 @@ module git.deuxfleurs.fr/Deuxfleurs/easybridge
|
|||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/42wim/matterbridge v1.18.3
|
||||
github.com/blang/semver v3.5.1+incompatible // indirect
|
||||
github.com/bwmarrin/discordgo v0.20.2 // indirect
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20200910202707-1e08a3fab204 // indirect
|
||||
github.com/dfordsoft/golib v0.0.0-20180902042739-76ee6ab99bec // indirect
|
||||
github.com/dyatlov/go-opengraph v0.0.0-20180429202543-816b6608b3c8 // indirect
|
||||
github.com/go-ldap/ldap v3.0.3+incompatible // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.1.7
|
||||
github.com/go-redis/redis v6.15.9+incompatible // indirect
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20181225215658-ec221ba9ea45+incompatible // indirect
|
||||
github.com/google/go-cmp v0.5.2 // indirect
|
||||
github.com/google/uuid v1.1.2 // indirect
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4
|
||||
github.com/jinzhu/gorm v1.9.16
|
||||
github.com/jinzhu/now v1.1.1 // indirect
|
||||
github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 // indirect
|
||||
github.com/kr/pretty v0.2.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/labstack/echo v3.3.10+incompatible // indirect
|
||||
github.com/lib/pq v1.8.0 // indirect
|
||||
git.deuxfleurs.fr/lx/go-xmpp v0.0.0-20200217161715-21c9a1d8b8fd
|
||||
git.deuxfleurs.fr/lx/gxmpp v0.0.0-20200217161715-21c9a1d8b8fd
|
||||
github.com/gorilla/mux v1.7.4
|
||||
github.com/jinzhu/gorm v1.9.12
|
||||
github.com/lrstanley/girc v0.0.0-20190801035559-4fc93959e1a7
|
||||
github.com/matterbridge/go-xmpp v0.0.0-20200418225040-c8a3a57b4050
|
||||
github.com/matterbridge/gomatrix v0.0.0-20191026211822-6fc7accd00ca // indirect
|
||||
github.com/mattermost/logr v1.0.13 // indirect
|
||||
github.com/mattermost/mattermost-server v5.11.1+incompatible
|
||||
github.com/mattermost/mattermost-server/v5 v5.27.0
|
||||
github.com/matterbridge/go-xmpp v0.0.0-20180131083630-7ec2b8b7def6
|
||||
github.com/mattn/go-gtk v0.0.0-20191030024613-af2e013261f5
|
||||
github.com/mattn/go-pointer v0.0.0-20190911064623-a0a44394634f // indirect
|
||||
github.com/mattn/go-xmpp v0.0.0-20200128155807-a86b6abcb3ad
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/nelsam/hel/v2 v2.3.3 // indirect
|
||||
github.com/nicksnyder/go-i18n v1.10.1 // indirect
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/nlopes/slack v0.6.0 // indirect
|
||||
github.com/nxadm/tail v1.4.5 // indirect
|
||||
github.com/onsi/ginkgo v1.14.1 // indirect
|
||||
github.com/onsi/gomega v1.10.2 // indirect
|
||||
github.com/pborman/uuid v1.2.1 // indirect
|
||||
github.com/pelletier/go-toml v1.8.1 // indirect
|
||||
github.com/poy/onpar v1.0.1 // indirect
|
||||
github.com/rs/xid v1.2.1
|
||||
github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4 // indirect
|
||||
github.com/sirupsen/logrus v1.7.0
|
||||
github.com/stretchr/objx v0.3.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/zap v1.16.0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0
|
||||
golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c // indirect
|
||||
golang.org/x/tools v0.0.0-20201002184944-ecd9fd270d5d // indirect
|
||||
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
|
||||
gopkg.in/yaml.v2 v2.3.0
|
||||
honnef.co/go/tools v0.0.1-2020.1.5 // indirect
|
||||
github.com/sirupsen/logrus v1.4.2
|
||||
gopkg.in/yaml.v2 v2.2.8
|
||||
)
|
||||
|
|
150
main.go
150
main.go
|
@ -1,39 +1,41 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
_"strings"
|
||||
_ "time"
|
||||
_ "fmt"
|
||||
"encoding/json"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/appservice"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector/irc"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector/xmpp"
|
||||
)
|
||||
|
||||
type ConfigAccount struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Rooms []string `json:"rooms"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
|
||||
type ConfigFile struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
|
||||
HttpBindAddr string`json:"http_bind_addr"`
|
||||
Registration string `json:"registration"`
|
||||
ASBindAddr string `json:"appservice_bind_addr"`
|
||||
Server string `json:"homeserver_url"`
|
||||
MatrixDomain string `json:"matrix_domain"`
|
||||
NameFormat string `json:"name_format"`
|
||||
|
||||
WebBindAddr string `json:"web_bind_addr"`
|
||||
WebURL string `json:"web_url"`
|
||||
SessionKey string `json:"web_session_key"`
|
||||
|
||||
Server string `json:"homeserver_url"`
|
||||
DbType string `json:"db_type"`
|
||||
DbPath string `json:"db_path"`
|
||||
|
||||
AvatarFile string `json:"easybridge_avatar"`
|
||||
MatrixDomain string `json:"matrix_domain"`
|
||||
Accounts map[string]map[string]ConfigAccount `json:"accounts"`
|
||||
}
|
||||
|
||||
var configFlag = flag.String("config", "./config.json", "Configuration file path")
|
||||
|
@ -42,20 +44,13 @@ var config *ConfigFile
|
|||
var registration *mxlib.Registration
|
||||
|
||||
func readConfig() ConfigFile {
|
||||
defaultKey := make([]byte, 32)
|
||||
rand.Read(defaultKey)
|
||||
|
||||
config_file := ConfigFile{
|
||||
LogLevel: "info",
|
||||
ASBindAddr: "0.0.0.0:8321",
|
||||
WebBindAddr: "0.0.0.0:8281",
|
||||
HttpBindAddr: "0.0.0.0:8321",
|
||||
Registration: "./registration.yaml",
|
||||
Server: "http://localhost:8008",
|
||||
NameFormat: "{}_ezbr_",
|
||||
DbType: "sqlite3",
|
||||
DbPath: "easybridge.db",
|
||||
AvatarFile: "./easybridge.jpg",
|
||||
SessionKey: hex.EncodeToString(defaultKey),
|
||||
Server: "http://localhost:8008",
|
||||
DbType: "sqlite3",
|
||||
DbPath: "easybridge.db",
|
||||
Accounts: map[string]map[string]ConfigAccount{},
|
||||
}
|
||||
|
||||
_, err := os.Stat(*configFlag)
|
||||
|
@ -101,23 +96,22 @@ func readRegistration(file string) mxlib.Registration {
|
|||
}
|
||||
|
||||
reg := mxlib.Registration{
|
||||
Id: "Easybridge",
|
||||
Url: "http://localhost:8321",
|
||||
AsToken: hex.EncodeToString(rnd[:32]),
|
||||
HsToken: hex.EncodeToString(rnd[32:]),
|
||||
Id: "Easybridge",
|
||||
Url: "http://localhost:8321",
|
||||
AsToken: hex.EncodeToString(rnd[:32]),
|
||||
HsToken: hex.EncodeToString(rnd[32:]),
|
||||
SenderLocalpart: "_ezbr_",
|
||||
RateLimited: false,
|
||||
Namespaces: mxlib.RegistrationNamespaceSet{
|
||||
Users: []mxlib.RegistrationNamespace{
|
||||
mxlib.RegistrationNamespace{
|
||||
Exclusive: true,
|
||||
Regex: "@.*_ezbr_",
|
||||
Regex: "@_ezbr_.*",
|
||||
},
|
||||
},
|
||||
Aliases: []mxlib.RegistrationNamespace{
|
||||
mxlib.RegistrationNamespace{
|
||||
Exclusive: true,
|
||||
Regex: "#.*_ezbr_",
|
||||
Regex: "#_ezbr_.*",
|
||||
},
|
||||
},
|
||||
Rooms: []mxlib.RegistrationNamespace{},
|
||||
|
@ -160,51 +154,71 @@ func readRegistration(file string) mxlib.Registration {
|
|||
}
|
||||
|
||||
func main() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Read configuration
|
||||
config_file := readConfig()
|
||||
config = &config_file
|
||||
|
||||
log_level, err := log.ParseLevel(config.LogLevel)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.SetLevel(log_level)
|
||||
|
||||
reg_file := readRegistration(config.Registration)
|
||||
registration = ®_file
|
||||
|
||||
// Create context and handlers for errors and signals
|
||||
ctx, stop_all := context.WithCancel(context.Background())
|
||||
errch := make(chan error)
|
||||
sigch := make(chan os.Signal)
|
||||
signal.Notify(sigch, os.Interrupt, syscall.SIGTERM)
|
||||
defer func() {
|
||||
signal.Stop(sigch)
|
||||
stop_all()
|
||||
}()
|
||||
as_config := &appservice.Config{
|
||||
HttpBindAddr: config.HttpBindAddr,
|
||||
Server: config.Server,
|
||||
DbType: config.DbType,
|
||||
DbPath: config.DbPath,
|
||||
MatrixDomain: config.MatrixDomain,
|
||||
}
|
||||
|
||||
// Start appservice and web server
|
||||
_, err = StartAppService(errch, ctx)
|
||||
errch, err := appservice.Start(registration, as_config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
_ = StartWeb(errch, ctx)
|
||||
|
||||
// Wait for an error somewhere or interrupt signal
|
||||
select {
|
||||
case err = <-errch:
|
||||
log.Error(err)
|
||||
stop_all()
|
||||
case sig := <-sigch:
|
||||
log.Warnf("Got signal: %s", sig.String())
|
||||
stop_all()
|
||||
case <-ctx.Done():
|
||||
for user, accounts := range config.Accounts {
|
||||
for name, params := range accounts {
|
||||
var conn connector.Connector
|
||||
switch params.Protocol {
|
||||
case "irc":
|
||||
conn = &irc.IRC{}
|
||||
case "xmpp":
|
||||
conn = &xmpp.XMPP{}
|
||||
default:
|
||||
log.Fatalf("Invalid protocol %s", params.Protocol)
|
||||
}
|
||||
account := &appservice.Account{
|
||||
MatrixUser: fmt.Sprintf("@%s:%s", user, config.MatrixDomain),
|
||||
AccountName: name,
|
||||
Protocol: params.Protocol,
|
||||
Conn: conn,
|
||||
JoinedRooms: map[connector.RoomID]bool{},
|
||||
}
|
||||
conn.SetHandler(account)
|
||||
appservice.AddAccount(account)
|
||||
go connectAndJoin(conn, params)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Closing all account connections...")
|
||||
CloseAllAccountsForShutdown()
|
||||
log.Info("Exiting.")
|
||||
err = <-errch
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func connectAndJoin(conn connector.Connector, params ConfigAccount) {
|
||||
log.Printf("Connecting to %s", params.Protocol)
|
||||
err := conn.Configure(params.Config)
|
||||
if err != nil {
|
||||
log.Printf("Could not connect to %s: %s", params.Protocol, err)
|
||||
} else {
|
||||
log.Printf("Connected to %s, now joining %#v", params.Protocol, params.Rooms)
|
||||
for _, room := range params.Rooms {
|
||||
err := conn.Join(connector.RoomID(room))
|
||||
if err != nil {
|
||||
log.Printf("Could not join %s: %s", room, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
73
mxlib/api.go
73
mxlib/api.go
|
@ -6,7 +6,7 @@ import (
|
|||
|
||||
type MxError struct {
|
||||
ErrCode string `json:"errcode"`
|
||||
ErrMsg string `json:"error"`
|
||||
ErrMsg string `json:"error"`
|
||||
}
|
||||
|
||||
func (e *MxError) Error() string {
|
||||
|
@ -18,41 +18,22 @@ type Transaction struct {
|
|||
}
|
||||
|
||||
type Event struct {
|
||||
Content map[string]interface{} `json:"content"`
|
||||
Type string `json:"type"`
|
||||
EventId string `json:"event_id"`
|
||||
RoomId string `json:"room_id"`
|
||||
Sender string `json:"sender"`
|
||||
OriginServerTs int `json:"origin_server_ts"`
|
||||
}
|
||||
|
||||
type PasswordLoginRequest struct {
|
||||
Type string `json:"type"`
|
||||
Identifier map[string]string `json:"identifier"`
|
||||
Password string `json:"password"`
|
||||
DeviceID string `json:"device_id"`
|
||||
InitialDeviceDisplayNAme string `json:"initial_device_display_name"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Content map[string]interface{} `json:"content"`
|
||||
Type string `json:"type"`
|
||||
EventId string `json:"event_id"`
|
||||
RoomId string `json:"room_id"`
|
||||
Sender string `json:"sender"`
|
||||
OriginServerTs int `json:"origin_server_ts"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Auth RegisterRequestAuth `json:"auth"`
|
||||
Type string `json:"type"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type RegisterRequestAuth struct {
|
||||
Type string `json:"type"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
UserId string `json:"user_id"`
|
||||
UserId string `json:"user_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
DeviceId string `json:"device_id"`
|
||||
DeviceId string `json:"device_id"`
|
||||
}
|
||||
|
||||
type ProfileDisplaynameRequest struct {
|
||||
|
@ -60,22 +41,23 @@ type ProfileDisplaynameRequest struct {
|
|||
}
|
||||
|
||||
type CreateRoomRequest struct {
|
||||
Preset string `json:"preset"`
|
||||
RoomAliasName string `json:"room_alias_name"`
|
||||
Name string `json:"name"`
|
||||
Topic string `json:"topic"`
|
||||
Invite []string `json:"invite"`
|
||||
Preset string `json:"preset"`
|
||||
RoomAliasName string `json:"room_alias_name"`
|
||||
Name string `json:"name"`
|
||||
Topic string `json:"topic"`
|
||||
Invite []string `json:"invite"`
|
||||
CreationContent map[string]interface{} `json:"creation_content"`
|
||||
PowerLevels map[string]interface{} `json:"power_level_content_override"`
|
||||
PowerLevels map[string]interface{} `json:"power_level_content_override"`
|
||||
}
|
||||
|
||||
type CreateDirectRoomRequest struct {
|
||||
Preset string `json:"preset"`
|
||||
Topic string `json:"topic"`
|
||||
Invite []string `json:"invite"`
|
||||
type CreateRoomNoAliasRequest struct {
|
||||
Preset string `json:"preset"`
|
||||
Name string `json:"name"`
|
||||
Topic string `json:"topic"`
|
||||
Invite []string `json:"invite"`
|
||||
CreationContent map[string]interface{} `json:"creation_content"`
|
||||
PowerLevels map[string]interface{} `json:"power_level_content_override"`
|
||||
IsDirect bool `json:"is_direct"`
|
||||
PowerLevels map[string]interface{} `json:"power_level_content_override"`
|
||||
IsDirect bool `json:"is_direct"`
|
||||
}
|
||||
|
||||
type CreateRoomResponse struct {
|
||||
|
@ -83,7 +65,7 @@ type CreateRoomResponse struct {
|
|||
}
|
||||
|
||||
type DirectoryRoomResponse struct {
|
||||
RoomId string `json:"room_id"`
|
||||
RoomId string `json:"room_id"`
|
||||
Servers []string `json:"string"`
|
||||
}
|
||||
|
||||
|
@ -103,10 +85,3 @@ type RoomSendResponse struct {
|
|||
EventId string `json:"event_id"`
|
||||
}
|
||||
|
||||
type UploadResponse struct {
|
||||
ContentUri string `json:"content_uri"`
|
||||
}
|
||||
|
||||
type ProfileAvatarUrl struct {
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
}
|
||||
|
|
404
mxlib/client.go
404
mxlib/client.go
|
@ -1,404 +0,0 @@
|
|||
package mxlib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Server string
|
||||
Token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(server string, token string) *Client {
|
||||
tr := &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: true,
|
||||
}
|
||||
return &Client{
|
||||
Server: server,
|
||||
Token: token,
|
||||
httpClient: &http.Client{Transport: tr},
|
||||
}
|
||||
}
|
||||
|
||||
func (mx *Client) GetApiCall(endpoint string, response interface{}) error {
|
||||
log.Debugf("Matrix GET request: %s\n", endpoint)
|
||||
|
||||
req, err := http.NewRequest("GET", mx.Server+endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mx.DoAndParse(req, response)
|
||||
}
|
||||
|
||||
func (mx *Client) PutApiCall(endpoint string, data interface{}, response interface{}) error {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("Matrix PUT request: %s\n", endpoint)
|
||||
|
||||
req, err := http.NewRequest("PUT", mx.Server+endpoint, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
return mx.DoAndParse(req, response)
|
||||
}
|
||||
|
||||
func (mx *Client) PostApiCall(endpoint string, data interface{}, response interface{}) error {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("Matrix POST request: %s\n", endpoint)
|
||||
|
||||
req, err := http.NewRequest("POST", mx.Server+endpoint, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
return mx.DoAndParse(req, response)
|
||||
}
|
||||
|
||||
func (mx *Client) DeleteApiCall(endpoint string, response interface{}) error {
|
||||
log.Debugf("Matrix DELETE request: %s\n", endpoint)
|
||||
|
||||
req, err := http.NewRequest("DELETE", mx.Server+endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mx.DoAndParse(req, response)
|
||||
}
|
||||
|
||||
func (mx *Client) DoAndParse(req *http.Request, response interface{}) error {
|
||||
if mx.Token != "" {
|
||||
req.Header.Add("Authorization", "Bearer "+mx.Token)
|
||||
}
|
||||
|
||||
resp, err := mx.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var e MxError
|
||||
err = json.NewDecoder(resp.Body).Decode(&e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debugf("Response: %d %#v\n", resp.StatusCode, e)
|
||||
return &e
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("Response: 200 OK")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func (mx *Client) PasswordLogin(username string, password string, device_id string, device_name string) (string, error) {
|
||||
req := PasswordLoginRequest{
|
||||
Type: "m.login.password",
|
||||
Identifier: map[string]string{
|
||||
"type": "m.id.user",
|
||||
"user": username,
|
||||
},
|
||||
Password: password,
|
||||
DeviceID: device_id,
|
||||
InitialDeviceDisplayNAme: device_name,
|
||||
}
|
||||
var rep LoginResponse
|
||||
err := mx.PostApiCall("/_matrix/client/r0/login", &req, &rep)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if mx.Token == "" {
|
||||
mx.Token = rep.AccessToken
|
||||
}
|
||||
return rep.UserID, nil
|
||||
}
|
||||
|
||||
func (mx *Client) RegisterUser(username string) error {
|
||||
req := RegisterRequest{
|
||||
Auth: RegisterRequestAuth{
|
||||
Type: "m.login.application_service",
|
||||
},
|
||||
Type: "m.login.application_service",
|
||||
Username: username,
|
||||
}
|
||||
var rep RegisterResponse
|
||||
return mx.PostApiCall("/_matrix/client/r0/register?kind=user", &req, &rep)
|
||||
}
|
||||
|
||||
func (mx *Client) ProfileDisplayname(userid string, displayname string) error {
|
||||
req := ProfileDisplaynameRequest{
|
||||
Displayname: displayname,
|
||||
}
|
||||
var rep struct{}
|
||||
err := mx.PutApiCall(fmt.Sprintf("/_matrix/client/r0/profile/%s/displayname?user_id=%s",
|
||||
url.QueryEscape(userid), url.QueryEscape(userid)),
|
||||
&req, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mx *Client) ProfileAvatar(userid string, m connector.MediaObject) error {
|
||||
var mxc *MediaObject
|
||||
if mxm, ok := m.(*MediaObject); ok {
|
||||
mxc = mxm
|
||||
} else {
|
||||
mxm, err := mx.UploadMedia(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mxc = mxm
|
||||
}
|
||||
|
||||
req := ProfileAvatarUrl{
|
||||
AvatarUrl: mxc.MxcUri(),
|
||||
}
|
||||
var rep struct{}
|
||||
err := mx.PutApiCall(fmt.Sprintf("/_matrix/client/r0/profile/%s/avatar_url?user_id=%s",
|
||||
url.QueryEscape(userid), url.QueryEscape(userid)),
|
||||
&req, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mx *Client) DirectoryRoom(alias string) (string, error) {
|
||||
var rep DirectoryRoomResponse
|
||||
err := mx.GetApiCall("/_matrix/client/r0/directory/room/"+url.QueryEscape(alias), &rep)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rep.RoomId, nil
|
||||
}
|
||||
|
||||
func (mx *Client) DirectoryDeleteRoom(alias string) error {
|
||||
var rep struct{}
|
||||
err := mx.DeleteApiCall("/_matrix/client/r0/directory/room/"+url.QueryEscape(alias), &rep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mx *Client) CreateRoom(name string, alias string, invite []string) (string, error) {
|
||||
rq := CreateRoomRequest{
|
||||
Preset: "private_chat",
|
||||
RoomAliasName: alias,
|
||||
Name: name,
|
||||
Topic: "",
|
||||
Invite: invite,
|
||||
CreationContent: map[string]interface{}{
|
||||
"m.federate": false,
|
||||
},
|
||||
PowerLevels: map[string]interface{}{
|
||||
"invite": 100,
|
||||
"events": map[string]interface{}{
|
||||
"m.room.topic": 0,
|
||||
"m.room.avatar": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
var rep CreateRoomResponse
|
||||
err := mx.PostApiCall("/_matrix/client/r0/createRoom", &rq, &rep)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rep.RoomId, nil
|
||||
}
|
||||
|
||||
func (mx *Client) CreateDirectRoomAs(invite []string, as_user string) (string, error) {
|
||||
rq := CreateDirectRoomRequest{
|
||||
Preset: "private_chat",
|
||||
Topic: "",
|
||||
Invite: invite,
|
||||
CreationContent: map[string]interface{}{
|
||||
"m.federate": false,
|
||||
},
|
||||
PowerLevels: map[string]interface{}{
|
||||
"invite": 100,
|
||||
},
|
||||
IsDirect: true,
|
||||
}
|
||||
var rep CreateRoomResponse
|
||||
err := mx.PostApiCall("/_matrix/client/r0/createRoom?user_id="+url.QueryEscape(as_user), &rq, &rep)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rep.RoomId, nil
|
||||
}
|
||||
|
||||
func (mx *Client) RoomInvite(room string, user string) error {
|
||||
rq := RoomInviteRequest{
|
||||
UserId: user,
|
||||
}
|
||||
var rep struct{}
|
||||
err := mx.PostApiCall("/_matrix/client/r0/rooms/"+url.QueryEscape(room)+"/invite", &rq, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mx *Client) RoomKick(room string, user string, reason string) error {
|
||||
rq := RoomKickRequest{
|
||||
UserId: user,
|
||||
Reason: reason,
|
||||
}
|
||||
var rep struct{}
|
||||
err := mx.PostApiCall("/_matrix/client/r0/rooms/"+url.QueryEscape(room)+"/kick", &rq, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mx *Client) RoomJoinAs(room string, user string) error {
|
||||
rq := struct{}{}
|
||||
var rep RoomJoinResponse
|
||||
err := mx.PostApiCall("/_matrix/client/r0/rooms/"+url.QueryEscape(room)+"/join?user_id="+url.QueryEscape(user), &rq, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mx *Client) RoomLeaveAs(room string, user string) error {
|
||||
rq := struct{}{}
|
||||
var rep struct{}
|
||||
err := mx.PostApiCall("/_matrix/client/r0/rooms/"+url.QueryEscape(room)+"/leave?user_id="+url.QueryEscape(user), &rq, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mx *Client) SendAs(room string, event_type string, content map[string]interface{}, user string) error {
|
||||
txn_id := time.Now().UnixNano()
|
||||
var rep RoomSendResponse
|
||||
err := mx.PutApiCall(fmt.Sprintf(
|
||||
"/_matrix/client/r0/rooms/%s/send/%s/%d?user_id=%s",
|
||||
url.QueryEscape(room), event_type, txn_id, url.QueryEscape(user)),
|
||||
&content, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mx *Client) SendMessageAs(room string, typ string, body string, user string) error {
|
||||
content := map[string]interface{}{
|
||||
"msgtype": typ,
|
||||
"body": body,
|
||||
}
|
||||
return mx.SendAs(room, "m.room.message", content, user)
|
||||
}
|
||||
|
||||
func (mx *Client) PutStateAs(room string, event_type string, key string, content map[string]interface{}, as_user string) error {
|
||||
var rep RoomSendResponse
|
||||
err := mx.PutApiCall(fmt.Sprintf(
|
||||
"/_matrix/client/r0/rooms/%s/state/%s/%s?user_id=%s",
|
||||
url.QueryEscape(room), event_type, key, url.QueryEscape(as_user)),
|
||||
&content, &rep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mx *Client) RoomNameAs(room string, name string, as_user string) error {
|
||||
content := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
return mx.PutStateAs(room, "m.room.name", "", content, as_user)
|
||||
}
|
||||
|
||||
func (mx *Client) RoomAvatarAs(room string, pic connector.MediaObject, as_user string) error {
|
||||
mo, err := mx.UploadMedia(pic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := map[string]interface{}{
|
||||
"url": mo.MxcUri(),
|
||||
"info": map[string]interface{}{
|
||||
"mimetype": mo.Mimetype(),
|
||||
"size": mo.Size(),
|
||||
},
|
||||
}
|
||||
return mx.PutStateAs(room, "m.room.avatar", "", content, as_user)
|
||||
}
|
||||
|
||||
func (mx *Client) RoomTopicAs(room string, topic string, as_user string) error {
|
||||
content := map[string]interface{}{
|
||||
"topic": topic,
|
||||
}
|
||||
return mx.PutStateAs(room, "m.room.topic", "", content, as_user)
|
||||
}
|
||||
|
||||
func (mx *Client) UploadMedia(m connector.MediaObject) (*MediaObject, error) {
|
||||
// Return early if this is already a Matrix media object
|
||||
if mxm, ok := m.(*MediaObject); ok {
|
||||
return mxm, nil
|
||||
}
|
||||
|
||||
reader, err := m.Read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
req, err := http.NewRequest("POST",
|
||||
mx.Server+"/_matrix/media/r0/upload?filename="+url.QueryEscape(m.Filename()),
|
||||
reader)
|
||||
req.Header.Add("Content-Type", m.Mimetype())
|
||||
req.ContentLength = m.Size() // TODO: this wasn't specified as mandatory in the matrix client/server spec, do a PR to fix this
|
||||
|
||||
var resp UploadResponse
|
||||
err = mx.DoAndParse(req, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mxc := strings.Split(strings.Replace(resp.ContentUri, "mxc://", "", 1), "/")
|
||||
if len(mxc) != 2 {
|
||||
return nil, fmt.Errorf("Invalid mxc:// returned: %s", resp.ContentUri)
|
||||
}
|
||||
|
||||
media := &MediaObject{
|
||||
mxClient: mx,
|
||||
filename: m.Filename(),
|
||||
size: m.Size(),
|
||||
mimetype: m.Mimetype(),
|
||||
imageSize: m.ImageSize(),
|
||||
MxcServer: mxc[0],
|
||||
MxcMediaId: mxc[1],
|
||||
}
|
||||
return media, nil
|
||||
}
|
||||
|
||||
func (mx *Client) ParseMediaInfo(content map[string]interface{}) *MediaObject {
|
||||
// Content is an event content of type m.file or m.image
|
||||
info := content["info"].(map[string]interface{})
|
||||
mxc := strings.Split(strings.Replace(content["url"].(string), "mxc://", "", 1), "/")
|
||||
media := &MediaObject{
|
||||
mxClient: mx,
|
||||
filename: content["body"].(string),
|
||||
size: int64(info["size"].(float64)),
|
||||
mimetype: info["mimetype"].(string),
|
||||
MxcServer: mxc[0],
|
||||
MxcMediaId: mxc[1],
|
||||
}
|
||||
if content["msgtype"].(string) == "m.image" {
|
||||
media.imageSize = &connector.ImageSize{
|
||||
Width: int(info["w"].(float64)),
|
||||
Height: int(info["h"].(float64)),
|
||||
}
|
||||
}
|
||||
return media
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
package mxlib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
type MediaObject struct {
|
||||
mxClient *Client
|
||||
filename string
|
||||
size int64
|
||||
mimetype string
|
||||
imageSize *connector.ImageSize
|
||||
MxcServer string
|
||||
MxcMediaId string
|
||||
}
|
||||
|
||||
func (m *MediaObject) Filename() string {
|
||||
return m.filename
|
||||
}
|
||||
|
||||
func (m *MediaObject) Size() int64 {
|
||||
return m.size
|
||||
}
|
||||
|
||||
func (m *MediaObject) Mimetype() string {
|
||||
return m.mimetype
|
||||
}
|
||||
|
||||
func (m *MediaObject) ImageSize() *connector.ImageSize {
|
||||
return m.imageSize
|
||||
}
|
||||
|
||||
func (m *MediaObject) Read() (io.ReadCloser, error) {
|
||||
req, err := http.NewRequest("GET", m.URL(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", "Bearer "+m.mxClient.Token)
|
||||
|
||||
resp, err := m.mxClient.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP error %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (m *MediaObject) URL() string {
|
||||
return fmt.Sprintf("%s/_matrix/media/r0/download/%s/%s/%s",
|
||||
m.mxClient.Server, m.MxcServer, m.MxcMediaId, url.QueryEscape(m.filename))
|
||||
}
|
||||
|
||||
func (m *MediaObject) MxcUri() string {
|
||||
return fmt.Sprintf("mxc://%s/%s", m.MxcServer, m.MxcMediaId)
|
||||
}
|
|
@ -5,22 +5,21 @@ import (
|
|||
)
|
||||
|
||||
type Registration struct {
|
||||
Id string `yaml:"id"`
|
||||
Url string `yaml:"url"`
|
||||
AsToken string `yaml:"as_token"`
|
||||
HsToken string `yaml:"hs_token"`
|
||||
SenderLocalpart string `yaml:"sender_localpart"`
|
||||
RateLimited bool `yaml:"rate_limited"`
|
||||
Namespaces RegistrationNamespaceSet `yaml:"namespaces"`
|
||||
Id string `yaml:"id"`
|
||||
Url string `yaml:"url"`
|
||||
AsToken string `yaml:"as_token"`
|
||||
HsToken string `yaml:"hs_token"`
|
||||
SenderLocalpart string `yaml:"sender_localpart"`
|
||||
Namespaces RegistrationNamespaceSet `yaml:"namespaces"`
|
||||
}
|
||||
|
||||
type RegistrationNamespaceSet struct {
|
||||
Users []RegistrationNamespace `yaml:"users"`
|
||||
Users []RegistrationNamespace `yaml:"users"`
|
||||
Aliases []RegistrationNamespace `yaml:"aliases"`
|
||||
Rooms []RegistrationNamespace `yaml:"rooms"`
|
||||
Rooms []RegistrationNamespace `yaml:"rooms"`
|
||||
}
|
||||
|
||||
type RegistrationNamespace struct {
|
||||
Exclusive bool `yaml:"exclusive"`
|
||||
Regex string `yaml:"regex"`
|
||||
Exclusive bool `yaml:"exclusive"`
|
||||
Regex string `yaml:"regex"`
|
||||
}
|
||||
|
|
362
server.go
362
server.go
|
@ -1,362 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib"
|
||||
)
|
||||
|
||||
var mx *mxlib.Client
|
||||
|
||||
func StartAppService(errch chan error, ctx context.Context) (*http.Server, error) {
|
||||
mx = mxlib.NewClient(config.Server, registration.AsToken)
|
||||
|
||||
err := InitDb()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dbKvGet("ezbr_initialized") != "yes" {
|
||||
err = mx.RegisterUser(registration.SenderLocalpart)
|
||||
if mxe, ok := err.(*mxlib.MxError); !ok || mxe.ErrCode != "M_USER_IN_USE" {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, st := os.Stat(config.AvatarFile)
|
||||
if !os.IsNotExist(st) {
|
||||
err = mx.ProfileAvatar(ezbrMxId(), &connector.FileMediaObject{
|
||||
Path: config.AvatarFile,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err = mx.ProfileDisplayname(ezbrMxId(), fmt.Sprintf("Easybridge (%s)", EASYBRIDGE_SYSTEM_PROTOCOL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dbKvPut("ezbr_initialized", "yes")
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/_matrix/app/v1/transactions/{txnId}", handleTxn)
|
||||
router.HandleFunc("/transactions/{txnId}", handleTxn)
|
||||
|
||||
log.Printf("Starting HTTP server on %s", config.ASBindAddr)
|
||||
http_server := &http.Server{
|
||||
Addr: config.ASBindAddr,
|
||||
Handler: checkTokenAndLog(router),
|
||||
BaseContext: func(net.Listener) context.Context {
|
||||
return ctx
|
||||
},
|
||||
}
|
||||
go func() {
|
||||
err := http_server.ListenAndServe()
|
||||
if err != nil {
|
||||
errch <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Notify users that Easybridge has restarted
|
||||
go func() {
|
||||
var users []DbAccountConfig
|
||||
db.Model(&DbAccountConfig{}).Select("mx_user_id").Group("mx_user_id").Find(&users)
|
||||
for _, u := range users {
|
||||
ezbrSystemSendf(u.MxUserID,
|
||||
"Easybridge has restarted, please visit %s or open configuration widget to reconnect to your accounts.",
|
||||
config.WebURL)
|
||||
}
|
||||
}()
|
||||
|
||||
return http_server, nil
|
||||
}
|
||||
|
||||
func checkTokenAndLog(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
if strings.Join(r.Form["access_token"], "") != registration.HsToken {
|
||||
http.Error(w, "Wrong or no token provided", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, strings.Split(r.URL.String(), "?")[0])
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func handleTxn(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PUT" {
|
||||
var txn mxlib.Transaction
|
||||
err := json.NewDecoder(r.Body).Decode(&txn)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Warnf("JSON decode error: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Processing transaction %s (%d events)", mux.Vars(r)["txnId"], len(txn.Events))
|
||||
log.Tracef("Transaction content: %#v\n", txn)
|
||||
|
||||
for i := range txn.Events {
|
||||
ev := &txn.Events[i]
|
||||
if isBridgedIdentifier(ev.Sender) {
|
||||
// Don't do anything with ezbr events that come back to us
|
||||
continue
|
||||
}
|
||||
err = handleTxnEvent(ev)
|
||||
if err != nil {
|
||||
ezbrSystemSend(ev.Sender, fmt.Sprintf("Could not process %s (from %s): %s", ev.Type, ev.Sender, err))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "{}\n")
|
||||
} else {
|
||||
http.Error(w, "Expected PUT request", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func handleTxnEvent(e *mxlib.Event) error {
|
||||
if e.Type == "m.room.message" {
|
||||
e_body, ok := e.Content["body"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("Invalid m.room.message event, body is not defined: %#v", e)
|
||||
}
|
||||
typ, ok := e.Content["msgtype"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("Invalid m.room.message event, msgtype is not defined: %#v", e)
|
||||
}
|
||||
|
||||
ev := &connector.Event{
|
||||
Type: connector.EVENT_MESSAGE,
|
||||
Text: e_body,
|
||||
Id: e.EventId,
|
||||
}
|
||||
|
||||
if typ == "m.emote" {
|
||||
ev.Type = connector.EVENT_MESSAGE
|
||||
} else if typ == "m.file" || typ == "m.image" {
|
||||
ev.Text = ""
|
||||
ev.Attachments = []connector.SMediaObject{
|
||||
connector.SMediaObject{mx.ParseMediaInfo(e.Content)},
|
||||
}
|
||||
}
|
||||
|
||||
if pm_room := dbIsPmRoom(e.RoomId); pm_room != nil {
|
||||
if pm_room.Protocol == EASYBRIDGE_SYSTEM_PROTOCOL {
|
||||
handleSystemMessage(e.Sender, e_body)
|
||||
return nil
|
||||
}
|
||||
// If this is a private message room
|
||||
acct := FindAccount(pm_room.MxUserID, pm_room.AccountName)
|
||||
if acct == nil {
|
||||
return fmt.Errorf("Not connected to %s", pm_room.AccountName)
|
||||
} else if e.Sender == pm_room.MxUserID {
|
||||
ev.Author = acct.Conn.User()
|
||||
ev.Recipient = pm_room.UserID
|
||||
_, err := acct.Conn.Send(ev)
|
||||
return err
|
||||
}
|
||||
} else if room := dbIsPublicRoom(e.RoomId); room != nil {
|
||||
// If this is a regular room
|
||||
acct := FindJoinedAccount(e.Sender, room.Protocol, room.RoomID)
|
||||
if acct == nil {
|
||||
mx.RoomKick(e.RoomId, e.Sender, fmt.Sprintf("Not present in %s on %s, please talk with Easybridge to rejoin", room.RoomID, room.Protocol))
|
||||
return fmt.Errorf("not joined %s on %s", room.RoomID, room.Protocol)
|
||||
} else {
|
||||
ev.Author = acct.Conn.User()
|
||||
ev.Room = room.RoomID
|
||||
|
||||
// use room id as lock slot key, see account.go in eventInternal
|
||||
dbLockSlot(e.RoomId)
|
||||
defer dbUnlockSlot(e.RoomId)
|
||||
|
||||
created_ev_id, err := acct.Conn.Send(ev)
|
||||
if err == nil && created_ev_id != "" {
|
||||
cache_key := fmt.Sprintf("%s/event_seen/%s/%s",
|
||||
room.Protocol, e.RoomId, created_ev_id)
|
||||
dbKvPutLocked(cache_key, "yes")
|
||||
}
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("Room not bridged")
|
||||
}
|
||||
} else if e.Type == "m.room.member" {
|
||||
ms, ok := e.Content["membership"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("Invalid m.room.member event, membership is not defined: %#v", e)
|
||||
}
|
||||
|
||||
if ms == "leave" {
|
||||
if pm_room := dbIsPmRoom(e.RoomId); pm_room != nil {
|
||||
// If user leaves a PM room, we must drop it
|
||||
dbDeletePmRoom(pm_room)
|
||||
them_mx, err := dbGetMxUser(pm_room.Protocol, pm_room.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mx.RoomLeaveAs(e.RoomId, them_mx)
|
||||
return nil
|
||||
} else if room := dbIsPublicRoom(e.RoomId); room != nil {
|
||||
// If leaving a public room, leave from server as well
|
||||
acct := FindJoinedAccount(e.Sender, room.Protocol, room.RoomID)
|
||||
if acct != nil {
|
||||
acct.Conn.Leave(room.RoomID)
|
||||
acct.delAutojoin(room.RoomID)
|
||||
return nil
|
||||
} else {
|
||||
mx.RoomKick(e.RoomId, e.Sender, fmt.Sprintf("Not present in %s on %s, please talk with Easybridge to rejoin", room.RoomID, room.Protocol))
|
||||
return fmt.Errorf("not joined %s on %s", room.RoomID, room.Protocol)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("Room not bridged")
|
||||
}
|
||||
}
|
||||
} else if e.Type == "m.room.topic" {
|
||||
e_topic, ok := e.Content["topic"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("Invalid m.room.topic event, topic is not defined: %#v", e)
|
||||
}
|
||||
|
||||
if room := dbIsPublicRoom(e.RoomId); room != nil {
|
||||
acct := FindJoinedAccount(e.Sender, room.Protocol, room.RoomID)
|
||||
if acct != nil {
|
||||
return acct.Conn.SetRoomInfo(room.RoomID, &connector.RoomInfo{
|
||||
Topic: e_topic,
|
||||
})
|
||||
} else {
|
||||
return fmt.Errorf("Could not find room account for %s %s %s", e.Sender, room.Protocol, room.RoomID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSystemMessage(mxid string, msg string) {
|
||||
cmd := strings.Fields(msg)
|
||||
if len(cmd) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
switch cmd[0] {
|
||||
case "help":
|
||||
ezbrSystemSend(mxid, "Welcome to Easybridge! Here is a list of available commands:")
|
||||
ezbrSystemSend(mxid, "- help: request help")
|
||||
ezbrSystemSend(mxid, "- list: list accounts")
|
||||
ezbrSystemSend(mxid, "- accounts: list accounts")
|
||||
ezbrSystemSend(mxid, "- join <protocol or account> <room id>: join public chat room")
|
||||
ezbrSystemSend(mxid, "- talk <protocol or account> <user id>: open private conversation to contact")
|
||||
ezbrSystemSend(mxid, "- search <protocol or account> <name>: search for users by name")
|
||||
ezbrSystemSend(mxid, "- cmd <protocol or account> <command>: send special command to account")
|
||||
case "list", "account", "accounts":
|
||||
one := false
|
||||
if accts, ok := registeredAccounts[mxid]; ok {
|
||||
for name, acct := range accts {
|
||||
one = true
|
||||
ezbrSystemSendf(mxid, "- %s (%s)", name, acct.Protocol)
|
||||
}
|
||||
}
|
||||
if !one {
|
||||
ezbrSystemSendf(mxid, "No account currently configured")
|
||||
}
|
||||
case "join":
|
||||
if len(cmd) != 3 {
|
||||
ezbrSystemSendf(mxid, "Usage: %s <protocol or account> <room id>", cmd[0])
|
||||
return
|
||||
}
|
||||
|
||||
account := findAccount(mxid, cmd[1])
|
||||
if account != nil {
|
||||
err := account.Conn.Join(connector.RoomID(cmd[2]))
|
||||
if err != nil {
|
||||
ezbrSystemSendf(mxid, "%s", err)
|
||||
}
|
||||
} else {
|
||||
ezbrSystemSendf(mxid, "No account with name or using protocol %s", cmd[1])
|
||||
}
|
||||
case "query", "talk":
|
||||
if len(cmd) != 3 {
|
||||
ezbrSystemSendf(mxid, "Usage: %s <protocol or account> <user id>", cmd[0])
|
||||
return
|
||||
}
|
||||
|
||||
account := findAccount(mxid, cmd[1])
|
||||
if account != nil {
|
||||
quser := connector.UserID(cmd[2])
|
||||
err := account.Conn.Invite(quser, connector.RoomID(""))
|
||||
if err != nil {
|
||||
ezbrSystemSendf(mxid, "%s", err)
|
||||
return
|
||||
}
|
||||
|
||||
quser_mxid, err := dbGetMxUser(account.Protocol, quser)
|
||||
if err != nil {
|
||||
ezbrSystemSendf(mxid, "%s", err)
|
||||
return
|
||||
}
|
||||
_, err = dbGetMxPmRoom(account.Protocol, quser, quser_mxid, mxid, account.AccountName)
|
||||
if err != nil {
|
||||
ezbrSystemSendf(mxid, "%s", err)
|
||||
}
|
||||
} else {
|
||||
ezbrSystemSendf(mxid, "No account with name or using protocol %s", cmd[1])
|
||||
}
|
||||
case "search":
|
||||
if len(cmd) < 3 {
|
||||
ezbrSystemSendf(mxid, "Usage: %s <protocol or account> <name>", cmd[0])
|
||||
return
|
||||
}
|
||||
|
||||
account := findAccount(mxid, cmd[1])
|
||||
if account != nil {
|
||||
rep, err := account.Conn.SearchForUsers(strings.Join(cmd[2:], " "))
|
||||
if err != nil {
|
||||
ezbrSystemSendf(mxid, "Search error: %s", err)
|
||||
} else {
|
||||
ezbrSystemSendf(mxid, "%d users found", len(rep))
|
||||
for _, user := range rep {
|
||||
ezbrSystemSendf(mxid, "- %s (%s)", user.DisplayName, user.ID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ezbrSystemSendf(mxid, "No account with name or using protocol %s", cmd[1])
|
||||
}
|
||||
case "cmd":
|
||||
if len(cmd) < 3 {
|
||||
ezbrSystemSendf(mxid, "Usage: %s <protocol or account> <name>", cmd[0])
|
||||
return
|
||||
}
|
||||
|
||||
account := findAccount(mxid, cmd[1])
|
||||
if account != nil {
|
||||
account.Conn.UserCommand(strings.Join(cmd[2:], " "))
|
||||
} else {
|
||||
ezbrSystemSendf(mxid, "No account with name or using protocol %s", cmd[1])
|
||||
}
|
||||
default:
|
||||
ezbrSystemSend(mxid, "Unrecognized command. Type `help` if you need some help!")
|
||||
}
|
||||
}
|
||||
|
||||
func findAccount(mxid string, q string) *Account {
|
||||
if accts, ok := registeredAccounts[mxid]; ok {
|
||||
for name, acct := range accts {
|
||||
if strings.EqualFold(name, q) || strings.EqualFold(acct.Protocol, q) {
|
||||
return acct
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
7
static/css/bootstrap.min.css
vendored
7
static/css/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
|
@ -1,77 +0,0 @@
|
|||
{{define "title"}}Account configuration |{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div class="d-flex">
|
||||
<h4>Configure account</h4>
|
||||
<a class="ml-auto btn btn-info" href="/">Go back</a>
|
||||
</div>
|
||||
|
||||
{{if .ErrorMessage}}
|
||||
<div class="alert alert-danger mt-4">An error occurred.
|
||||
<div style="font-size: 0.8em">{{ .ErrorMessage }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" class="mt-4">
|
||||
<div class="form-group">
|
||||
<label for="name">Account name:</label>
|
||||
<input type="text" {{if .NameEditable}}{{else}}disabled="disabled"{{end}} id="name" name="name" class="form-control" value="{{ .Name }}" />
|
||||
{{if .InvalidName}}
|
||||
<div class="alert alert-warning">Invalid name (must not be empty)</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Protocol:</label>
|
||||
<input type="text" disabled="disabled" class="form-control" value="{{ .Protocol }}" />
|
||||
</div>
|
||||
{{$config := .Config}}
|
||||
{{$errors := .Errors}}
|
||||
{{range $i, $schema := .Schema}}
|
||||
<div class="form-group">
|
||||
<label for="{{$schema.Name}}">{{$schema.Description}}:</label>
|
||||
{{if $schema.FixedValue}}
|
||||
<input type="text"
|
||||
disabled="disabled"
|
||||
class="form-control"
|
||||
name="{{$schema.Name}}"
|
||||
id="{{$schema.Name}}"
|
||||
value="{{index $config $schema.Name}}" />
|
||||
{{else if $schema.IsBoolean}}
|
||||
{{$value := index $config $schema.Name}}
|
||||
<label for="{{$schema.Name}}-true">
|
||||
<input type="radio" name="{{$schema.Name}}" id="{{$schema.Name}}-true" value="true" {{if eq $value "true"}}checked="checked"{{end}} />
|
||||
Yes
|
||||
</label>
|
||||
<label for="{{$schema.Name}}-false">
|
||||
<input type="radio" name="{{$schema.Name}}" id="{{$schema.Name}}-false" value="false" {{if eq $value "false"}}checked="checked"{{end}} />
|
||||
No
|
||||
</label>
|
||||
{{else if $schema.IsPassword}}
|
||||
<input type="password"
|
||||
class="form-control"
|
||||
name="{{$schema.Name}}"
|
||||
id="{{$schema.Name}}"
|
||||
placeholder="(not modified if left empty)" />
|
||||
{{else if $schema.IsNumeric}}
|
||||
<input type="number"
|
||||
class="form-control"
|
||||
name="{{$schema.Name}}"
|
||||
id="{{$schema.Name}}"
|
||||
value="{{index $config $schema.Name}}" />
|
||||
{{else}}
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="{{$schema.Name}}"
|
||||
id="{{$schema.Name}}"
|
||||
value="{{index $config $schema.Name}}" />
|
||||
{{end}}
|
||||
{{$error := index $errors $schema.Name}}
|
||||
{{if $error}}
|
||||
<div class="alert alert-warning mt-2">{{$error}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<button type="submit" class="btn btn-primary">Save configuration</button>
|
||||
</form>
|
||||
|
||||
{{end}}
|
|
@ -1,12 +0,0 @@
|
|||
{{define "title"}}Delete account |{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
|
||||
<h4>Really delete account {{.}}?</h4>
|
||||
|
||||
<form method="POST">
|
||||
<input type="submit" class="btn btn-danger" name="delete" value="Yes" />
|
||||
<a href="/" class="btn btn-secondary ml-4" href="/">No, go back</a>
|
||||
</form>
|
||||
|
||||
{{end}}
|
|
@ -1,42 +0,0 @@
|
|||
{{define "title"}}{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div class="alert alert-info">
|
||||
Logged in as <strong>{{ .Login.MxId }}</strong>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<a class="ml-auto btn btn-sm btn-dark" href="/logout">Log out</a>
|
||||
</div>
|
||||
|
||||
{{ if .Accounts }}
|
||||
<table class="table mt-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account name</th>
|
||||
<th>Protocol</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $acc := .Accounts}}
|
||||
<tr>
|
||||
<td>{{ $acc.AccountName }}</td>
|
||||
<td>{{ $acc.Protocol }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-primary" href="/edit/{{ $acc.AccountName }}">Modify</a>
|
||||
<a class="btn btn-sm btn-danger ml-4" href="/delete/{{ $acc.AccountName }}">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
<h5 class="mt-4">Add account</h5>
|
||||
|
||||
<a class="btn btn-sm btn-dark mr-4" href="/add/IRC">IRC</a>
|
||||
<a class="btn btn-sm btn-warning mr-4" href="/add/XMPP">XMPP</a>
|
||||
<a class="btn btn-sm btn-info mr-4" href="/add/Mattermost">Mattermost</a>
|
||||
<a class="btn btn-sm btn-primary mr-4" href="/add/Messenger">Facebook Messenger</a>
|
||||
|
||||
{{end}}
|
|
@ -1,18 +0,0 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
|
||||
<title>{{template "title"}} Easybridge</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Easybridge manager</h1>
|
||||
<hr />
|
||||
{{template "body" .}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,30 +0,0 @@
|
|||
{{define "title"}}{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<h4>Log in</h4>
|
||||
|
||||
<div class="alert alert-info">
|
||||
Log in using your Matrix credentials on {{ .MatrixDomain }}
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
{{if .WrongPass}}
|
||||
<div class="alert alert-danger">Wrong password.</div>
|
||||
{{end}}
|
||||
{{if .ErrorMessage}}
|
||||
<div class="alert alert-danger">Unable to log in.
|
||||
<div style="font-size: 0.8em">{{ .ErrorMessage }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="form-group">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" name="username" id="username" class="form-control" value="{{ .Username }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" name="password" id="password" class="form-control" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Log in</button>
|
||||
</form>
|
||||
|
||||
{{end}}
|
147
test/main.go
Normal file
147
test/main.go
Normal file
|
@ -0,0 +1,147 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector/irc"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector/xmpp"
|
||||
)
|
||||
|
||||
type TmpHandler struct{
|
||||
exit chan bool
|
||||
}
|
||||
|
||||
func (h *TmpHandler) Joined(roomId connector.RoomID) {
|
||||
fmt.Printf("C Joined: %s\n", roomId)
|
||||
}
|
||||
|
||||
func (h *TmpHandler) Left(roomId connector.RoomID) {
|
||||
fmt.Printf("C Joined: %s\n", roomId)
|
||||
}
|
||||
|
||||
func (h *TmpHandler) UserInfoUpdated(u connector.UserID, i *connector.UserInfo) {
|
||||
fmt.Printf("C User info: %s => %#v\n", u, i)
|
||||
}
|
||||
|
||||
func (h *TmpHandler) RoomInfoUpdated(r connector.RoomID, i *connector.RoomInfo) {
|
||||
fmt.Printf("C Room info: %s => %#v\n", r, i)
|
||||
}
|
||||
func (h *TmpHandler) Event(e *connector.Event) {
|
||||
if e.Type == connector.EVENT_JOIN {
|
||||
fmt.Printf("C E Join %s %s\n", e.Author, e.Room)
|
||||
} else if e.Type == connector.EVENT_LEAVE {
|
||||
fmt.Printf("C E Leave %s %s\n", e.Author, e.Room)
|
||||
} else if e.Type == connector.EVENT_MESSAGE {
|
||||
fmt.Printf("C E Message %s %s %s\n", e.Author, e.Room, e.Text)
|
||||
if strings.Contains(e.Text, "ezbrexit") {
|
||||
fmt.Printf("we have to exit\n")
|
||||
h.exit <- true
|
||||
}
|
||||
} else if e.Type == connector.EVENT_ACTION {
|
||||
fmt.Printf("C E Action %s %s %s\n", e.Author, e.Room, e.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func testIrc() {
|
||||
irc := &irc.IRC{}
|
||||
h := TmpHandler{
|
||||
exit: make(chan bool),
|
||||
}
|
||||
irc.SetHandler(&h)
|
||||
|
||||
err := irc.Configure(connector.Configuration{
|
||||
"server": "irc.ulminfo.fr",
|
||||
"port": "6666",
|
||||
"ssl": "true",
|
||||
"nick": "ezbr",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Connect: %s", err)
|
||||
}
|
||||
|
||||
err = irc.Join(connector.RoomID("#ezbrtest@irc.ulminfo.fr"))
|
||||
if err != nil {
|
||||
log.Fatalf("Join: %s", err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(1)*time.Second)
|
||||
err = irc.Send(&connector.Event{
|
||||
Room: connector.RoomID("#ezbrtest@irc.ulminfo.fr"),
|
||||
Type: connector.EVENT_MESSAGE,
|
||||
Text: "EZBR TEST",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Send: %s", err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(1)*time.Second)
|
||||
err = irc.Send(&connector.Event{
|
||||
Recipient: connector.UserID("lx@irc.ulminfo.fr"),
|
||||
Type: connector.EVENT_MESSAGE,
|
||||
Text: "EZBR TEST direct message lol",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Send: %s", err)
|
||||
}
|
||||
|
||||
fmt.Printf("waiting exit signal\n")
|
||||
<-h.exit
|
||||
fmt.Printf("got exit signal\n")
|
||||
irc.Close()
|
||||
}
|
||||
|
||||
func testXmpp() {
|
||||
xmpp := &xmpp.XMPP{}
|
||||
h := TmpHandler{
|
||||
exit: make(chan bool),
|
||||
}
|
||||
xmpp.SetHandler(&h)
|
||||
|
||||
err := xmpp.Configure(connector.Configuration{
|
||||
"server": "jabber.fr",
|
||||
"jid": "ezbr@jabber.fr",
|
||||
"password": "azerty1234",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Connect: %s", err)
|
||||
}
|
||||
|
||||
err = xmpp.Join(connector.RoomID("ezbrtest@muc.linkmauve.fr"))
|
||||
if err != nil {
|
||||
log.Fatalf("Join: %s", err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(1)*time.Second)
|
||||
err = xmpp.Send(&connector.Event{
|
||||
Room: connector.RoomID("ezbrtest@muc.linkmauve.fr"),
|
||||
Type: connector.EVENT_MESSAGE,
|
||||
Text: "EZBR TEST",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Send: %s", err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(1)*time.Second)
|
||||
err = xmpp.Send(&connector.Event{
|
||||
Recipient: connector.UserID("alexis211@jabber.fr"),
|
||||
Type: connector.EVENT_MESSAGE,
|
||||
Text: "EZBR TEST direct message lol",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Send: %s", err)
|
||||
}
|
||||
|
||||
fmt.Printf("waiting exit signal\n")
|
||||
<-h.exit
|
||||
fmt.Printf("got exit signal\n")
|
||||
xmpp.Close()
|
||||
}
|
||||
|
||||
func main() {
|
||||
testXmpp()
|
||||
}
|
143
util.go
143
util.go
|
@ -1,143 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/nacl/secretbox"
|
||||
|
||||
. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
)
|
||||
|
||||
const EASYBRIDGE_SYSTEM_PROTOCOL string = "✯◡✯"
|
||||
|
||||
func ezbrMxId() string {
|
||||
return fmt.Sprintf("@%s:%s", registration.SenderLocalpart, config.MatrixDomain)
|
||||
}
|
||||
|
||||
func ezbrSystemRoom(user_mx_id string) (string, error) {
|
||||
mx_room_id, err := dbGetMxPmRoom(EASYBRIDGE_SYSTEM_PROTOCOL, UserID("Easybridge"), ezbrMxId(), user_mx_id, "easybridge")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
widget_kv_key := "ezbr_widget_on:" + mx_room_id
|
||||
if config.WebURL != "" && dbKvGet(widget_kv_key) != "yes" {
|
||||
widget := map[string]interface{}{
|
||||
"type": "m.easybridge",
|
||||
"url": config.WebURL,
|
||||
"name": "Easybridge account configuration dashboard",
|
||||
}
|
||||
err = mx.PutStateAs(mx_room_id, "im.vector.modular.widgets", "ezbr_widget", widget, ezbrMxId())
|
||||
if err == nil {
|
||||
dbKvPut(widget_kv_key, "yes")
|
||||
}
|
||||
}
|
||||
|
||||
return mx_room_id, nil
|
||||
}
|
||||
|
||||
func ezbrSystemSend(user_mx_id string, msg string) {
|
||||
mx_room_id, err := ezbrSystemRoom(user_mx_id)
|
||||
if err == nil {
|
||||
err = mx.SendMessageAs(mx_room_id, "m.text", msg, ezbrMxId())
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnf("(%s) %s", user_mx_id, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func ezbrSystemSendf(user_mx_id string, format string, args ...interface{}) {
|
||||
ezbrSystemSend(user_mx_id, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func roomAlias(protocol string, id RoomID) string {
|
||||
what := fmt.Sprintf("%s_%s", safeStringForId(string(id)), protocol)
|
||||
return strings.Replace(config.NameFormat, "{}", what, 1)
|
||||
}
|
||||
|
||||
func userMxId(protocol string, id UserID) string {
|
||||
what := fmt.Sprintf("%s_%s", safeStringForId(string(id)), protocol)
|
||||
return strings.Replace(config.NameFormat, "{}", what, 1)
|
||||
}
|
||||
|
||||
func safeStringForId(in string) string {
|
||||
id2 := ""
|
||||
for _, c := range in {
|
||||
if c == '@' {
|
||||
id2 += "__"
|
||||
} else if c == ':' {
|
||||
id2 += "_"
|
||||
} else if unicode.IsDigit(c) || unicode.IsLetter(c) || c == '.' || c == '-' || c == '_' {
|
||||
id2 += string(c)
|
||||
}
|
||||
}
|
||||
return id2
|
||||
}
|
||||
|
||||
func isBridgedIdentifier(mxid string) bool {
|
||||
if mxid[0] == '@' || mxid[0] == '#' {
|
||||
return isBridgedIdentifier(mxid[1:])
|
||||
}
|
||||
|
||||
if strings.Contains(mxid, ":") {
|
||||
sp := strings.Split(mxid, ":")
|
||||
return (sp[1] == config.MatrixDomain) && isBridgedIdentifier(sp[0])
|
||||
}
|
||||
|
||||
nameformat_fixed_part := strings.Replace(config.NameFormat, "{}", "", 1)
|
||||
if strings.HasPrefix(config.NameFormat, "{}") {
|
||||
return strings.HasSuffix(mxid, nameformat_fixed_part)
|
||||
} else if strings.HasSuffix(config.NameFormat, "{}") {
|
||||
return strings.HasPrefix(mxid, nameformat_fixed_part)
|
||||
} else {
|
||||
// This is not supported
|
||||
log.Fatalf("Invalid name format %s, please put {} at the beginning or at the end", config.NameFormat)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Encoding and encryption of account config
|
||||
|
||||
func encryptAccountConfig(config map[string]string, key *[32]byte) string {
|
||||
bytes, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var nonce [24]byte
|
||||
_, err = rand.Read(nonce[:])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
crypto := secretbox.Seal([]byte{}, bytes, &nonce, key)
|
||||
all := append(nonce[:], crypto...)
|
||||
return base64.StdEncoding.EncodeToString(all)
|
||||
}
|
||||
|
||||
func decryptAccountConfig(data string, key *[32]byte) (map[string]string, error) {
|
||||
bytes, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var nonce [24]byte
|
||||
copy(nonce[:], bytes[:24])
|
||||
|
||||
decoded, ok := secretbox.Open([]byte{}, bytes[24:], &nonce, key)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Invalid key")
|
||||
}
|
||||
|
||||
var config map[string]string
|
||||
err = json.Unmarshal(decoded, &config)
|
||||
return config, err
|
||||
}
|
357
web.go
357
web.go
|
@ -1,357 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/blake2b"
|
||||
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
|
||||
"git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib"
|
||||
)
|
||||
|
||||
const SESSION_NAME = "easybridge_session"
|
||||
|
||||
var sessionsStore sessions.Store = nil
|
||||
var userKeys = map[string]*[32]byte{}
|
||||
|
||||
func StartWeb(errch chan error, ctx context.Context) *http.Server {
|
||||
session_key := blake2b.Sum256([]byte(config.SessionKey))
|
||||
sessionsStore = sessions.NewCookieStore(session_key[:])
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/", handleHome)
|
||||
r.HandleFunc("/logout", handleLogout)
|
||||
r.HandleFunc("/add/{protocol}", handleAdd)
|
||||
r.HandleFunc("/edit/{account}", handleEdit)
|
||||
r.HandleFunc("/delete/{account}", handleDelete)
|
||||
|
||||
staticfiles := http.FileServer(http.Dir("static"))
|
||||
r.Handle("/static/{file:.*}", http.StripPrefix("/static/", staticfiles))
|
||||
|
||||
log.Printf("Starting web UI HTTP server on %s", config.WebBindAddr)
|
||||
web_server := &http.Server{
|
||||
Addr: config.WebBindAddr,
|
||||
Handler: logRequest(r),
|
||||
BaseContext: func(net.Listener) context.Context {
|
||||
return ctx
|
||||
},
|
||||
}
|
||||
go func() {
|
||||
err := web_server.ListenAndServe()
|
||||
if err != nil {
|
||||
errch <- err
|
||||
}
|
||||
}()
|
||||
|
||||
return web_server
|
||||
}
|
||||
|
||||
func logRequest(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL)
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
type LoginInfo struct {
|
||||
MxId string
|
||||
}
|
||||
|
||||
func checkLogin(w http.ResponseWriter, r *http.Request) *LoginInfo {
|
||||
var login_info *LoginInfo
|
||||
|
||||
session, err := sessionsStore.Get(r, SESSION_NAME)
|
||||
if err == nil {
|
||||
mxid, ok := session.Values["login_mxid"].(string)
|
||||
user_key, ok2 := session.Values["login_user_key"].([]byte)
|
||||
if ok && ok2 {
|
||||
if _, had_key := userKeys[mxid]; !had_key && len(user_key) == 32 {
|
||||
key := new([32]byte)
|
||||
copy(key[:], user_key)
|
||||
userKeys[mxid] = key
|
||||
LoadDbAccounts(mxid, key)
|
||||
}
|
||||
login_info = &LoginInfo{
|
||||
MxId: mxid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if login_info == nil {
|
||||
login_info = handleLogin(w, r)
|
||||
}
|
||||
|
||||
return login_info
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
type HomeData struct {
|
||||
Login *LoginInfo
|
||||
Accounts []*Account
|
||||
}
|
||||
|
||||
func handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
templateHome := template.Must(template.ParseFiles("templates/layout.html", "templates/home.html"))
|
||||
|
||||
login := checkLogin(w, r)
|
||||
if login == nil {
|
||||
return
|
||||
}
|
||||
|
||||
templateHome.Execute(w, &HomeData{
|
||||
Login: login,
|
||||
Accounts: ListAccounts(login.MxId),
|
||||
})
|
||||
}
|
||||
|
||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := sessionsStore.Get(r, SESSION_NAME)
|
||||
if err != nil {
|
||||
session, _ = sessionsStore.New(r, SESSION_NAME)
|
||||
}
|
||||
|
||||
delete(session.Values, "login_mxid")
|
||||
|
||||
err = session.Save(r, w)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
type LoginFormData struct {
|
||||
Username string
|
||||
WrongPass bool
|
||||
ErrorMessage string
|
||||
MatrixDomain string
|
||||
}
|
||||
|
||||
func handleLogin(w http.ResponseWriter, r *http.Request) *LoginInfo {
|
||||
templateLogin := template.Must(template.ParseFiles("templates/layout.html", "templates/login.html"))
|
||||
|
||||
data := &LoginFormData{
|
||||
MatrixDomain: config.MatrixDomain,
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
templateLogin.Execute(w, data)
|
||||
return nil
|
||||
} else if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
|
||||
username := strings.Join(r.Form["username"], "")
|
||||
password := strings.Join(r.Form["password"], "")
|
||||
|
||||
cli := mxlib.NewClient(config.Server, "")
|
||||
mxid, err := cli.PasswordLogin(username, password, "EZBRIDGE", "Easybridge")
|
||||
|
||||
if err != nil {
|
||||
data.Username = username
|
||||
data.ErrorMessage = err.Error()
|
||||
templateLogin.Execute(w, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
key := new([32]byte)
|
||||
key_slice := argon2.IDKey([]byte(password), []byte("EZBRIDGE account store"), 3, 64*1024, 4, 32)
|
||||
copy(key[:], key_slice)
|
||||
userKeys[mxid] = key
|
||||
|
||||
SaveDbAccounts(mxid, key)
|
||||
LoadDbAccounts(mxid, key)
|
||||
|
||||
// Successfully logged in, save it to session
|
||||
session, err := sessionsStore.Get(r, SESSION_NAME)
|
||||
if err != nil {
|
||||
session, _ = sessionsStore.New(r, SESSION_NAME)
|
||||
}
|
||||
|
||||
session.Values["login_mxid"] = mxid
|
||||
session.Values["login_user_key"] = key_slice
|
||||
|
||||
err = session.Save(r, w)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &LoginInfo{
|
||||
MxId: mxid,
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "Unsupported method", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
func handleAdd(w http.ResponseWriter, r *http.Request) {
|
||||
login := checkLogin(w, r)
|
||||
if login == nil {
|
||||
return
|
||||
}
|
||||
|
||||
protocol := mux.Vars(r)["protocol"]
|
||||
|
||||
configForm(w, r, login, "", protocol, map[string]string{})
|
||||
}
|
||||
|
||||
func handleEdit(w http.ResponseWriter, r *http.Request) {
|
||||
login := checkLogin(w, r)
|
||||
if login == nil {
|
||||
return
|
||||
}
|
||||
|
||||
account := mux.Vars(r)["account"]
|
||||
acct := FindAccount(login.MxId, account)
|
||||
if acct == nil {
|
||||
http.Error(w, "No such account", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
configForm(w, r, login, account, acct.Protocol, acct.Config)
|
||||
}
|
||||
|
||||
type ConfigFormData struct {
|
||||
ErrorMessage string
|
||||
|
||||
Name string
|
||||
NameEditable bool
|
||||
InvalidName bool
|
||||
|
||||
Protocol string
|
||||
|
||||
Config map[string]string
|
||||
Errors map[string]string
|
||||
Schema connector.ConfigSchema
|
||||
}
|
||||
|
||||
func configForm(w http.ResponseWriter, r *http.Request,
|
||||
login *LoginInfo, name string, protocol string,
|
||||
prevConfig map[string]string) {
|
||||
templateConfig := template.Must(template.ParseFiles("templates/layout.html", "templates/config.html"))
|
||||
|
||||
data := &ConfigFormData{
|
||||
Name: name,
|
||||
NameEditable: (name == ""),
|
||||
Protocol: protocol,
|
||||
Config: map[string]string{},
|
||||
Errors: map[string]string{},
|
||||
Schema: connector.Protocols[protocol].Schema,
|
||||
}
|
||||
for k, v := range prevConfig {
|
||||
data.Config[k] = v
|
||||
}
|
||||
for _, sch := range data.Schema {
|
||||
if _, ok := data.Config[sch.Name]; !ok && sch.Default != "" {
|
||||
data.Config[sch.Name] = sch.Default
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
ok := true
|
||||
r.ParseForm()
|
||||
|
||||
if data.NameEditable {
|
||||
data.Name = strings.Join(r.Form["name"], "")
|
||||
if data.Name == "" {
|
||||
ok = false
|
||||
data.InvalidName = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, schema := range data.Schema {
|
||||
field := schema.Name
|
||||
old_value := data.Config[field]
|
||||
data.Config[field] = strings.Join(r.Form[field], "")
|
||||
if schema.IsPassword {
|
||||
if data.Config[field] == "" {
|
||||
data.Config[field] = old_value
|
||||
}
|
||||
} else if data.Config[field] == "" {
|
||||
if schema.Required {
|
||||
ok = false
|
||||
data.Errors[field] = "This field is required"
|
||||
}
|
||||
} else if schema.FixedValue != "" {
|
||||
if data.Config[field] != schema.FixedValue {
|
||||
ok = false
|
||||
data.Errors[field] = "This field must be equal to " + schema.FixedValue
|
||||
}
|
||||
} else if schema.IsBoolean {
|
||||
if data.Config[field] != "false" && data.Config[field] != "true" {
|
||||
ok = false
|
||||
data.Errors[field] = "This field must be 'true' or 'false'"
|
||||
}
|
||||
} else if schema.IsNumeric {
|
||||
_, err := strconv.Atoi(data.Config[field])
|
||||
if err != nil {
|
||||
ok = false
|
||||
data.Errors[field] = "This field must be a valid number"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
var entry DbAccountConfig
|
||||
db.Where(&DbAccountConfig{
|
||||
MxUserID: login.MxId,
|
||||
Name: data.Name,
|
||||
}).Assign(&DbAccountConfig{
|
||||
Protocol: protocol,
|
||||
Config: encryptAccountConfig(data.Config, userKeys[login.MxId]),
|
||||
}).FirstOrCreate(&entry)
|
||||
|
||||
err := SetAccount(login.MxId, data.Name, protocol, data.Config)
|
||||
if err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
data.ErrorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
templateConfig.Execute(w, data)
|
||||
}
|
||||
|
||||
func handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
templateDelete := template.Must(template.ParseFiles("templates/layout.html", "templates/delete.html"))
|
||||
|
||||
login := checkLogin(w, r)
|
||||
if login == nil {
|
||||
return
|
||||
}
|
||||
|
||||
account := mux.Vars(r)["account"]
|
||||
|
||||
if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
del := strings.Join(r.Form["delete"], "")
|
||||
if del == "Yes" {
|
||||
RemoveAccount(login.MxId, account)
|
||||
db.Where(&DbAccountConfig{
|
||||
MxUserID: login.MxId,
|
||||
Name: account,
|
||||
}).Delete(&DbAccountConfig{})
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
templateDelete.Execute(w, account)
|
||||
}
|
Loading…
Reference in a new issue