Merge branch 'master' into allow-portals-without-power
This commit is contained in:
@@ -1,23 +1,21 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
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 GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. 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.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ 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.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
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.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
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.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
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.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ 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.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
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
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If 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".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://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
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -8,5 +8,4 @@ A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
||||
## Discussion
|
||||
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
|
||||
|
||||
A Telegram chat bridged to the Matrix room will be created once the bridge supports using a bot
|
||||
for unauthenticated users.
|
||||
A Telegram chat bridged to the Matrix room might be created at some point.
|
||||
|
||||
+1
-18
@@ -45,7 +45,7 @@
|
||||
* [x] Video messages
|
||||
* [x] Documents
|
||||
* [x] Message deletions
|
||||
* [ ] Message edits (not yet supported in Matrix)
|
||||
* [x] Message edits
|
||||
* [x] Avatars
|
||||
* [x] Presence
|
||||
* [x] Typing notifications
|
||||
@@ -74,23 +74,6 @@
|
||||
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
|
||||
* [x] Option to use bot to relay messages for unauthenticated Matrix users
|
||||
* [ ] Option to use own Matrix account for messages sent from other Telegram clients
|
||||
* [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands)
|
||||
* [x] Logging in and out (`login` + code entering)
|
||||
* [x] Logging out
|
||||
* [ ] Registering (`register`)
|
||||
* [x] Searching for users (`search`)
|
||||
* [x] Starting private chats (`pm`)
|
||||
* [x] Joining chats with invite links (`join`)
|
||||
* [x] Creating a Telegram chat for an existing Matrix room (`create`)
|
||||
* [x] Upgrading the chat of a portal room into a supergroup (`upgrade`)
|
||||
* [x] Change username of supergroup/channel (`group-name`)
|
||||
* [x] Getting the Telegram invite link to a Matrix room (`invite-link`)
|
||||
* [ ] Bridging existing Matrix rooms to existing Telegram chats (`bridge`)
|
||||
* [ ] Unbridging Matrix rooms from Telegram chats (`unbridge`)
|
||||
* Bridge administration
|
||||
* [x] Clean up and forget a portal room (`delete-portal`)
|
||||
* [x] Find and clean up old portal rooms (`clean-rooms`)
|
||||
* [ ] Setting Matrix-only power levels (`powerlevel`)
|
||||
|
||||
† Information not automatically sent from source, i.e. implementation may not be possible
|
||||
‡ Maybe, i.e. this feature may or may not be implemented at some point
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Add metadata to TelegramFile
|
||||
|
||||
Revision ID: cfc972368e50
|
||||
Revises: 501dad2868bc
|
||||
Create Date: 2018-03-09 16:07:01.236712
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'cfc972368e50'
|
||||
down_revision = '501dad2868bc'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("telegram_file") as batch_op:
|
||||
batch_op.add_column(sa.Column('size', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('width', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('height', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('thumbnail', sa.String(), nullable=True))
|
||||
batch_op.create_foreign_key(constraint_name="fk_file_thumbnail",
|
||||
referent_table="telegram_file",
|
||||
local_cols=['thumbnail'],
|
||||
remote_cols=['id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("telegram_file") as batch_op:
|
||||
batch_op.drop_column('size')
|
||||
batch_op.drop_column('width')
|
||||
batch_op.drop_column('height')
|
||||
batch_op.drop_column('thumbnail')
|
||||
+22
-9
@@ -2,6 +2,7 @@
|
||||
homeserver:
|
||||
address: https://matrix.org
|
||||
domain: matrix.org
|
||||
verify_ssl: true
|
||||
|
||||
# Application service host/registration related details
|
||||
# Changing these values requires regeneration of the registration.
|
||||
@@ -70,15 +71,11 @@ bridge:
|
||||
- username
|
||||
- phone number
|
||||
|
||||
# Whether or not to use native Matrix replies. At the time of writing, only riot-web supports
|
||||
# replies and the format of them is subject to change.
|
||||
native_replies: true
|
||||
# If native replies are disabled, should the custom replies contain a link to the message being
|
||||
# replied to?
|
||||
link_in_reply: false
|
||||
# Show message editing as a reply to the original message.
|
||||
# If this is false, message edits are not shown at all, as Matrix does not support editing yet.
|
||||
edits_as_replies: false
|
||||
# Highlight changed/added parts in edits. Requires lxml.
|
||||
highlight_edits: false
|
||||
# Whether or not Matrix bot messages (type m.notice) should be bridged.
|
||||
bridge_notices: false
|
||||
# The maximum number of simultaneous Telegram deletions to handle.
|
||||
@@ -87,8 +84,13 @@ bridge:
|
||||
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
|
||||
# login website (see appservice.public config section)
|
||||
allow_matrix_login: true
|
||||
# Whether or not to allow creating portals from Telegram.
|
||||
authless_relaybot_portals: true
|
||||
# Use inline images instead of m.image to make rich captions possible.
|
||||
# N.B. Inline images are not supported on all clients (e.g. Riot iOS).
|
||||
inline_images: false
|
||||
# Whether or not to bridge plaintext highlights.
|
||||
# Only enable this if your displayname_template has some static part that the bridge can use to
|
||||
# reliably identify what is a plaintext highlight.
|
||||
plaintext_highlights: false
|
||||
|
||||
# The prefix for commands. Only required in non-management rooms.
|
||||
command_prefix: "!tg"
|
||||
@@ -108,6 +110,17 @@ bridge:
|
||||
"public.example.com": "full"
|
||||
"@admin:example.com": "admin"
|
||||
|
||||
# Options related to the message relay Telegram bot.
|
||||
relaybot:
|
||||
# Whether or not to allow creating portals from Telegram.
|
||||
authless_portals: true
|
||||
# Whether or not to allow Telegram group admins to use the bot commands.
|
||||
whitelist_group_admins: true
|
||||
# List of usernames/user IDs who are also allowed to use the bot commands.
|
||||
whitelist:
|
||||
- myusername
|
||||
- 12345678
|
||||
|
||||
# Telegram config
|
||||
telegram:
|
||||
# Get your own API keys at https://my.telegram.org/apps
|
||||
@@ -118,4 +131,4 @@ telegram:
|
||||
|
||||
# The version of the config. The bridge will read this and automatically update the config if
|
||||
# the schema has changed. For the latest version, check the example config.
|
||||
version: 1
|
||||
version: 2
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from .appservice import AppService
|
||||
from .errors import MatrixError, MatrixRequestError, IntentError
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||
@@ -1,179 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# matrix-appservice-python - A Matrix Application Service framework written in Python.
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Partly based on github.com/Cadair/python-appservice-framework (MIT license)
|
||||
from contextlib import contextmanager
|
||||
from aiohttp import web
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from .intent_api import HTTPAPI
|
||||
from .state_store import StateStore
|
||||
|
||||
|
||||
class AppService:
|
||||
def __init__(self, server, domain, as_token, hs_token, bot_localpart, loop=None, log=None,
|
||||
query_user=None, query_alias=None):
|
||||
self.server = server
|
||||
self.domain = domain
|
||||
self.as_token = as_token
|
||||
self.hs_token = hs_token
|
||||
self.bot_mxid = f"@{bot_localpart}:{domain}"
|
||||
self.state_store = StateStore(autosave_file="mx-state.json")
|
||||
self.state_store.load("mx-state.json")
|
||||
|
||||
self.transactions = []
|
||||
|
||||
self._http_session = None
|
||||
self._intent = None
|
||||
|
||||
self.loop = loop or asyncio.get_event_loop()
|
||||
self.log = (logging.getLogger(log) if isinstance(log, str)
|
||||
else log or logging.getLogger("mautrix_appservice"))
|
||||
|
||||
async def default_query_handler(_):
|
||||
return None
|
||||
|
||||
self.query_user = query_user or default_query_handler
|
||||
self.query_alias = query_alias or default_query_handler
|
||||
|
||||
self.event_handlers = []
|
||||
|
||||
self.app = web.Application(loop=self.loop)
|
||||
self.app.router.add_route("PUT", "/transactions/{transaction_id}",
|
||||
self._http_handle_transaction)
|
||||
self.app.router.add_route("GET", "/rooms/{alias}", self._http_query_alias)
|
||||
self.app.router.add_route("GET", "/users/{user_id}", self._http_query_user)
|
||||
|
||||
self.matrix_event_handler(self.update_state_store)
|
||||
|
||||
@property
|
||||
def http_session(self):
|
||||
if self._http_session is None:
|
||||
raise AttributeError("the http_session attribute can only be used "
|
||||
"from within the `AppService.run` context manager")
|
||||
else:
|
||||
return self._http_session
|
||||
|
||||
@property
|
||||
def intent(self):
|
||||
if self._intent is None:
|
||||
raise AttributeError("the intent attribute can only be used from "
|
||||
"within the `AppService.run` context manager")
|
||||
else:
|
||||
return self._intent
|
||||
|
||||
@contextmanager
|
||||
def run(self, host="127.0.0.1", port=8080):
|
||||
self._http_session = aiohttp.ClientSession(loop=self.loop)
|
||||
self._intent = HTTPAPI(base_url=self.server, domain=self.domain, bot_mxid=self.bot_mxid,
|
||||
token=self.as_token, log=self.log, state_store=self.state_store,
|
||||
client_session=self._http_session).bot_intent()
|
||||
|
||||
yield self.loop.create_server(self.app.make_handler(), host, port)
|
||||
|
||||
self._intent = None
|
||||
self._http_session.close()
|
||||
self._http_session = None
|
||||
|
||||
def _check_token(self, request):
|
||||
try:
|
||||
token = request.rel_url.query["access_token"]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
if token != self.hs_token:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _http_query_user(self, request):
|
||||
if not self._check_token(request):
|
||||
return web.Response(status=401)
|
||||
|
||||
user_id = request.match_info["userId"]
|
||||
|
||||
try:
|
||||
response = await self.query_user(user_id)
|
||||
except Exception:
|
||||
self.log.exception("Exception in user query handler")
|
||||
return web.Response(status=500)
|
||||
|
||||
if not response:
|
||||
return web.Response(status=404)
|
||||
return web.json_response(response)
|
||||
|
||||
async def _http_query_alias(self, request):
|
||||
if not self._check_token(request):
|
||||
return web.Response(status=401)
|
||||
|
||||
alias = request.match_info["alias"]
|
||||
|
||||
try:
|
||||
response = await self.query_alias(alias)
|
||||
except Exception:
|
||||
self.log.exception("Exception in alias query handler")
|
||||
return web.Response(status=500)
|
||||
|
||||
if not response:
|
||||
return web.Response(status=404)
|
||||
return web.json_response(response)
|
||||
|
||||
async def _http_handle_transaction(self, request):
|
||||
if not self._check_token(request):
|
||||
return web.Response(status=401)
|
||||
|
||||
transaction_id = request.match_info["transaction_id"]
|
||||
if transaction_id in self.transactions:
|
||||
return web.Response(status=200)
|
||||
|
||||
json = await request.json()
|
||||
|
||||
try:
|
||||
events = json["events"]
|
||||
except KeyError:
|
||||
return web.Response(status=400)
|
||||
|
||||
for event in events:
|
||||
self.handle_matrix_event(event)
|
||||
|
||||
self.transactions.append(transaction_id)
|
||||
|
||||
return web.json_response({})
|
||||
|
||||
async def update_state_store(self, event):
|
||||
event_type = event["type"]
|
||||
if event_type == "m.room.power_levels":
|
||||
self.state_store.set_power_levels(event["room_id"], event["content"])
|
||||
elif event_type == "m.room.member":
|
||||
self.state_store.set_membership(event["room_id"], event["state_key"],
|
||||
event["content"]["membership"])
|
||||
|
||||
def handle_matrix_event(self, event):
|
||||
async def try_handle(handler):
|
||||
try:
|
||||
await handler(event)
|
||||
except Exception:
|
||||
self.log.exception("Exception in Matrix event handler")
|
||||
|
||||
for handler in self.event_handlers:
|
||||
asyncio.ensure_future(try_handle(handler), loop=self.loop)
|
||||
|
||||
def matrix_event_handler(self, func):
|
||||
self.event_handlers.append(func)
|
||||
return func
|
||||
@@ -1,38 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
class MatrixError(Exception):
|
||||
"""A generic Matrix error. Specific errors will subclass this."""
|
||||
pass
|
||||
|
||||
|
||||
class IntentError(MatrixError):
|
||||
def __init__(self, message, source):
|
||||
super().__init__(message)
|
||||
self.source = source
|
||||
|
||||
|
||||
class MatrixRequestError(MatrixError):
|
||||
""" The home server returned an error response. """
|
||||
|
||||
def __init__(self, code=0, text="", errcode=None, message=None):
|
||||
super().__init__(f"{code}: {text}")
|
||||
self.code = code
|
||||
self.text = text
|
||||
self.errcode = errcode
|
||||
self.message = message
|
||||
@@ -1,587 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
from urllib.parse import quote
|
||||
from time import time
|
||||
from json.decoder import JSONDecodeError
|
||||
from aiohttp.client_exceptions import ContentTypeError
|
||||
import re
|
||||
import json
|
||||
import magic
|
||||
import asyncio
|
||||
|
||||
from .errors import MatrixError, MatrixRequestError, IntentError
|
||||
|
||||
|
||||
class HTTPAPI:
|
||||
def __init__(self, base_url, domain=None, bot_mxid=None, token=None, identity=None, log=None,
|
||||
state_store=None, client_session=None, child=False):
|
||||
self.base_url = base_url
|
||||
self.token = token
|
||||
self.identity = identity
|
||||
self.validate_cert = True
|
||||
self.session = client_session
|
||||
|
||||
self.domain = domain
|
||||
self.bot_mxid = bot_mxid
|
||||
self._bot_intent = None
|
||||
self.state_store = state_store
|
||||
|
||||
if child:
|
||||
self.log = log
|
||||
else:
|
||||
self.intent_log = log.getChild("intent")
|
||||
self.log = log.getChild("api")
|
||||
self.txn_id = 0
|
||||
self.children = {}
|
||||
|
||||
def user(self, user):
|
||||
try:
|
||||
return self.children[user]
|
||||
except KeyError:
|
||||
child = ChildHTTPAPI(user, self)
|
||||
self.children[user] = child
|
||||
return child
|
||||
|
||||
def bot_intent(self):
|
||||
if self._bot_intent:
|
||||
return self._bot_intent
|
||||
return IntentAPI(self.bot_mxid, self, state_store=self.state_store, log=self.intent_log)
|
||||
|
||||
def intent(self, user):
|
||||
return IntentAPI(user, self.user(user), self.bot_intent(), self.state_store,
|
||||
self.intent_log)
|
||||
|
||||
async def _send(self, method, endpoint, content, query_params, headers):
|
||||
while True:
|
||||
query_params["access_token"] = self.token
|
||||
request = self.session.request(method, endpoint, params=query_params,
|
||||
data=content, headers=headers)
|
||||
async with request as response:
|
||||
if response.status < 200 or response.status >= 300:
|
||||
errcode = message = None
|
||||
try:
|
||||
response_data = await response.json()
|
||||
errcode = response_data["errcode"]
|
||||
message = response_data["error"]
|
||||
except (JSONDecodeError, ContentTypeError, KeyError):
|
||||
pass
|
||||
raise MatrixRequestError(code=response.status, text=await response.text(),
|
||||
errcode=errcode, message=message)
|
||||
|
||||
if response.status == 429:
|
||||
await asyncio.sleep(response.json()["retry_after_ms"] / 1000)
|
||||
else:
|
||||
return await response.json()
|
||||
|
||||
def _log_request(self, method, path, content, query_params):
|
||||
log_content = content if not isinstance(content, bytes) else f"<{len(content)} bytes>"
|
||||
log_content = log_content or "(No content)"
|
||||
query_identity = query_params["user_id"] if "user_id" in query_params else "No identity"
|
||||
self.log.debug("%s %s %s as user %s", method, path, log_content, query_identity)
|
||||
|
||||
def request(self, method, path, content=None, query_params=None, headers=None,
|
||||
api_path="/_matrix/client/r0"):
|
||||
content = content or {}
|
||||
query_params = query_params or {}
|
||||
headers = headers or {}
|
||||
|
||||
method = method.upper()
|
||||
if method not in ["GET", "PUT", "DELETE", "POST"]:
|
||||
raise MatrixError("Unsupported HTTP method: %s" % method)
|
||||
|
||||
if "Content-Type" not in headers:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if headers["Content-Type"] == "application/json":
|
||||
content = json.dumps(content)
|
||||
|
||||
if self.identity:
|
||||
query_params["user_id"] = self.identity
|
||||
|
||||
self._log_request(method, path, content, query_params)
|
||||
|
||||
endpoint = self.base_url + api_path + path
|
||||
return self._send(method, endpoint, content, query_params, headers or {})
|
||||
|
||||
def get_download_url(self, mxcurl):
|
||||
if mxcurl.startswith('mxc://'):
|
||||
return f"{self.base_url}/_matrix/media/r0/download/{mxcurl[6:]}"
|
||||
else:
|
||||
raise ValueError("MXC URL did not begin with 'mxc://'")
|
||||
|
||||
async def get_display_name(self, user_id):
|
||||
content = await self.request("GET", f"/profile/{user_id}/displayname")
|
||||
return content.get('displayname', None)
|
||||
|
||||
async def get_avatar_url(self, user_id):
|
||||
content = await self.request("GET", f"/profile/{user_id}/avatar_url")
|
||||
return content.get('avatar_url', None)
|
||||
|
||||
async def get_room_id(self, room_alias):
|
||||
content = await self.request("GET", f"/directory/room/{quote(room_alias)}")
|
||||
return content.get("room_id", None)
|
||||
|
||||
def set_typing(self, room_id, is_typing=True, timeout=5000, user=None):
|
||||
content = {
|
||||
"typing": is_typing
|
||||
}
|
||||
if is_typing:
|
||||
content["timeout"] = timeout
|
||||
user = user or self.identity
|
||||
return self.request("PUT", f"/rooms/{room_id}/typing/{user}", content)
|
||||
|
||||
|
||||
class ChildHTTPAPI(HTTPAPI):
|
||||
def __init__(self, user, parent):
|
||||
super().__init__(parent.base_url, parent.domain, parent.bot_mxid, parent.token, user,
|
||||
parent.log, parent.state_store, parent.session, child=True)
|
||||
self.parent = parent
|
||||
|
||||
@property
|
||||
def txn_id(self):
|
||||
return self.parent.txn_id
|
||||
|
||||
@txn_id.setter
|
||||
def txn_id(self, value):
|
||||
self.parent.txn_id = value
|
||||
|
||||
|
||||
class IntentAPI:
|
||||
mxid_regex = re.compile("@(.+):(.+)")
|
||||
|
||||
def __init__(self, mxid, client, bot=None, state_store=None, log=None):
|
||||
self.client = client
|
||||
self.bot = bot
|
||||
self.mxid = mxid
|
||||
self.log = log
|
||||
|
||||
results = self.mxid_regex.match(mxid)
|
||||
if not results:
|
||||
raise ValueError("invalid MXID")
|
||||
self.localpart = results.group(1)
|
||||
|
||||
self.state_store = state_store
|
||||
|
||||
def user(self, user):
|
||||
if not self.bot:
|
||||
return self.client.intent(user)
|
||||
else:
|
||||
self.log.warning("Called IntentAPI#user() of child intent object.")
|
||||
return self.bot.client.intent(user)
|
||||
|
||||
# region User actions
|
||||
|
||||
async def get_joined_rooms(self):
|
||||
await self.ensure_registered()
|
||||
response = await self.client.request("GET", "/joined_rooms")
|
||||
return response["joined_rooms"]
|
||||
|
||||
async def set_display_name(self, name):
|
||||
await self.ensure_registered()
|
||||
content = {"displayname": name}
|
||||
return await self.client.request("PUT", f"/profile/{self.mxid}/displayname", content)
|
||||
|
||||
async def set_presence(self, status="online", ignore_cache=False):
|
||||
await self.ensure_registered()
|
||||
if not ignore_cache and self.state_store.has_presence(self.mxid, status):
|
||||
return
|
||||
content = {
|
||||
"presence": status
|
||||
}
|
||||
resp = await self.client.request("PUT", f"/presence/{self.mxid}/status", content)
|
||||
self.state_store.set_presence(self.mxid, status)
|
||||
return resp
|
||||
|
||||
async def set_avatar(self, url):
|
||||
await self.ensure_registered()
|
||||
content = {"avatar_url": url}
|
||||
return await self.client.request("PUT", f"/profile/{self.mxid}/avatar_url", content)
|
||||
|
||||
async def upload_file(self, data, mime_type=None):
|
||||
await self.ensure_registered()
|
||||
mime_type = mime_type or magic.from_buffer(data, mime=True)
|
||||
return await self.client.request("POST", "", content=data,
|
||||
headers={"Content-Type": mime_type},
|
||||
api_path="/_matrix/media/r0/upload")
|
||||
|
||||
async def download_file(self, url):
|
||||
await self.ensure_registered()
|
||||
url = self.client.get_download_url(url)
|
||||
async with self.client.session.get(url) as response:
|
||||
return await response.read()
|
||||
|
||||
# endregion
|
||||
# region Room actions
|
||||
|
||||
async def create_room(self, alias=None, is_public=False, name=None, topic=None,
|
||||
is_direct=False, invitees=None, initial_state=None,
|
||||
guests_can_join=False):
|
||||
await self.ensure_registered()
|
||||
content = {
|
||||
"visibility": "private",
|
||||
"is_direct": is_direct,
|
||||
"preset": "public_chat" if is_public else "private_chat",
|
||||
"guests_can_join": guests_can_join,
|
||||
}
|
||||
if alias:
|
||||
content["room_alias_name"] = alias
|
||||
if invitees:
|
||||
content["invite"] = invitees
|
||||
if name:
|
||||
content["name"] = name
|
||||
if topic:
|
||||
content["topic"] = topic
|
||||
if initial_state:
|
||||
content["initial_state"] = initial_state
|
||||
|
||||
return await self.client.request("POST", "/createRoom", content)
|
||||
|
||||
def _invite_direct(self, room_id, user_id):
|
||||
content = {"user_id": user_id}
|
||||
return self.client.request("POST", "/rooms/" + room_id + "/invite", content)
|
||||
|
||||
async def invite(self, room_id, user_id, check_cache=False):
|
||||
await self.ensure_joined(room_id)
|
||||
try:
|
||||
ok_states = {"invite", "join"}
|
||||
do_invite = (not check_cache
|
||||
or self.state_store.get_membership(room_id, user_id) not in ok_states)
|
||||
if do_invite:
|
||||
response = await self._invite_direct(room_id, user_id)
|
||||
self.state_store.invited(room_id, user_id)
|
||||
return response
|
||||
except MatrixRequestError as e:
|
||||
if e.errcode != "M_FORBIDDEN":
|
||||
raise IntentError(f"Failed to invite {user_id} to {room_id}", e)
|
||||
if "is already in the room" in e.message:
|
||||
self.state_store.joined(room_id, user_id)
|
||||
|
||||
def set_room_avatar(self, room_id, avatar_url, info=None):
|
||||
content = {
|
||||
"url": avatar_url,
|
||||
}
|
||||
if info:
|
||||
content["info"] = info
|
||||
return self.send_state_event(room_id, "m.room.avatar", content)
|
||||
|
||||
async def add_room_alias(self, room_id, localpart):
|
||||
await self.ensure_registered()
|
||||
content = {"room_id": room_id}
|
||||
alias = f"#{localpart}:{self.client.domain}"
|
||||
return await self.client.request("PUT", f"/directory/room/{quote(alias)}", content)
|
||||
|
||||
async def remove_room_alias(self, localpart):
|
||||
await self.ensure_registered()
|
||||
alias = f"#{localpart}:{self.client.domain}"
|
||||
return await self.client.request("DELETE", f"/directory/room/{quote(alias)}")
|
||||
|
||||
def set_room_name(self, room_id, name):
|
||||
body = {"name": name}
|
||||
return self.send_state_event(room_id, "m.room.name", body)
|
||||
|
||||
async def get_power_levels(self, room_id, ignore_cache=False):
|
||||
await self.ensure_joined(room_id)
|
||||
if not ignore_cache:
|
||||
try:
|
||||
return self.state_store.get_power_levels(room_id)
|
||||
except KeyError:
|
||||
pass
|
||||
levels = await self.client.request("GET",
|
||||
f"/rooms/{quote(room_id)}/state/m.room.power_levels")
|
||||
self.state_store.set_power_levels(room_id, levels)
|
||||
return levels
|
||||
|
||||
async def set_power_levels(self, room_id, content):
|
||||
if "events" not in content:
|
||||
content["events"] = {}
|
||||
response = await self.send_state_event(room_id, "m.room.power_levels", content)
|
||||
self.state_store.set_power_levels(room_id, content)
|
||||
return response
|
||||
|
||||
async def get_pinned_messages(self, room_id):
|
||||
await self.ensure_joined(room_id)
|
||||
response = await self.client.request("GET", f"/rooms/{room_id}/state/m.room.pinned_events")
|
||||
return response["content"]["pinned"]
|
||||
|
||||
def set_pinned_messages(self, room_id, events):
|
||||
return self.send_state_event(room_id, "m.room.pinned_events", {
|
||||
"pinned": events
|
||||
})
|
||||
|
||||
async def pin_message(self, room_id, event_id):
|
||||
events = await self.get_pinned_messages(room_id)
|
||||
if event_id not in events:
|
||||
events.append(event_id)
|
||||
await self.set_pinned_messages(room_id, events)
|
||||
|
||||
async def unpin_message(self, room_id, event_id):
|
||||
events = await self.get_pinned_messages(room_id)
|
||||
if event_id in events:
|
||||
events.remove(event_id)
|
||||
await self.set_pinned_messages(room_id, events)
|
||||
|
||||
async def set_join_rule(self, room_id, join_rule):
|
||||
if join_rule not in ("public", "knock", "invite", "private"):
|
||||
raise ValueError(f"Invalid join rule \"{join_rule}\"")
|
||||
await self.send_state_event(room_id, "m.room.join_rules", {
|
||||
"join_rule": join_rule,
|
||||
})
|
||||
|
||||
async def get_event(self, room_id, event_id):
|
||||
await self.ensure_joined(room_id)
|
||||
return await self.client.request("GET", f"/rooms/{room_id}/event/{event_id}")
|
||||
|
||||
async def set_typing(self, room_id, is_typing=True, timeout=5000, ignore_cache=False):
|
||||
await self.ensure_joined(room_id)
|
||||
if not ignore_cache and is_typing == self.state_store.is_typing(room_id, self.mxid):
|
||||
return
|
||||
content = {
|
||||
"typing": is_typing
|
||||
}
|
||||
if is_typing:
|
||||
content["timeout"] = timeout
|
||||
resp = await self.client.request("PUT", f"/rooms/{room_id}/typing/{self.mxid}", content)
|
||||
self.state_store.set_typing(room_id, self.mxid, is_typing, timeout)
|
||||
return resp
|
||||
|
||||
async def mark_read(self, room_id, event_id):
|
||||
await self.ensure_joined(room_id)
|
||||
return await self.client.request("POST", f"/rooms/{room_id}/receipt/m.read/{event_id}",
|
||||
content={})
|
||||
|
||||
def send_notice(self, room_id, text, html=None, relates_to=None):
|
||||
return self.send_text(room_id, text, html, "m.notice", relates_to)
|
||||
|
||||
def send_emote(self, room_id, text, html=None, relates_to=None):
|
||||
return self.send_text(room_id, text, html, "m.emote", relates_to)
|
||||
|
||||
def send_image(self, room_id, url, info=None, text=None, relates_to=None):
|
||||
return self.send_file(room_id, url, info or {}, text, "m.image", relates_to)
|
||||
|
||||
def send_file(self, room_id, url, info=None, text=None, file_type="m.file", relates_to=None):
|
||||
return self.send_message(room_id, {
|
||||
"msgtype": file_type,
|
||||
"url": url,
|
||||
"body": text or "Uploaded file",
|
||||
"info": info or {},
|
||||
"m.relates_to": relates_to or None,
|
||||
})
|
||||
|
||||
def send_text(self, room_id, text, html=None, msgtype="m.text", relates_to=None):
|
||||
if html:
|
||||
if not text:
|
||||
text = html
|
||||
return self.send_message(room_id, {
|
||||
"body": text,
|
||||
"msgtype": msgtype,
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": html or text,
|
||||
"m.relates_to": relates_to or None,
|
||||
})
|
||||
else:
|
||||
return self.send_message(room_id, {
|
||||
"body": text,
|
||||
"msgtype": msgtype,
|
||||
"m.relates_to": relates_to or None,
|
||||
})
|
||||
|
||||
def send_message(self, room_id, body):
|
||||
return self.send_event(room_id, "m.room.message", body)
|
||||
|
||||
async def error_and_leave(self, room_id, text, html=None):
|
||||
await self.ensure_joined(room_id)
|
||||
await self.send_notice(room_id, text, html=html)
|
||||
await self.leave_room(room_id)
|
||||
|
||||
def kick(self, room_id, user_id, message):
|
||||
return self.set_membership(room_id, user_id, "leave", message)
|
||||
|
||||
def get_membership(self, room_id, user_id):
|
||||
return self.get_state_event(room_id, "m.room.member", state_key=user_id)
|
||||
|
||||
def set_membership(self, room_id, user_id, membership, reason="", profile=None):
|
||||
body = {
|
||||
"membership": membership,
|
||||
"reason": reason
|
||||
}
|
||||
profile = profile or {}
|
||||
if "displayname" in profile:
|
||||
body["displayname"] = profile["displayname"]
|
||||
if "avatar_url" in profile:
|
||||
body["avatar_url"] = profile["avatar_url"]
|
||||
|
||||
return self.send_state_event(room_id, "m.room.member", body, state_key=user_id)
|
||||
|
||||
def redact(self, room_id, event_id, reason=None, txn_id=None):
|
||||
txn_id = txn_id or str(self.client.txn_id) + str(int(time() * 1000))
|
||||
self.client.txn_id += 1
|
||||
content = {}
|
||||
if reason:
|
||||
content["reason"] = reason
|
||||
return self.client.request("PUT",
|
||||
f"/rooms/{quote(room_id)}/redact/{quote(event_id)}/{txn_id}",
|
||||
content)
|
||||
|
||||
@staticmethod
|
||||
def _get_event_url(room_id, event_type, txn_id):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
elif not event_type:
|
||||
raise ValueError("Event type not given")
|
||||
elif not txn_id:
|
||||
raise ValueError("Transaction ID not given")
|
||||
return f"/rooms/{quote(room_id)}/send/{quote(event_type)}/{quote(txn_id)}"
|
||||
|
||||
async def send_event(self, room_id, event_type, content, txn_id=None):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
elif not event_type:
|
||||
raise ValueError("Event type not given")
|
||||
await self.ensure_joined(room_id)
|
||||
await self._ensure_has_power_level_for(room_id, event_type)
|
||||
|
||||
txn_id = txn_id or str(self.client.txn_id) + str(int(time() * 1000))
|
||||
self.client.txn_id += 1
|
||||
|
||||
url = self._get_event_url(room_id, event_type, txn_id)
|
||||
|
||||
return await self.client.request("PUT", url, content)
|
||||
|
||||
@staticmethod
|
||||
def _get_state_url(room_id, event_type, state_key=""):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
elif not event_type:
|
||||
raise ValueError("Event type not given")
|
||||
url = f"/rooms/{quote(room_id)}/state/{quote(event_type)}"
|
||||
if state_key:
|
||||
url += f"/{quote(state_key)}"
|
||||
return url
|
||||
|
||||
async def send_state_event(self, room_id, event_type, content, state_key=""):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
elif not event_type:
|
||||
raise ValueError("Event type not given")
|
||||
await self.ensure_joined(room_id)
|
||||
await self._ensure_has_power_level_for(room_id, event_type, is_state_event=True)
|
||||
url = self._get_state_url(room_id, event_type, state_key)
|
||||
return await self.client.request("PUT", url, content)
|
||||
|
||||
async def get_state_event(self, room_id, event_type, state_key=""):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
elif not event_type:
|
||||
raise ValueError("Event type not given")
|
||||
await self.ensure_joined(room_id)
|
||||
url = self._get_state_url(room_id, event_type, state_key)
|
||||
return await self.client.request("GET", url)
|
||||
|
||||
def join_room(self, room_id):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
return self.ensure_joined(room_id, ignore_cache=True)
|
||||
|
||||
def _join_room_direct(self, room):
|
||||
if not room:
|
||||
raise ValueError("Room ID not given")
|
||||
return self.client.request("POST", f"/join/{quote(room)}")
|
||||
|
||||
def leave_room(self, room_id):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
try:
|
||||
self.state_store.left(room_id, self.mxid)
|
||||
return self.client.request("POST", f"/rooms/{quote(room_id)}/leave")
|
||||
except MatrixRequestError as e:
|
||||
if "not in room" not in e.message:
|
||||
raise
|
||||
|
||||
def get_room_memberships(self, room_id):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
return self.client.request("GET", f"/rooms/{quote(room_id)}/members")
|
||||
|
||||
async def get_room_members(self, room_id, allowed_memberships=("join",)):
|
||||
memberships = await self.get_room_memberships(room_id)
|
||||
return [membership["state_key"] for membership in memberships["chunk"] if
|
||||
membership["content"]["membership"] in allowed_memberships]
|
||||
|
||||
async def get_room_state(self, room_id):
|
||||
await self.ensure_joined(room_id)
|
||||
state = await self.client.request("GET", f"/rooms/{quote(room_id)}/state")
|
||||
# TODO update values based on state?
|
||||
return state
|
||||
|
||||
# endregion
|
||||
# region Ensure functions
|
||||
|
||||
async def ensure_joined(self, room_id, ignore_cache=False):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
if not ignore_cache and self.state_store.is_joined(room_id, self.mxid):
|
||||
return
|
||||
await self.ensure_registered()
|
||||
try:
|
||||
await self._join_room_direct(room_id)
|
||||
self.state_store.joined(room_id, self.mxid)
|
||||
except MatrixRequestError as e:
|
||||
if e.errcode != "M_FORBIDDEN" or not self.bot:
|
||||
raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e)
|
||||
try:
|
||||
await self.bot.invite(room_id, self.mxid)
|
||||
await self._join_room_direct(room_id)
|
||||
self.state_store.joined(room_id, self.mxid)
|
||||
except MatrixRequestError as e2:
|
||||
raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e2)
|
||||
|
||||
def _register(self):
|
||||
content = {"username": self.localpart}
|
||||
query_params = {"kind": "user"}
|
||||
return self.client.request("POST", "/register", content, query_params)
|
||||
|
||||
async def ensure_registered(self):
|
||||
if self.state_store.is_registered(self.mxid):
|
||||
return
|
||||
try:
|
||||
await self._register()
|
||||
except MatrixRequestError as e:
|
||||
if e.errcode != "M_USER_IN_USE":
|
||||
self.log.exception(f"Failed to register {self.mxid}!")
|
||||
# raise IntentError(f"Failed to register {self.mxid}", e)
|
||||
return
|
||||
self.state_store.registered(self.mxid)
|
||||
|
||||
async def _ensure_has_power_level_for(self, room_id, event_type, is_state_event=False):
|
||||
if not room_id:
|
||||
raise ValueError("Room ID not given")
|
||||
elif not event_type:
|
||||
raise ValueError("Event type not given")
|
||||
|
||||
if not self.state_store.has_power_levels(room_id):
|
||||
await self.get_power_levels(room_id)
|
||||
if self.state_store.has_power_level(room_id, self.mxid, event_type,
|
||||
is_state_event=is_state_event):
|
||||
return
|
||||
elif not self.bot:
|
||||
self.log.warning(
|
||||
f"Power level of {self.mxid} is not enough for {event_type} in {room_id}")
|
||||
# raise IntentError(f"Power level of {self.mxid} is not enough"
|
||||
# f"for {event_type} in {room_id}")
|
||||
return
|
||||
# TODO implement
|
||||
|
||||
# endregion
|
||||
@@ -1,155 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# matrix-appservice-python - A Matrix Application Service framework written in Python.
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
class StateStore:
|
||||
def __init__(self, autosave_file=None):
|
||||
self.autosave_file = autosave_file
|
||||
|
||||
# Persistent storage
|
||||
self.registrations = set()
|
||||
self.memberships = {}
|
||||
self.power_levels = {}
|
||||
|
||||
# Non-persistent storage
|
||||
self.presence = {}
|
||||
self.typing = {}
|
||||
|
||||
def save(self, file):
|
||||
if isinstance(file, str):
|
||||
output = open(file, "w")
|
||||
else:
|
||||
output = file
|
||||
|
||||
json.dump({
|
||||
"registrations": list(self.registrations),
|
||||
"memberships": self.memberships,
|
||||
"power_levels": self.power_levels,
|
||||
}, output)
|
||||
|
||||
if isinstance(file, str):
|
||||
output.close()
|
||||
|
||||
def load(self, file):
|
||||
if isinstance(file, str):
|
||||
try:
|
||||
input_source = open(file, "r")
|
||||
except FileNotFoundError:
|
||||
return
|
||||
else:
|
||||
input_source = file
|
||||
|
||||
data = json.load(input_source)
|
||||
if "registrations" in data:
|
||||
self.registrations = set(data["registrations"])
|
||||
if "memberships" in data:
|
||||
self.memberships = data["memberships"]
|
||||
if "power_levels" in data:
|
||||
self.power_levels = data["power_levels"]
|
||||
|
||||
if isinstance(file, str):
|
||||
input_source.close()
|
||||
|
||||
def _autosave(self):
|
||||
if self.autosave_file:
|
||||
self.save(self.autosave_file)
|
||||
|
||||
def set_presence(self, user, presence):
|
||||
self.presence[user] = presence
|
||||
|
||||
def has_presence(self, user, presence):
|
||||
try:
|
||||
return self.presence[user] == presence
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def set_typing(self, room_id, user, is_typing, timeout=0):
|
||||
if is_typing:
|
||||
ts = int(round(time.time() * 1000))
|
||||
self.typing[(room_id, user)] = ts + timeout
|
||||
else:
|
||||
del self.typing[(room_id, user)]
|
||||
|
||||
def is_typing(self, room_id, user):
|
||||
ts = int(round(time.time() * 1000))
|
||||
try:
|
||||
return self.typing[(room_id, user)] > ts
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def is_registered(self, user):
|
||||
return user in self.registrations
|
||||
|
||||
def registered(self, user):
|
||||
self.registrations.add(user)
|
||||
self._autosave()
|
||||
|
||||
def get_membership(self, room, user):
|
||||
return self.memberships.get(room, {}).get(user, "left")
|
||||
|
||||
def is_joined(self, room, user):
|
||||
return self.get_membership(room, user) == "join"
|
||||
|
||||
def set_membership(self, room, user, membership):
|
||||
if room not in self.memberships:
|
||||
self.memberships[room] = {}
|
||||
self.memberships[room][user] = membership
|
||||
self._autosave()
|
||||
|
||||
def joined(self, room, user):
|
||||
return self.set_membership(room, user, "join")
|
||||
|
||||
def invited(self, room, user):
|
||||
return self.set_membership(room, user, "invite")
|
||||
|
||||
def left(self, room, user):
|
||||
return self.set_membership(room, user, "left")
|
||||
|
||||
def has_power_levels(self, room):
|
||||
return room in self.power_levels
|
||||
|
||||
def get_power_levels(self, room):
|
||||
return self.power_levels[room]
|
||||
|
||||
def has_power_level(self, room, user, event, is_state_event=False, default=None):
|
||||
room_levels = self.power_levels.get(room, {})
|
||||
default_required = default or (room_levels.get("state_default", 50) if is_state_event
|
||||
else room_levels.get("events_default", 0))
|
||||
required = room_levels.get("events", {}).get(event, default_required)
|
||||
has = room_levels.get("users", {}).get(user, room_levels.get("users_default", 0))
|
||||
return has >= required
|
||||
|
||||
def set_power_level(self, room, user, level):
|
||||
if room not in self.power_levels:
|
||||
self.power_levels[room] = {
|
||||
"users": {},
|
||||
"events": {},
|
||||
}
|
||||
elif "users" not in self.power_levels[room]:
|
||||
self.power_levels[room]["users"] = {}
|
||||
self.power_levels[room]["users"][user] = level
|
||||
self._autosave()
|
||||
|
||||
def set_power_levels(self, room, content):
|
||||
if "events" not in content:
|
||||
content["events"] = {}
|
||||
if "users" not in content:
|
||||
content["users"] = {}
|
||||
self.power_levels[room] = content
|
||||
self._autosave()
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import argparse
|
||||
import sys
|
||||
import logging
|
||||
@@ -35,6 +35,7 @@ from .user import init as init_user, User
|
||||
from .bot import init as init_bot
|
||||
from .portal import init as init_portal
|
||||
from .puppet import init as init_puppet
|
||||
from .formatter import init as init_formatter
|
||||
from .public import PublicBridgeWebsite
|
||||
from .context import Context
|
||||
|
||||
@@ -85,7 +86,8 @@ loop = asyncio.get_event_loop()
|
||||
|
||||
appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
|
||||
config["appservice.as_token"], config["appservice.hs_token"],
|
||||
config["appservice.bot_username"], log="mau.as", loop=loop)
|
||||
config["appservice.bot_username"], log="mau.as", loop=loop,
|
||||
verify_ssl=config["homeserver.verify_ssl"])
|
||||
|
||||
context = Context(appserv, db_session, config, loop, None, None, telethon_session_container)
|
||||
|
||||
@@ -98,6 +100,7 @@ with appserv.run(config["appservice.hostname"], config["appservice.port"]) as st
|
||||
init_abstract_user(context)
|
||||
context.bot = init_bot(context)
|
||||
context.mx = MatrixHandler(context)
|
||||
init_formatter(context)
|
||||
init_portal(context)
|
||||
init_puppet(context)
|
||||
startup_actions = init_user(context) + [start, context.mx.init_as_bot()]
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import platform
|
||||
import os
|
||||
|
||||
@@ -136,7 +136,7 @@ class AbstractUser:
|
||||
async def update_pinned_messages(self, update):
|
||||
portal = po.Portal.get_by_tgid(update.channel_id)
|
||||
if portal and portal.mxid:
|
||||
await portal.update_telegram_pin(self, update.id)
|
||||
await portal.receive_telegram_pin_id(update.id)
|
||||
|
||||
async def update_participants(self, update):
|
||||
portal = po.Portal.get_by_tgid(update.participants.chat_id)
|
||||
|
||||
+69
-21
@@ -3,23 +3,24 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Awaitable, Callable
|
||||
import logging
|
||||
import re
|
||||
|
||||
from telethon_aio.tl.types import *
|
||||
from telethon_aio.tl.functions.messages import GetChatsRequest
|
||||
from telethon_aio.tl.functions.channels import GetChannelsRequest
|
||||
from telethon_aio.tl.functions.messages import GetChatsRequest, GetFullChatRequest
|
||||
from telethon_aio.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
|
||||
from telethon_aio.errors import ChannelInvalidError, ChannelPrivateError
|
||||
|
||||
from .abstract_user import AbstractUser
|
||||
@@ -29,16 +30,33 @@ from . import puppet as pu, portal as po, user as u
|
||||
config = None
|
||||
|
||||
|
||||
ReplyFunc = Callable[[str], Awaitable[Message]]
|
||||
|
||||
|
||||
class Bot(AbstractUser):
|
||||
log = logging.getLogger("mau.bot")
|
||||
mxid_regex = re.compile("@.+:.+")
|
||||
|
||||
def __init__(self, token):
|
||||
def __init__(self, token: str):
|
||||
super().__init__()
|
||||
self.token = token
|
||||
self.whitelisted = True
|
||||
self.username = None
|
||||
self.chats = {chat.id: chat.type for chat in BotChat.query.all()}
|
||||
self.tg_whitelist = []
|
||||
self.whitelist_group_admins = config["bridge.relaybot.whitelist_group_admins"] or False
|
||||
|
||||
async def init_permissions(self):
|
||||
whitelist = config["bridge.relaybot.whitelist"] or []
|
||||
for id in whitelist:
|
||||
if isinstance(id, str):
|
||||
entity = await self.client.get_input_entity(id)
|
||||
if isinstance(entity, InputUser):
|
||||
id = entity.user_id
|
||||
else:
|
||||
id = None
|
||||
if isinstance(id, int):
|
||||
self.tg_whitelist.append(id)
|
||||
|
||||
async def start(self):
|
||||
await super().start()
|
||||
@@ -48,6 +66,7 @@ class Bot(AbstractUser):
|
||||
return self
|
||||
|
||||
async def post_login(self):
|
||||
await self.init_permissions()
|
||||
info = await self.client.get_me()
|
||||
self.tgid = info.id
|
||||
self.username = info.username
|
||||
@@ -68,19 +87,19 @@ class Bot(AbstractUser):
|
||||
except (ChannelPrivateError, ChannelInvalidError):
|
||||
self.remove_chat(id.channel_id)
|
||||
|
||||
def register_portal(self, portal):
|
||||
def register_portal(self, portal: po.Portal):
|
||||
self.add_chat(portal.tgid, portal.peer_type)
|
||||
|
||||
def unregister_portal(self, portal):
|
||||
def unregister_portal(self, portal: po.Portal):
|
||||
self.remove_chat(portal.tgid)
|
||||
|
||||
def add_chat(self, id, type):
|
||||
def add_chat(self, id: int, type: str):
|
||||
if id not in self.chats:
|
||||
self.chats[id] = type
|
||||
self.db.add(BotChat(id=id, type=type))
|
||||
self.db.commit()
|
||||
|
||||
def remove_chat(self, id):
|
||||
def remove_chat(self, id: int):
|
||||
try:
|
||||
del self.chats[id]
|
||||
except KeyError:
|
||||
@@ -88,8 +107,34 @@ class Bot(AbstractUser):
|
||||
self.db.delete(BotChat.query.get(id))
|
||||
self.db.commit()
|
||||
|
||||
async def handle_command_portal(self, portal, reply):
|
||||
if not config["bridge.authless_relaybot_portals"]:
|
||||
async def _can_use_commands(self, chat, tgid):
|
||||
if tgid in self.tg_whitelist:
|
||||
return True
|
||||
|
||||
user = u.User.get_by_tgid(tgid)
|
||||
if user and user.is_admin:
|
||||
self.tg_whitelist.append(user.tgid)
|
||||
return True
|
||||
|
||||
if self.whitelist_group_admins:
|
||||
if isinstance(chat, PeerChannel):
|
||||
p = await self.client(GetParticipantRequest(chat, tgid))
|
||||
return isinstance(p, (ChannelParticipantCreator, ChannelParticipantAdmin))
|
||||
elif isinstance(chat, PeerChat):
|
||||
chat = await self.client(GetFullChatRequest(chat.chat_id))
|
||||
participants = chat.full_chat.participants.participants
|
||||
for p in participants:
|
||||
if p.user_id == tgid:
|
||||
return isinstance(p, (ChatParticipantCreator, ChatParticipantAdmin))
|
||||
|
||||
async def check_can_use_commands(self, event: Message, reply: ReplyFunc):
|
||||
if not await self._can_use_commands(event.to_id, event.from_id):
|
||||
await reply("You do not have the permission to use that command.")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc):
|
||||
if not config["bridge.relaybot.authless_portals"]:
|
||||
return await reply("This bridge doesn't allow portal creation from Telegram.")
|
||||
|
||||
await portal.create_matrix_room(self)
|
||||
@@ -101,7 +146,7 @@ class Bot(AbstractUser):
|
||||
return await reply(
|
||||
"Portal is not public. Use `/invite <mxid>` to get an invite.")
|
||||
|
||||
async def handle_command_invite(self, portal, reply, mxid):
|
||||
async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc, mxid: str):
|
||||
if len(mxid) == 0:
|
||||
return await reply("Usage: `/invite <mxid>`")
|
||||
elif not portal.mxid:
|
||||
@@ -120,14 +165,14 @@ class Bot(AbstractUser):
|
||||
await portal.main_intent.invite(portal.mxid, user.mxid)
|
||||
return await reply(f"Invited `{user.mxid}` to the portal.")
|
||||
|
||||
def handle_command_id(self, message, reply):
|
||||
def handle_command_id(self, message: Message, reply: ReplyFunc):
|
||||
# Provide the prefixed ID to the user so that the user wouldn't need to specify whether the
|
||||
# chat is a normal group or a supergroup/channel when using the ID.
|
||||
if isinstance(message.to_id, PeerChannel):
|
||||
return reply(f"-100{message.to_id.channel_id}")
|
||||
return reply(str(-message.to_id.chat_id))
|
||||
|
||||
def match_command(self, text, command):
|
||||
def match_command(self, text: str, command: str) -> bool:
|
||||
text = text.lower()
|
||||
command = f"/{command.lower()}"
|
||||
command_targeted = f"{command}@{self.username.lower()}"
|
||||
@@ -142,7 +187,7 @@ class Bot(AbstractUser):
|
||||
|
||||
return False
|
||||
|
||||
async def handle_command(self, message):
|
||||
async def handle_command(self, message: Message):
|
||||
def reply(reply_text):
|
||||
return self.client.send_message(message.to_id, reply_text, markdown=True,
|
||||
reply_to=message.id)
|
||||
@@ -155,15 +200,19 @@ class Bot(AbstractUser):
|
||||
portal = po.Portal.get_by_entity(message.to_id)
|
||||
|
||||
if self.match_command(text, "portal"):
|
||||
if not await self.check_can_use_commands(message, reply):
|
||||
return
|
||||
await self.handle_command_portal(portal, reply)
|
||||
elif self.match_command(text, "invite"):
|
||||
if not await self.check_can_use_commands(message, reply):
|
||||
return
|
||||
try:
|
||||
mxid = text[text.index(" ") + 1:]
|
||||
except ValueError:
|
||||
mxid = ""
|
||||
await self.handle_command_invite(portal, reply, mxid=mxid)
|
||||
|
||||
def handle_service_message(self, message):
|
||||
def handle_service_message(self, message: MessageService):
|
||||
to_id = message.to_id
|
||||
if isinstance(to_id, PeerChannel):
|
||||
to_id = to_id.channel_id
|
||||
@@ -183,7 +232,6 @@ class Bot(AbstractUser):
|
||||
async def update(self, update):
|
||||
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
|
||||
return
|
||||
|
||||
if isinstance(update.message, MessageService):
|
||||
return self.handle_service_message(update.message)
|
||||
|
||||
@@ -193,11 +241,11 @@ class Bot(AbstractUser):
|
||||
if is_command:
|
||||
return await self.handle_command(update.message)
|
||||
|
||||
def is_in_chat(self, peer_id):
|
||||
def is_in_chat(self, peer_id) -> bool:
|
||||
return peer_id in self.chats
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
def name(self) -> str:
|
||||
return "bot"
|
||||
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import asyncio
|
||||
|
||||
from telethon_aio.errors import *
|
||||
@@ -51,6 +51,50 @@ def register(evt):
|
||||
return evt.reply("Not yet implemented.")
|
||||
|
||||
|
||||
@command_handler(needs_auth=False, management_only=True)
|
||||
async def register(evt):
|
||||
if evt.sender.logged_in:
|
||||
return await evt.reply("You are already logged in.")
|
||||
elif len(evt.args) < 1:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp register <phone> <full name>`")
|
||||
|
||||
phone_number = evt.args[0]
|
||||
full_name = evt.args[1:].split(" ", 1)
|
||||
if len(full_name) == 1:
|
||||
full_name.append("")
|
||||
await request_code(evt, phone_number, {
|
||||
"next": enter_code_register,
|
||||
"action": "Register",
|
||||
"full_name": full_name,
|
||||
})
|
||||
|
||||
|
||||
async def enter_code_register(evt):
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
|
||||
try:
|
||||
await evt.sender.ensure_started(even_if_no_session=True)
|
||||
first_name, last_name = evt.sender.command_status["full_name"]
|
||||
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
|
||||
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
|
||||
evt.sender.command_status = None
|
||||
return await evt.reply(f"Successfully registered to Telegram.")
|
||||
except PhoneNumberOccupiedError:
|
||||
return await evt.reply("That phone number has already been registered. "
|
||||
"You can log in with `$cmdprefix+sp login`.")
|
||||
except FirstNameInvalidError:
|
||||
return await evt.reply("Invalid name. Please set a Matrix displayname before registering.")
|
||||
except PhoneCodeExpiredError:
|
||||
return await evt.reply(
|
||||
"Phone code expired. Try again with `$cmdprefix+sp register <phone>`.")
|
||||
except PhoneCodeInvalidError:
|
||||
return await evt.reply("Invalid phone code.")
|
||||
except Exception:
|
||||
evt.log.exception("Error sending phone code")
|
||||
return await evt.reply("Unhandled exception while sending code. "
|
||||
"Check console for more details.")
|
||||
|
||||
|
||||
@command_handler(needs_auth=False, management_only=True)
|
||||
async def login(evt):
|
||||
if evt.sender.logged_in:
|
||||
@@ -80,22 +124,12 @@ async def login(evt):
|
||||
return await evt.reply("This bridge instance has been configured to not allow logging in.")
|
||||
|
||||
|
||||
@command_handler(needs_auth=False)
|
||||
async def enter_phone(evt):
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone <phone>`")
|
||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
||||
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
||||
"Please use `$cmdprefix+sp login` to get login instructions")
|
||||
|
||||
phone_number = evt.args[0]
|
||||
async def request_code(evt, phone_number, next_status):
|
||||
ok = False
|
||||
try:
|
||||
await evt.sender.ensure_started(even_if_no_session=True)
|
||||
await evt.sender.client.sign_in(phone_number)
|
||||
evt.sender.command_status = {
|
||||
"next": enter_code,
|
||||
"action": "Login",
|
||||
}
|
||||
ok = True
|
||||
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
|
||||
except PhoneNumberAppSignupForbiddenError:
|
||||
return await evt.reply(
|
||||
@@ -109,17 +143,31 @@ async def enter_phone(evt):
|
||||
"Your phone number has been temporarily blocked for flooding. "
|
||||
f"Please wait for {format_duration(e.seconds)} before trying again.")
|
||||
except PhoneNumberBannedError:
|
||||
return await evt.reply("Your phone number has been banned from Telegram.")
|
||||
return await evt.reply("Your phone number has been banned from Telegram.")
|
||||
except PhoneNumberUnoccupiedError:
|
||||
return await evt.reply("That phone number has not been registered. "
|
||||
"Please register with `$cmdprefix+sp register <phone>`.")
|
||||
return await evt.reply("That phone number has not been registered. "
|
||||
"Please register with `$cmdprefix+sp register <phone>`.")
|
||||
except Exception:
|
||||
evt.log.exception("Error requesting phone code")
|
||||
return await evt.reply("Unhandled exception while requesting code. "
|
||||
"Check console for more details.")
|
||||
finally:
|
||||
if evt.sender.command_status["next"] == enter_phone:
|
||||
evt.sender.command_status = None
|
||||
evt.sender.command_status = next_status if ok else None
|
||||
|
||||
|
||||
@command_handler(needs_auth=False)
|
||||
async def enter_phone(evt):
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone <phone>`")
|
||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
||||
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
||||
"Please use `$cmdprefix+sp login` to get login instructions")
|
||||
|
||||
phone_number = evt.args[0]
|
||||
await request_code(evt, phone_number, {
|
||||
"next": enter_code,
|
||||
"action": "Login",
|
||||
})
|
||||
|
||||
|
||||
@command_handler(needs_auth=False)
|
||||
@@ -136,8 +184,7 @@ async def enter_code(evt):
|
||||
evt.sender.command_status = None
|
||||
return await evt.reply(f"Successfully logged in as @{user.username}")
|
||||
except PhoneCodeExpiredError:
|
||||
return await evt.reply(
|
||||
"Phone code expired. Try again with `$cmdprefix+sp login <phone>`.")
|
||||
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
|
||||
except PhoneCodeInvalidError:
|
||||
return await evt.reply("Invalid phone code.")
|
||||
except SessionPasswordNeededError:
|
||||
@@ -160,7 +207,6 @@ async def enter_password(evt):
|
||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
||||
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
||||
"Please use `$cmdprefix+sp login` to get login instructions")
|
||||
|
||||
try:
|
||||
await evt.sender.ensure_started(even_if_no_session=True)
|
||||
user = await evt.sender.client.sign_in(password=evt.args[0])
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
|
||||
from . import command_handler
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import markdown
|
||||
import logging
|
||||
|
||||
@@ -81,12 +81,13 @@ class CommandHandler:
|
||||
async def handle(self, room, sender, command, args, is_management, is_portal):
|
||||
evt = CommandEvent(self, room, sender, command, args,
|
||||
is_management, is_portal)
|
||||
orig_command = command
|
||||
command = command.lower()
|
||||
try:
|
||||
command = command_handlers[command]
|
||||
except KeyError:
|
||||
if sender.command_status and "next" in sender.command_status:
|
||||
args.insert(0, command)
|
||||
args.insert(0, orig_command)
|
||||
evt.command = ""
|
||||
command = sender.command_status["next"]
|
||||
else:
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from . import command_handler
|
||||
|
||||
|
||||
@@ -57,7 +57,8 @@ def help(evt):
|
||||
#### Miscellaneous things
|
||||
**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.
|
||||
**sync** [`chats`|`contacts`|`me`] - Synchronize your chat portals, contacts and/or own info.
|
||||
**ping-bot** - Get info of the message relay Telegram bot.
|
||||
**ping-bot** - Get info of the message relay Telegram bot.
|
||||
**set-pl** <_level_> [_mxid_] - Set a temporary power level without affecting Telegram.
|
||||
|
||||
#### Initiating chats
|
||||
**pm** <_identifier_> - Open a private chat with the given Telegram user. The identifier is either
|
||||
@@ -72,7 +73,10 @@ def help(evt):
|
||||
**delete-portal** - Remove all users from the current portal room and forget the portal.
|
||||
Only works for group chats; to delete a private chat portal, simply
|
||||
leave the room.
|
||||
**unbridge** - Remove puppets from the current portal room and forget the portal.
|
||||
**unbridge** - Remove puppets from the current portal room and forget the portal.
|
||||
**bridge** [_id_] - Bridge the current Matrix room to the Telegram chat with the given
|
||||
ID. The ID must be the prefixed version that you get with the `/id`
|
||||
command of the Telegram-side bot.
|
||||
**group-name** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash
|
||||
(`-`) as the name.
|
||||
**clean-rooms** - Clean up unused portal/management rooms.
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import asyncio
|
||||
|
||||
from telethon_aio.errors import *
|
||||
@@ -23,6 +23,24 @@ from .. import portal as po
|
||||
from . import command_handler, CommandEvent
|
||||
|
||||
|
||||
@command_handler(needs_admin=True, needs_auth=False, name="set-pl")
|
||||
async def set_power_level(evt: CommandEvent):
|
||||
try:
|
||||
level = int(evt.args[0])
|
||||
except KeyError:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp set-power <level> [mxid]`")
|
||||
except ValueError:
|
||||
return await evt.reply("The level must be an integer.")
|
||||
levels = await evt.az.intent.get_power_levels(evt.room_id)
|
||||
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
|
||||
levels["users"][mxid] = level
|
||||
try:
|
||||
await evt.az.intent.set_power_levels(evt.room_id, levels)
|
||||
except MatrixRequestError:
|
||||
evt.log.exception("Failed to set power level.")
|
||||
return await evt.reply("Failed to set power level.")
|
||||
|
||||
|
||||
@command_handler()
|
||||
async def invite_link(evt: CommandEvent):
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from telethon_aio.errors import *
|
||||
from telethon_aio.tl.types import User as TLUser
|
||||
from telethon_aio.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
|
||||
|
||||
+107
-14
@@ -3,19 +3,21 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
from ruamel.yaml.tokens import CommentToken
|
||||
from ruamel.yaml.error import CommentMark
|
||||
import random
|
||||
import string
|
||||
|
||||
@@ -42,6 +44,9 @@ class DictWithRecursion:
|
||||
def __getitem__(self, key):
|
||||
return self.get(key, None)
|
||||
|
||||
def __contains__(self, key):
|
||||
return self[key] is not None
|
||||
|
||||
def _recursive_set(self, data, key, value):
|
||||
if '.' in key:
|
||||
key, next_key = key.split('.', 1)
|
||||
@@ -71,6 +76,7 @@ class DictWithRecursion:
|
||||
return
|
||||
try:
|
||||
del data[key]
|
||||
del data.ca.items[key]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@@ -80,6 +86,7 @@ class DictWithRecursion:
|
||||
return
|
||||
try:
|
||||
del self._data[key]
|
||||
del self._data.ca.items[key]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@@ -93,10 +100,19 @@ class DictWithRecursion:
|
||||
except ValueError:
|
||||
path = None
|
||||
entry = self[path] if path else self._data
|
||||
c = self._data.ca.items.setdefault(key, [None, [], None, None])
|
||||
c = entry.ca.items.setdefault(key, [None, [], None, None])
|
||||
c[1] = []
|
||||
entry.yaml_set_comment_before_after_key(key=key, before=message, indent=indent)
|
||||
|
||||
def comment_newline(self, key):
|
||||
try:
|
||||
path, key = key.rsplit(".", 1)
|
||||
except ValueError:
|
||||
path = None
|
||||
entry = self[path] if path else self._data
|
||||
c = entry.ca.items.setdefault(key, [None, [], None, None])
|
||||
c[2] = CommentToken("\n\n", CommentMark(0), None)
|
||||
|
||||
|
||||
class Config(DictWithRecursion):
|
||||
def __init__(self, path, registration_path):
|
||||
@@ -131,10 +147,16 @@ class Config(DictWithRecursion):
|
||||
del self["bridge.whitelist"]
|
||||
del self["bridge.admins"]
|
||||
|
||||
self["bridge.authless_relaybot_portals"] = self.get("bridge.authless_relaybot_portals",
|
||||
True)
|
||||
self.comment("bridge.authless_relaybot_portals",
|
||||
"Whether or not to allow creating portals from Telegram.")
|
||||
if "bridge.authless_relaybot_portals" not in self:
|
||||
self["bridge.authless_relaybot_portals"] = True
|
||||
self.comment("bridge.authless_relaybot_portals",
|
||||
"Whether or not to allow creating portals from Telegram.")
|
||||
if "bridge.max_telegram_delete" not in self:
|
||||
self["bridge.max_telegram_delete"] = 10
|
||||
self.comment("bridge.max_telegram_delete",
|
||||
"The maximum number of simultaneous Telegram deletions to handle.\n"
|
||||
"A large number of simultaneous redactions could put strain on your "
|
||||
"homeserver.")
|
||||
|
||||
self.comment("bridge.permissions", "\n".join((
|
||||
"",
|
||||
@@ -156,13 +178,84 @@ class Config(DictWithRecursion):
|
||||
"\nThe version of the config. The bridge will read this and automatically "
|
||||
"update the config if\nthe schema has changed. For the latest version, "
|
||||
"check the example config.")
|
||||
return self["version"]
|
||||
|
||||
def update_1_2(self):
|
||||
del self["bridge.link_in_reply"]
|
||||
del self["bridge.native_replies"]
|
||||
if "bridge.bridge_notices" not in self:
|
||||
self["bridge.bridge_notices"] = False
|
||||
self.comment("bridge.bridge_notices",
|
||||
"Whether or not Matrix bot messages (type m.notice) should be bridged.")
|
||||
if "bridge.allow_matrix_login" not in self:
|
||||
self["bridge.allow_matrix_login"] = True
|
||||
self.comment("bridge.allow_matrix_login",
|
||||
"Allow logging in within Matrix. If false, the only way to log in is "
|
||||
"using the out-of-Matrix login website (see appservice.public config "
|
||||
"section)")
|
||||
if "bridge.inline_images" not in self:
|
||||
self["bridge.inline_images"] = False
|
||||
self.comment("bridge.inline_images",
|
||||
"Use inline images instead of m.image to make rich captions possible.\n"
|
||||
"N.B. Inline images are not supported on all clients (e.g. Riot iOS).")
|
||||
if "appservice.public" not in self:
|
||||
self["appservice.public.enabled"] = False
|
||||
self["appservice.public.prefix"] = "/public"
|
||||
self["appservice.public.external"] = "https://example.com/public"
|
||||
self.comment("appservice.public",
|
||||
"Public part of web server for out-of-Matrix interaction with the "
|
||||
"bridge.\nUsed for things like login if the user wants to make sure the "
|
||||
"2FA password isn't stored in the HS database.")
|
||||
self.comment("appservice.public.enabled",
|
||||
"Whether or not the public-facing endpoints should be enabled.")
|
||||
self.comment("appservice.public.prefix",
|
||||
"The prefix to use in the public-facing endpoints.")
|
||||
self.comment("appservice.public.external",
|
||||
"The base URL where the public-facing endpoints are available. The "
|
||||
"prefix is not added\nimplicitly.")
|
||||
if "homeserver.verify_ssl" not in self:
|
||||
self["homeserver.verify_ssl"] = True
|
||||
self["version"] = 2
|
||||
return self["version"]
|
||||
|
||||
def update_2_3(self):
|
||||
if "bridge.plaintext_highlights" not in self:
|
||||
self["bridge.plaintext_highlights"] = False
|
||||
self.comment("bridge.plaintext_highlights",
|
||||
"Whether or not to bridge plaintext highlights.\n"
|
||||
"Only enable this if your displayname_template has some static part that "
|
||||
"the bridge can use to\nreliably identify what is a plaintext highlight.")
|
||||
if "bridge.highlight_edits" not in self:
|
||||
self["bridge.highlight_edits"] = False
|
||||
self.comment("bridge.highlight_edits",
|
||||
"Highlight changed/added parts in edits. Requires lxml.")
|
||||
if "bridge.relaybot" not in self:
|
||||
self["bridge.relaybot.authless_portals"] = bool(
|
||||
self["bridge.authless_relaybot_portals"]) or True
|
||||
del self["bridge.authless_relaybot_portals"]
|
||||
self["bridge.relaybot.whitelist_group_admins"] = True
|
||||
self["bridge.relaybot.whitelist"] = []
|
||||
self.comment("bridge.relaybot", "Options related to the message relay Telegram bot.")
|
||||
self.comment("bridge.relaybot.authless_portals",
|
||||
"Whether or not to allow creating portals from Telegram.")
|
||||
self.comment("bridge.relaybot.whitelist_group_admins",
|
||||
"Whether or not to allow Telegram group admins to use the bot commands.")
|
||||
self.comment("bridge.relaybot.whitelist",
|
||||
"List of usernames/user IDs who are also allowed to use the bot commands.")
|
||||
self["version"] = 3
|
||||
return self["version"]
|
||||
|
||||
def check_updates(self):
|
||||
if self.get("version", 0) == 0:
|
||||
self.update_0_1()
|
||||
else:
|
||||
return
|
||||
self.save()
|
||||
version = self.get("version", 0)
|
||||
new_version = version
|
||||
if version < 1:
|
||||
new_version = self.update_0_1()
|
||||
if version < 2:
|
||||
new_version = self.update_1_2()
|
||||
if version < 3:
|
||||
new_version = self.update_2_3()
|
||||
if new_version != version:
|
||||
self.save()
|
||||
|
||||
def _get_permissions(self, key):
|
||||
level = self["bridge.permissions"].get(key, "")
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
class Context:
|
||||
|
||||
+11
-6
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from sqlalchemy import (Column, UniqueConstraint, ForeignKey, ForeignKeyConstraint, Integer,
|
||||
BigInteger, String, Boolean)
|
||||
from sqlalchemy.orm import relationship
|
||||
@@ -81,8 +81,8 @@ class Contact(Base):
|
||||
query = None
|
||||
__tablename__ = "contact"
|
||||
|
||||
user = Column("user", Integer, ForeignKey("user.tgid"), primary_key=True)
|
||||
contact = Column("contact", Integer, ForeignKey("puppet.id"), primary_key=True)
|
||||
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
|
||||
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True)
|
||||
|
||||
|
||||
class Puppet(Base):
|
||||
@@ -112,6 +112,11 @@ class TelegramFile(Base):
|
||||
mime_type = Column(String)
|
||||
was_converted = Column(Boolean)
|
||||
timestamp = Column(BigInteger)
|
||||
size = Column(Integer, nullable=True)
|
||||
width = Column(Integer, nullable=True)
|
||||
height = Column(Integer, nullable=True)
|
||||
thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
|
||||
thumbnail = relationship("TelegramFile", uselist=False)
|
||||
|
||||
|
||||
def init(db_session):
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram
|
||||
from .from_telegram import telegram_reply_to_matrix, telegram_to_matrix
|
||||
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram,
|
||||
init_mx)
|
||||
from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg)
|
||||
from ..context import Context
|
||||
|
||||
|
||||
def init(context: Context):
|
||||
init_mx(context)
|
||||
init_tg(context)
|
||||
|
||||
@@ -3,31 +3,48 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from collections import deque
|
||||
from typing import Optional, List, Tuple, Type, Callable, Dict, Union
|
||||
import math
|
||||
import re
|
||||
import logging
|
||||
|
||||
from telethon_aio.tl.types import *
|
||||
from telethon_aio.tl.types import (MessageEntityMention,
|
||||
InputMessageEntityMentionName, MessageEntityEmail,
|
||||
MessageEntityUrl, MessageEntityTextUrl, MessageEntityBold,
|
||||
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
|
||||
MessageEntityBotCommand, MessageEntityHashtag,
|
||||
MessageEntityMentionName, InputUser)
|
||||
|
||||
try:
|
||||
from telethon_aio.tl.types import TypeMessageEntity
|
||||
except ImportError:
|
||||
TypeMessageEntity = Union[
|
||||
MessageEntityMention, MessageEntityHashtag, MessageEntityBotCommand, MessageEntityUrl,
|
||||
MessageEntityEmail, MessageEntityBold, MessageEntityItalic, MessageEntityCode,
|
||||
MessageEntityPre, MessageEntityTextUrl, MessageEntityMentionName]
|
||||
|
||||
from ..context import Context
|
||||
from .. import user as u, puppet as pu, portal as po
|
||||
from ..db import Message as DBMessage
|
||||
from .util import add_surrogates, remove_surrogates
|
||||
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
|
||||
trim_reply_fallback_text, html_to_unicode)
|
||||
|
||||
log = logging.getLogger("mau.fmt.mx")
|
||||
should_bridge_plaintext_highlights = False
|
||||
|
||||
|
||||
class MatrixParser(HTMLParser):
|
||||
@@ -35,7 +52,7 @@ class MatrixParser(HTMLParser):
|
||||
room_regex = re.compile("https://matrix.to/#/(#.+:.+)")
|
||||
block_tags = ("br", "p", "pre", "blockquote",
|
||||
"ol", "ul", "li",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6"
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"div", "hr", "table")
|
||||
|
||||
def __init__(self):
|
||||
@@ -49,21 +66,20 @@ class MatrixParser(HTMLParser):
|
||||
self._line_is_new = True
|
||||
self._list_entry_is_new = False
|
||||
|
||||
def _parse_url(self, url, args):
|
||||
def _parse_url(self, url: str, args: Dict[str, str]
|
||||
) -> Tuple[Optional[Type[TypeMessageEntity]], Optional[str]]:
|
||||
mention = self.mention_regex.match(url)
|
||||
if mention:
|
||||
mxid = mention.group(1)
|
||||
user = (pu.Puppet.get_by_mxid(mxid, create=False)
|
||||
user = (pu.Puppet.get_by_mxid(mxid)
|
||||
or u.User.get_by_mxid(mxid, create=False))
|
||||
if not user:
|
||||
return None, None
|
||||
if user.username:
|
||||
entity_type = MessageEntityMention
|
||||
url = f"@{user.username}"
|
||||
return MessageEntityMention, f"@{user.username}"
|
||||
else:
|
||||
entity_type = MessageEntityMentionName
|
||||
args["user_id"] = user.tgid
|
||||
return entity_type, url
|
||||
args["user_id"] = InputUser(user.tgid, 0)
|
||||
return InputMessageEntityMentionName, user.displayname or None
|
||||
|
||||
room = self.room_regex.match(url)
|
||||
if room:
|
||||
@@ -80,7 +96,7 @@ class MatrixParser(HTMLParser):
|
||||
args["url"] = url
|
||||
return MessageEntityTextUrl, None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
def handle_starttag(self, tag: str, attrs: List[Tuple[str, str]]):
|
||||
self._open_tags.appendleft(tag)
|
||||
self._open_tags_meta.appendleft(0)
|
||||
|
||||
@@ -127,7 +143,7 @@ class MatrixParser(HTMLParser):
|
||||
self._building_entities[tag] = entity_type(offset=offset, length=0, **args)
|
||||
|
||||
@property
|
||||
def _list_indent(self):
|
||||
def _list_indent(self) -> int:
|
||||
indent = 0
|
||||
first_skipped = False
|
||||
for index, tag in enumerate(self._open_tags):
|
||||
@@ -143,24 +159,41 @@ class MatrixParser(HTMLParser):
|
||||
indent += 3
|
||||
return indent
|
||||
|
||||
def _newline(self, allow_multi=False):
|
||||
if self._line_is_new or allow_multi:
|
||||
def _newline(self, allow_multi: bool = False):
|
||||
if self._line_is_new and not allow_multi:
|
||||
return
|
||||
self.text += "\n"
|
||||
self._line_is_new = True
|
||||
for entity in self._building_entities.values():
|
||||
entity.length += 1
|
||||
|
||||
def handle_data(self, text):
|
||||
text = unescape(text)
|
||||
def _handle_special_previous_tags(self, text: str) -> str:
|
||||
if "pre" not in self._open_tags and "code" not in self._open_tags:
|
||||
text = text.replace("\n", "")
|
||||
else:
|
||||
text = text.strip()
|
||||
|
||||
previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else ""
|
||||
extra_offset = 0
|
||||
if previous_tag == "a":
|
||||
url = self._open_tags_meta[0]
|
||||
if url:
|
||||
text = url
|
||||
elif previous_tag == "command":
|
||||
text = f"/{text}"
|
||||
return text
|
||||
|
||||
def _html_to_unicode(self, text: str) -> str:
|
||||
strikethrough, underline = "del" in self._open_tags, "u" in self._open_tags
|
||||
if strikethrough and underline:
|
||||
text = html_to_unicode(text, "\u0336\u0332")
|
||||
elif strikethrough:
|
||||
text = html_to_unicode(text, "\u0336")
|
||||
elif underline:
|
||||
text = html_to_unicode(text, "\u0332")
|
||||
return text
|
||||
|
||||
def _handle_tags_for_data(self, text: str) -> Tuple[str, int]:
|
||||
extra_offset = 0
|
||||
list_entry_handled_once = False
|
||||
# In order to maintain order of things like blockquotes in lists or lists in blockquotes,
|
||||
# we can't just have ifs/elses and we need to actually loop through the open tags in order.
|
||||
@@ -188,52 +221,77 @@ class MatrixParser(HTMLParser):
|
||||
text = indent + prefix + text
|
||||
self._list_entry_is_new = False
|
||||
list_entry_handled_once = True
|
||||
return text, extra_offset
|
||||
|
||||
def _extend_entities_in_construction(self, text: str, extra_offset: int):
|
||||
for tag, entity in self._building_entities.items():
|
||||
entity.length += len(text) - extra_offset
|
||||
entity.offset += extra_offset
|
||||
|
||||
def handle_data(self, text: str):
|
||||
text = unescape(text)
|
||||
text = self._handle_special_previous_tags(text)
|
||||
text = self._html_to_unicode(text)
|
||||
text, extra_offset = self._handle_tags_for_data(text)
|
||||
self._extend_entities_in_construction(text, extra_offset)
|
||||
self._line_is_new = False
|
||||
self.text += text
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
def handle_endtag(self, tag: str):
|
||||
try:
|
||||
self._open_tags.popleft()
|
||||
self._open_tags_meta.popleft()
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
if tag in self.block_tags:
|
||||
self._newline(allow_multi=tag == "br")
|
||||
|
||||
entity = self._building_entities.pop(tag, None)
|
||||
if entity:
|
||||
self.entities.append(entity)
|
||||
|
||||
if tag in self.block_tags:
|
||||
self._newline(allow_multi=tag == "br")
|
||||
|
||||
|
||||
command_regex = re.compile("(\s|^)!([A-Za-z0-9@]+)")
|
||||
plain_mention_regex = None
|
||||
|
||||
|
||||
def matrix_text_to_telegram(text):
|
||||
text = command_regex.sub(r"\1/\2", text)
|
||||
return text
|
||||
def plain_mention_to_html(match):
|
||||
puppet = pu.Puppet.find_by_displayname(match.group(2))
|
||||
if puppet:
|
||||
return (f"{match.group(1)}"
|
||||
f"<a href='https://matrix.to/#/{puppet.mxid}'>"
|
||||
f"{puppet.displayname}"
|
||||
"</a>")
|
||||
return "".join(match.groups())
|
||||
|
||||
|
||||
def matrix_to_telegram(html):
|
||||
def matrix_to_telegram(html: str) -> Tuple[str, List[TypeMessageEntity]]:
|
||||
try:
|
||||
parser = MatrixParser()
|
||||
html = html.replace("\n", "")
|
||||
html = command_regex.sub(r"\1<command>\2</command>", html)
|
||||
if should_bridge_plaintext_highlights:
|
||||
html = plain_mention_regex.sub(plain_mention_to_html, html)
|
||||
parser.feed(add_surrogates(html))
|
||||
return remove_surrogates(parser.text.strip()), parser.entities
|
||||
except Exception:
|
||||
log.exception("Failed to convert Matrix format:\nhtml=%s", html)
|
||||
|
||||
|
||||
def matrix_reply_to_telegram(content, tg_space, room_id=None):
|
||||
def matrix_reply_to_telegram(content: dict, tg_space: int, room_id: Optional[str] = None
|
||||
) -> Optional[int]:
|
||||
try:
|
||||
reply = content["m.relates_to"]["m.in_reply_to"]
|
||||
room_id = room_id or reply["room_id"]
|
||||
event_id = reply["event_id"]
|
||||
|
||||
try:
|
||||
if content["format"] == "org.matrix.custom.html":
|
||||
content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
|
||||
except KeyError:
|
||||
pass
|
||||
content["body"] = trim_reply_fallback_text(content["body"])
|
||||
|
||||
message = DBMessage.query.filter(DBMessage.mxid == event_id,
|
||||
DBMessage.tg_space == tg_space,
|
||||
DBMessage.mx_room == room_id).one_or_none()
|
||||
@@ -242,3 +300,44 @@ def matrix_reply_to_telegram(content, tg_space, room_id=None):
|
||||
except KeyError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def matrix_text_to_telegram(text: str) -> Tuple[str, List[TypeMessageEntity]]:
|
||||
text = command_regex.sub(r"\1/\2", text)
|
||||
if should_bridge_plaintext_highlights:
|
||||
entities, pmr_replacer = plain_mention_to_text()
|
||||
text = plain_mention_regex.sub(pmr_replacer, text)
|
||||
else:
|
||||
entities = []
|
||||
return text, entities
|
||||
|
||||
|
||||
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]:
|
||||
entities = []
|
||||
|
||||
def replacer(match):
|
||||
puppet = pu.Puppet.find_by_displayname(match.group(2))
|
||||
if puppet:
|
||||
offset = match.start()
|
||||
length = match.end() - offset
|
||||
if puppet.username:
|
||||
entity = MessageEntityMention(offset, length)
|
||||
text = f"@{puppet.username}"
|
||||
else:
|
||||
entity = InputMessageEntityMentionName(offset, length,
|
||||
user_id=InputUser(puppet.tgid, 0))
|
||||
text = puppet.displayname
|
||||
entities.append(entity)
|
||||
return text
|
||||
return "".join(match.groups())
|
||||
|
||||
return entities, replacer
|
||||
|
||||
|
||||
def init_mx(context: Context):
|
||||
global plain_mention_regex, should_bridge_plaintext_highlights
|
||||
config = context.config
|
||||
dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
|
||||
dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
|
||||
plain_mention_regex = re.compile(f"(\s|^)({dn_template})")
|
||||
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False
|
||||
|
||||
@@ -3,31 +3,55 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from html import escape
|
||||
import logging
|
||||
from typing import Optional, List, Tuple, Union
|
||||
|
||||
try:
|
||||
from lxml.html.diff import htmldiff
|
||||
except ImportError:
|
||||
htmldiff = None
|
||||
import logging
|
||||
import re
|
||||
|
||||
from telethon_aio.tl.types import (MessageEntityMention, MessageEntityMentionName,
|
||||
MessageEntityEmail, MessageEntityUrl, MessageEntityTextUrl,
|
||||
MessageEntityBold, MessageEntityItalic, MessageEntityCode,
|
||||
MessageEntityPre, MessageEntityBotCommand, Message, PeerChannel,
|
||||
MessageEntityHashtag)
|
||||
|
||||
try:
|
||||
from telethon_aio.tl.types import TypeMessageEntity
|
||||
except ImportError:
|
||||
TypeMessageEntity = Union[
|
||||
MessageEntityMention, MessageEntityHashtag, MessageEntityBotCommand, MessageEntityUrl,
|
||||
MessageEntityEmail, MessageEntityBold, MessageEntityItalic, MessageEntityCode,
|
||||
MessageEntityPre, MessageEntityTextUrl, MessageEntityMentionName]
|
||||
|
||||
from telethon_aio.tl.types import *
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
from mautrix_appservice.intent_api import IntentAPI
|
||||
|
||||
from .. import user as u, puppet as pu, portal as po
|
||||
from ..context import Context
|
||||
from ..db import Message as DBMessage
|
||||
from .util import add_surrogates, remove_surrogates
|
||||
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
|
||||
trim_reply_fallback_text, unicode_to_html)
|
||||
|
||||
log = logging.getLogger("mau.fmt.tg")
|
||||
should_highlight_edits = False
|
||||
|
||||
|
||||
def telegram_reply_to_matrix(evt, source):
|
||||
def telegram_reply_to_matrix(evt: Message, source: u.User) -> dict:
|
||||
if evt.reply_to_msg_id:
|
||||
space = (evt.to_id.channel_id
|
||||
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
|
||||
@@ -43,7 +67,8 @@ def telegram_reply_to_matrix(evt, source):
|
||||
return {}
|
||||
|
||||
|
||||
async def _add_forward_header(source, text, html, fwd_from_id):
|
||||
async def _add_forward_header(source, text: str, html: Optional[str],
|
||||
fwd_from_id: Optional[int]) -> Tuple[str, str]:
|
||||
if not html:
|
||||
html = escape(text)
|
||||
user = u.User.get_by_tgid(fwd_from_id)
|
||||
@@ -67,8 +92,23 @@ async def _add_forward_header(source, text, html, fwd_from_id):
|
||||
return text, html
|
||||
|
||||
|
||||
async def _add_reply_header(source, text, html, evt, relates_to,
|
||||
native_replies, message_link_in_reply, main_intent, reply_text):
|
||||
def highlight_edits(new_html: str, old_html: str) -> str:
|
||||
# Don't include `Edit:` text in diff.
|
||||
if old_html.startswith("<u>Edit:</u> "):
|
||||
old_html = old_html[len("<u>Edit:</u> "):]
|
||||
|
||||
# Generate diff with lxml
|
||||
new_html = htmldiff(old_html, new_html)
|
||||
|
||||
# Replace <ins> with <u> since Riot doesn't allow <ins>
|
||||
new_html = new_html.replace("<ins>", "<u>").replace("</ins>", "</u>")
|
||||
# Remove <del>s since we just want to hide deletions.
|
||||
new_html = re.sub("<del>.+?</del>", "", new_html)
|
||||
return new_html
|
||||
|
||||
|
||||
async def _add_reply_header(source: u.User, text: str, html: str, evt: Message, relates_to: dict,
|
||||
main_intent: IntentAPI, is_edit: bool) -> Tuple[str, str]:
|
||||
space = (evt.to_id.channel_id
|
||||
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
|
||||
else source.tgid)
|
||||
@@ -77,52 +117,71 @@ async def _add_reply_header(source, text, html, evt, relates_to,
|
||||
if not msg:
|
||||
return text, html
|
||||
|
||||
if native_replies:
|
||||
relates_to["m.in_reply_to"] = {
|
||||
"event_id": msg.mxid,
|
||||
"room_id": msg.mx_room,
|
||||
}
|
||||
if reply_text == "Edit":
|
||||
html = f"<u>Edit:</u> {html or escape(text)}"
|
||||
text = f"Edit: {text}"
|
||||
return text, html
|
||||
relates_to["m.in_reply_to"] = {
|
||||
"event_id": msg.mxid,
|
||||
"room_id": msg.mx_room,
|
||||
}
|
||||
|
||||
reply_displayname = "unknown user"
|
||||
try:
|
||||
event = await main_intent.get_event(msg.mx_room, msg.mxid)
|
||||
|
||||
content = event["content"]
|
||||
body = (content["formatted_body"]
|
||||
if "formatted_body" in content
|
||||
else content["body"])
|
||||
sender = event['sender']
|
||||
puppet = pu.Puppet.get_by_mxid(sender, create=False)
|
||||
reply_displayname = puppet.displayname if puppet else sender
|
||||
reply_to_user = f"<a href='https://matrix.to/#/{sender}'>{reply_displayname}</a>"
|
||||
reply_to_msg = (("<a href='https://matrix.to/#/"
|
||||
f"{msg.mx_room}/{msg.mxid}'>{reply_text}</a>")
|
||||
if message_link_in_reply else "Reply")
|
||||
quote = f"{reply_to_msg} to {reply_to_user}<blockquote>{body}</blockquote>"
|
||||
r_sender = event["sender"]
|
||||
|
||||
r_text_body = trim_reply_fallback_text(content["body"])
|
||||
r_html_body = trim_reply_fallback_html(content["formatted_body"]
|
||||
if "formatted_body" in content
|
||||
else escape(content["body"]))
|
||||
|
||||
puppet = pu.Puppet.get_by_mxid(r_sender, create=False)
|
||||
r_displayname = puppet.displayname if puppet else r_sender
|
||||
r_sender_link = f"<a href='https://matrix.to/#/{r_sender}'>{r_displayname}</a>"
|
||||
|
||||
if is_edit and should_highlight_edits:
|
||||
html = highlight_edits(html or escape(text), r_html_body)
|
||||
except (ValueError, KeyError, MatrixRequestError):
|
||||
quote = f"{reply_text} to unknown user <em>(Failed to fetch message)</em>:<br/>"
|
||||
if not html:
|
||||
html = escape(text)
|
||||
html = quote + html
|
||||
text = f"{reply_text} to {reply_displayname}:\n{text}"
|
||||
return text, html
|
||||
r_sender_link = "unknown user"
|
||||
# r_sender = "unknown user"
|
||||
r_text_body = "Failed to fetch message"
|
||||
r_html_body = "<em>Failed to fetch message</em>"
|
||||
|
||||
if is_edit:
|
||||
html = f"<u>Edit:</u> {html or escape(text)}"
|
||||
text = f"Edit: {text}"
|
||||
|
||||
r_keyword = "In reply to" if not is_edit else "Edit to"
|
||||
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>{r_keyword}</a>"
|
||||
html = (f"<blockquote data-mx-reply>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote>"
|
||||
+ (html or escape(text)))
|
||||
|
||||
lines = r_text_body.strip().split("\n")
|
||||
text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
|
||||
for line in lines:
|
||||
if line:
|
||||
text_with_quote += f"\n> {line}"
|
||||
text_with_quote += "\n\n"
|
||||
text_with_quote += text
|
||||
return text_with_quote, html
|
||||
|
||||
|
||||
async def telegram_to_matrix(evt, source, native_replies=False, message_link_in_reply=False,
|
||||
main_intent=None, reply_text="Reply"):
|
||||
async def telegram_to_matrix(evt: Message, source: u.User, main_intent: Optional[IntentAPI] = None,
|
||||
is_edit: bool = False, prefix_text: Optional[str] = None,
|
||||
prefix_html: Optional[str] = None) -> Tuple[str, str, dict]:
|
||||
text = add_surrogates(evt.message)
|
||||
html = _telegram_entities_to_matrix_catch(text, evt.entities) if evt.entities else None
|
||||
relates_to = {}
|
||||
|
||||
if prefix_html:
|
||||
html = prefix_html + (html or escape(text))
|
||||
if prefix_text:
|
||||
text = prefix_text + text
|
||||
|
||||
if evt.fwd_from:
|
||||
text, html = await _add_forward_header(source, text, html, evt.fwd_from.from_id)
|
||||
|
||||
if evt.reply_to_msg_id:
|
||||
text, html = await _add_reply_header(source, text, html, evt, relates_to, native_replies,
|
||||
message_link_in_reply, main_intent, reply_text)
|
||||
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent,
|
||||
is_edit)
|
||||
|
||||
if isinstance(evt, Message) and evt.post and evt.post_author:
|
||||
if not html:
|
||||
@@ -130,13 +189,16 @@ async def telegram_to_matrix(evt, source, native_replies=False, message_link_in_
|
||||
text += f"\n- {evt.post_author}"
|
||||
html += f"<br/><i>- <u>{evt.post_author}</u></i>"
|
||||
|
||||
html = unicode_to_html(text, html, "\u0336", "del")
|
||||
html = unicode_to_html(text, html, "\u0332", "u")
|
||||
|
||||
if html:
|
||||
html = html.replace("\n", "<br/>")
|
||||
|
||||
return remove_surrogates(text), remove_surrogates(html), relates_to
|
||||
|
||||
|
||||
def _telegram_entities_to_matrix_catch(text, entities):
|
||||
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
|
||||
try:
|
||||
return _telegram_entities_to_matrix(text, entities)
|
||||
except Exception:
|
||||
@@ -146,7 +208,7 @@ def _telegram_entities_to_matrix_catch(text, entities):
|
||||
text, entities)
|
||||
|
||||
|
||||
def _telegram_entities_to_matrix(text, entities):
|
||||
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity]) -> str:
|
||||
if not entities:
|
||||
return text
|
||||
html = []
|
||||
@@ -190,7 +252,7 @@ def _telegram_entities_to_matrix(text, entities):
|
||||
return "".join(html)
|
||||
|
||||
|
||||
def _parse_pre(html, entity_text, language):
|
||||
def _parse_pre(html: List[str], entity_text: str, language: str) -> bool:
|
||||
if language:
|
||||
html.append("<pre>"
|
||||
f"<code class='language-{language}'>{entity_text}</code>"
|
||||
@@ -200,7 +262,7 @@ def _parse_pre(html, entity_text, language):
|
||||
return False
|
||||
|
||||
|
||||
def _parse_mention(html, entity_text):
|
||||
def _parse_mention(html: List[str], entity_text: str) -> bool:
|
||||
username = entity_text[1:]
|
||||
|
||||
user = u.User.find_by_username(username) or pu.Puppet.find_by_username(username)
|
||||
@@ -217,7 +279,7 @@ def _parse_mention(html, entity_text):
|
||||
return False
|
||||
|
||||
|
||||
def _parse_name_mention(html, entity_text, user_id):
|
||||
def _parse_name_mention(html: List[str], entity_text: str, user_id: int) -> bool:
|
||||
user = u.User.get_by_tgid(user_id)
|
||||
if user:
|
||||
mxid = user.mxid
|
||||
@@ -231,9 +293,14 @@ def _parse_name_mention(html, entity_text, user_id):
|
||||
return False
|
||||
|
||||
|
||||
def _parse_url(html, entity_text, url):
|
||||
def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
|
||||
url = escape(url) if url else entity_text
|
||||
if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
|
||||
url = "http://" + url
|
||||
html.append(f"<a href='{url}'>{entity_text}</a>")
|
||||
return False
|
||||
|
||||
|
||||
def init_tg(context: Context):
|
||||
global should_highlight_edits
|
||||
should_highlight_edits = htmldiff and context.config["bridge.highlight_edits"]
|
||||
|
||||
@@ -1,16 +1,84 @@
|
||||
# Unicode surrogate handling
|
||||
# From https://github.com/LonamiWebs/Telethon/blob/master/telethon/extensions/markdown.py
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from html import escape
|
||||
from typing import Optional
|
||||
import struct
|
||||
import re
|
||||
|
||||
|
||||
def add_surrogates(text):
|
||||
# add_surrogates and remove_surrogates are unicode surrogate utility functions from Telethon.
|
||||
# Licensed under the MIT license.
|
||||
# https://github.com/LonamiWebs/Telethon/blob/master/telethon/extensions/markdown.py
|
||||
def add_surrogates(text: Optional[str]) -> Optional[str]:
|
||||
if text is None:
|
||||
return None
|
||||
return "".join("".join(chr(y) for y in struct.unpack("<HH", x.encode("utf-16-le")))
|
||||
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text)
|
||||
|
||||
|
||||
def remove_surrogates(text):
|
||||
def remove_surrogates(text: Optional[str]) -> Optional[str]:
|
||||
if text is None:
|
||||
return None
|
||||
return text.encode("utf-16", "surrogatepass").decode("utf-16")
|
||||
|
||||
|
||||
def trim_reply_fallback_text(text: str) -> str:
|
||||
if not text.startswith("> ") or "\n" not in text:
|
||||
return text
|
||||
lines = text.split("\n")
|
||||
while len(lines) > 0 and lines[0].startswith("> "):
|
||||
lines.pop(0)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
html_reply_fallback_regex = re.compile(r"^<blockquote data-mx-reply>[\s\S]+?</blockquote>")
|
||||
|
||||
|
||||
def trim_reply_fallback_html(html: str) -> str:
|
||||
return html_reply_fallback_regex.sub("", html)
|
||||
|
||||
|
||||
def unicode_to_html(text: str, html: str, ctrl: str, tag: str) -> str:
|
||||
if ctrl not in text:
|
||||
return html
|
||||
if not html:
|
||||
html = escape(text)
|
||||
tag_start = f"<{tag}>"
|
||||
tag_end = f"</{tag}>"
|
||||
characters = html.split(ctrl)
|
||||
html = ""
|
||||
in_tag = False
|
||||
for char in characters:
|
||||
if not in_tag:
|
||||
if len(char) > 1:
|
||||
html += char[0:-1]
|
||||
char = char[-1]
|
||||
html += tag_start
|
||||
in_tag = True
|
||||
html += char
|
||||
else:
|
||||
if len(char) > 1:
|
||||
html += tag_end
|
||||
in_tag = False
|
||||
html += char
|
||||
if in_tag:
|
||||
html += tag_end
|
||||
return html
|
||||
|
||||
|
||||
def html_to_unicode(text: str, ctrl: str) -> str:
|
||||
return ctrl.join(text) + ctrl
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import logging
|
||||
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
@@ -222,6 +222,18 @@ class MatrixHandler:
|
||||
return
|
||||
await handler(sender, content[content_key])
|
||||
|
||||
async def handle_room_pin(self, room, sender, new_events, old_events):
|
||||
portal = Portal.get_by_mxid(room)
|
||||
sender = await User.get_by_mxid(sender).ensure_started()
|
||||
if sender.has_full_access and portal:
|
||||
events = new_events - old_events
|
||||
if len(events) > 0:
|
||||
# New event pinned, set that as pinned in Telegram.
|
||||
await portal.handle_matrix_pin(sender, events.pop())
|
||||
elif len(new_events) == 0:
|
||||
# All pinned events removed, remove pinned event in Telegram.
|
||||
await portal.handle_matrix_pin(sender, None)
|
||||
|
||||
def filter_matrix_event(self, event):
|
||||
return (event["sender"] == self.az.bot_mxid
|
||||
or Puppet.get_id_from_mxid(event["sender"]) is not None)
|
||||
@@ -250,3 +262,10 @@ class MatrixHandler:
|
||||
evt["prev_content"])
|
||||
elif type in ("m.room.name", "m.room.avatar", "m.room.topic"):
|
||||
await self.handle_room_meta(type, evt["room_id"], evt["sender"], evt["content"])
|
||||
elif type == "m.room.pinned_events":
|
||||
new_events = set(evt["content"]["pinned"])
|
||||
try:
|
||||
old_events = set(evt["unsigned"]["prev_content"]["pinned"])
|
||||
except KeyError:
|
||||
old_events = set()
|
||||
await self.handle_room_pin(evt["room_id"], evt["sender"], new_events, old_events)
|
||||
|
||||
+147
-48
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
@@ -66,6 +66,8 @@ class Portal:
|
||||
|
||||
self._main_intent = None
|
||||
self._room_create_lock = asyncio.Lock()
|
||||
self._temp_pinned_message_id = None
|
||||
self._temp_pinned_message_sender = None
|
||||
|
||||
self._dedup = deque()
|
||||
self._dedup_mxid = {}
|
||||
@@ -516,12 +518,10 @@ class Portal:
|
||||
try:
|
||||
current_extension = body[body.rindex("."):]
|
||||
if mimetypes.types_map[current_extension] == mime:
|
||||
file_name = body
|
||||
else:
|
||||
file_name = f"matrix_upload{mimetypes.guess_extension(mime)}"
|
||||
return body
|
||||
except (ValueError, KeyError):
|
||||
file_name = f"matrix_upload{mimetypes.guess_extension(mime)}"
|
||||
return file_name, None if file_name == body else body
|
||||
pass
|
||||
return f"matrix_upload{mimetypes.guess_extension(mime)}"
|
||||
|
||||
async def leave_matrix(self, user, source, event_id):
|
||||
if not user.logged_in:
|
||||
@@ -570,25 +570,54 @@ class Portal:
|
||||
|
||||
@staticmethod
|
||||
def _preprocess_matrix_message(sender, message):
|
||||
if message["msgtype"] == "m.emote":
|
||||
msgtype = message["msgtype"]
|
||||
if msgtype == "m.emote":
|
||||
if "formatted_body" in message:
|
||||
message["formatted_body"] = f"* {sender.displayname} {message['formatted_body']}"
|
||||
message["body"] = f"* {sender.displayname} {message['body']}"
|
||||
message["msgtype"] = "m.text"
|
||||
elif not sender.logged_in:
|
||||
if "formatted_body" in message:
|
||||
message["formatted_body"] = (f"<{sender.displayname}> "
|
||||
f"{message['formatted_body']}")
|
||||
message["body"] = f"<{sender.displayname}> {message['body']}"
|
||||
return type
|
||||
html = message["formatted_body"] if "formatted_body" in message else None
|
||||
text = message["body"]
|
||||
if msgtype == "m.text":
|
||||
if html:
|
||||
html = f"<{sender.displayname}> {html}"
|
||||
text = f"<{sender.displayname}> {text}"
|
||||
else:
|
||||
msgtype = msgtype[len("m."):]
|
||||
prefix = {
|
||||
"file": "a ",
|
||||
"image": "an ",
|
||||
"audio": "",
|
||||
"video": "a ",
|
||||
"location": "a ",
|
||||
}.get(msgtype, "")
|
||||
if html:
|
||||
html = f"{sender.displayname} sent {prefix}{msgtype}: {html}"
|
||||
text = ": " + text if text else ""
|
||||
text = f"{sender.displayname} sent {prefix}{msgtype}{text}"
|
||||
if html:
|
||||
message["formatted_body"] = html
|
||||
message["body"] = text
|
||||
|
||||
def _handle_matrix_text(self, client, message, reply_to):
|
||||
if "format" in message and message["format"] == "org.matrix.custom.html":
|
||||
message, entities = formatter.matrix_to_telegram(message["formatted_body"])
|
||||
return client.send_message(self.peer, message, entities=entities, reply_to=reply_to)
|
||||
else:
|
||||
message = formatter.matrix_text_to_telegram(message["body"])
|
||||
return client.send_message(self.peer, message, reply_to=reply_to)
|
||||
async def _matrix_event_to_entities(self, client, event):
|
||||
try:
|
||||
if event.get("format", None) == "org.matrix.custom.html":
|
||||
message, entities = formatter.matrix_to_telegram(event["formatted_body"])
|
||||
|
||||
# TODO remove this crap
|
||||
for entity in entities:
|
||||
if isinstance(entity, InputMessageEntityMentionName):
|
||||
entity.user_id = await client.get_input_entity(entity.user_id.user_id)
|
||||
else:
|
||||
message, entities = formatter.matrix_text_to_telegram(event["body"])
|
||||
except KeyError:
|
||||
message, entities = None, None
|
||||
return message, entities
|
||||
|
||||
async def _handle_matrix_text(self, client, message, reply_to):
|
||||
message, entities = await self._matrix_event_to_entities(client, message)
|
||||
return await client.send_message(self.peer, message, entities=entities, reply_to=reply_to)
|
||||
|
||||
async def _handle_matrix_file(self, client, message, reply_to):
|
||||
file = await self.main_intent.download_file(message["url"])
|
||||
@@ -596,32 +625,53 @@ class Portal:
|
||||
info = message["info"]
|
||||
mime = info["mimetype"]
|
||||
|
||||
file_name, caption = self._get_file_meta(message["body"], mime)
|
||||
file_name = self._get_file_meta(message["mxtg_filename"], mime)
|
||||
|
||||
attributes = [DocumentAttributeFilename(file_name=file_name)]
|
||||
if "w" in info and "h" in info:
|
||||
attributes.append(DocumentAttributeImageSize(w=info["w"], h=info["h"]))
|
||||
|
||||
caption = message["body"] if message["body"] != file_name else None
|
||||
return await client.send_file(self.peer, file, mime, caption=caption,
|
||||
attributes=attributes, file_name=file_name,
|
||||
reply_to=reply_to)
|
||||
|
||||
async def _handle_matrix_location(self, client, message, reply_to):
|
||||
try:
|
||||
lat, long = message["geo_uri"][len("geo:"):].split(",")
|
||||
lat, long = float(lat), float(long)
|
||||
except (KeyError, ValueError):
|
||||
self.log.exception("Failed to parse location")
|
||||
return None
|
||||
message, entities = await self._matrix_event_to_entities(client, message)
|
||||
media = MessageMediaGeo(geo=GeoPoint(lat, long))
|
||||
return await client.send_media(self.peer, media, reply_to=reply_to, caption=message,
|
||||
entities=entities)
|
||||
|
||||
async def handle_matrix_message(self, sender, message, event_id):
|
||||
client = sender.client if sender.logged_in else self.bot.client
|
||||
space = (self.tgid if self.peer_type == "channel" # Channels have their own ID space
|
||||
else (sender.tgid if sender.logged_in else self.bot.tgid))
|
||||
reply_to = formatter.matrix_reply_to_telegram(message, space, room_id=self.mxid)
|
||||
|
||||
message["mxtg_filename"] = message["body"]
|
||||
self._preprocess_matrix_message(sender, message)
|
||||
type = message["msgtype"]
|
||||
|
||||
if type == "m.text" or (self.bridge_notices and type == "m.notice"):
|
||||
response = await self._handle_matrix_text(client, message, reply_to)
|
||||
elif type == "m.location":
|
||||
response = await self._handle_matrix_location(client, message, reply_to)
|
||||
elif type in ("m.image", "m.file", "m.audio", "m.video"):
|
||||
response = await self._handle_matrix_file(client, message, reply_to)
|
||||
else:
|
||||
self.log.debug("Unhandled Matrix event: %s", message)
|
||||
response = None
|
||||
|
||||
if not response:
|
||||
return
|
||||
|
||||
self.log.debug("Handled Matrix message: %s", response)
|
||||
self.is_duplicate(response, (event_id, space))
|
||||
self.db.add(DBMessage(
|
||||
tgid=response.id,
|
||||
@@ -630,6 +680,20 @@ class Portal:
|
||||
mxid=event_id))
|
||||
self.db.commit()
|
||||
|
||||
async def handle_matrix_pin(self, sender, pinned_message):
|
||||
if self.peer_type != "channel":
|
||||
return
|
||||
try:
|
||||
if not pinned_message:
|
||||
await sender.client(UpdatePinnedMessageRequest(channel=self.peer, id=0))
|
||||
else:
|
||||
message = DBMessage.query.filter(DBMessage.mxid == pinned_message,
|
||||
DBMessage.tg_space == self.tgid,
|
||||
DBMessage.mx_room == self.mxid).one_or_none()
|
||||
await sender.client(UpdatePinnedMessageRequest(channel=self.peer, id=message.tgid))
|
||||
except ChatNotModifiedError:
|
||||
pass
|
||||
|
||||
async def handle_matrix_deletion(self, deleter, event_id):
|
||||
space = self.tgid if self.peer_type == "channel" else deleter.tgid
|
||||
message = DBMessage.query.filter(DBMessage.mxid == event_id,
|
||||
@@ -650,7 +714,7 @@ class Portal:
|
||||
edit_messages=moderator, delete_messages=moderator,
|
||||
ban_users=moderator, invite_users=moderator,
|
||||
invite_link=moderator, pin_messages=moderator,
|
||||
add_admins=admin, manage_call=moderator)
|
||||
add_admins=admin)
|
||||
await sender.client(
|
||||
EditAdminRequest(channel=await self.get_input_entity(sender),
|
||||
user_id=user_id, admin_rights=rights))
|
||||
@@ -823,12 +887,19 @@ class Portal:
|
||||
if self.mxid:
|
||||
await user.intent.set_typing(self.mxid, is_typing=True)
|
||||
|
||||
async def handle_telegram_photo(self, source, intent, evt, relates_to=None):
|
||||
async def handle_telegram_photo(self, source: u.User, intent, evt: Message, relates_to=None):
|
||||
largest_size = self._get_largest_photo_size(evt.media.photo)
|
||||
file = await util.transfer_file_to_matrix(self.db, source.client, intent,
|
||||
largest_size.location)
|
||||
if not file:
|
||||
return None
|
||||
if config["bridge.inline_images"] and (evt.message or evt.fwd_from or evt.reply_to_msg_id):
|
||||
text, html, relates_to = await formatter.telegram_to_matrix(
|
||||
evt, source, self.main_intent,
|
||||
prefix_html=f"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>",
|
||||
prefix_text="Inline image: ")
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to)
|
||||
info = {
|
||||
"h": largest_size.h,
|
||||
"w": largest_size.w,
|
||||
@@ -842,25 +913,40 @@ class Portal:
|
||||
return await intent.send_image(self.mxid, file.mxc, info=info, text=name,
|
||||
relates_to=relates_to)
|
||||
|
||||
async def handle_telegram_document(self, source, intent, evt, relates_to=None):
|
||||
async def handle_telegram_document(self, source, intent, evt: Message, relates_to=None):
|
||||
document = evt.media.document
|
||||
file = await util.transfer_file_to_matrix(self.db, source.client, intent, document)
|
||||
file = await util.transfer_file_to_matrix(self.db, source.client, intent, document,
|
||||
document.thumb)
|
||||
if not file:
|
||||
return None
|
||||
name = evt.message
|
||||
width, height = file.width, file.height
|
||||
for attr in document.attributes:
|
||||
if not name and isinstance(attr, DocumentAttributeFilename):
|
||||
name = attr.file_name
|
||||
if isinstance(attr, DocumentAttributeFilename):
|
||||
name = name or attr.file_name
|
||||
if not file.was_converted:
|
||||
(mime_from_name, _) = mimetypes.guess_type(name)
|
||||
file.mime_type = mime_from_name or file.mime_type
|
||||
elif isinstance(attr, DocumentAttributeSticker):
|
||||
name = f"Sticker for {attr.alt}"
|
||||
elif isinstance(attr, DocumentAttributeVideo) and (not width or not height):
|
||||
width, height = attr.w, attr.h
|
||||
mime_type = document.mime_type or file.mime_type
|
||||
info = {
|
||||
"size": document.size,
|
||||
"size": file.size,
|
||||
"mimetype": mime_type,
|
||||
}
|
||||
if file.thumbnail:
|
||||
info["thumbnail_url"] = file.thumbnail.mxc
|
||||
info["thumbnail_info"] = {
|
||||
"mimetype": file.thumbnail.mime_type,
|
||||
"h": file.thumbnail.height or document.thumb.h,
|
||||
"w": file.thumbnail.width or document.thumb.w,
|
||||
"size": file.thumbnail.size,
|
||||
}
|
||||
if height and width:
|
||||
info["h"] = height
|
||||
info["w"] = width
|
||||
type = "m.file"
|
||||
if mime_type.startswith("video/"):
|
||||
type = "m.video"
|
||||
@@ -900,11 +986,7 @@ class Portal:
|
||||
|
||||
async def handle_telegram_text(self, source, intent, evt):
|
||||
self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}")
|
||||
text, html, relates_to = await formatter.telegram_to_matrix(
|
||||
evt, source,
|
||||
config["bridge.native_replies"],
|
||||
config["bridge.link_in_reply"],
|
||||
self.main_intent)
|
||||
text, html, relates_to = await formatter.telegram_to_matrix(evt, source, self.main_intent)
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to)
|
||||
|
||||
@@ -928,11 +1010,8 @@ class Portal:
|
||||
return
|
||||
|
||||
evt.reply_to_msg_id = evt.id
|
||||
text, html, relates_to = await formatter.telegram_to_matrix(
|
||||
evt, source,
|
||||
config["bridge.native_replies"],
|
||||
config["bridge.link_in_reply"],
|
||||
self.main_intent, reply_text="Edit")
|
||||
text, html, relates_to = await formatter.telegram_to_matrix(evt, source, self.main_intent,
|
||||
is_edit=True)
|
||||
intent = sender.intent if sender else self.main_intent
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
response = await intent.send_text(self.mxid, text, html=html, relates_to=relates_to)
|
||||
@@ -966,7 +1045,9 @@ class Portal:
|
||||
DBMessage(tgid=evt.id, mx_room=self.mxid, mxid=mxid, tg_space=tg_space))
|
||||
self.db.commit()
|
||||
return
|
||||
media = evt.media if hasattr(evt, "media") else None
|
||||
allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo)
|
||||
media = evt.media if hasattr(evt, "media") and isinstance(evt.media,
|
||||
allowed_media) else None
|
||||
intent = sender.intent if sender else self.main_intent
|
||||
if not media and evt.message:
|
||||
response = await self.handle_telegram_text(source, intent, evt)
|
||||
@@ -988,7 +1069,7 @@ class Portal:
|
||||
|
||||
if not response:
|
||||
return
|
||||
|
||||
self.log.debug("Handled Telegram message: %s", evt)
|
||||
mxid = response["event_id"]
|
||||
DBMessage.query \
|
||||
.filter(DBMessage.mx_room == self.mxid,
|
||||
@@ -1013,7 +1094,6 @@ class Portal:
|
||||
or self.is_duplicate_action(update))
|
||||
if should_ignore:
|
||||
return
|
||||
|
||||
# TODO figure out how to see changes to about text / channel username
|
||||
if isinstance(action, MessageActionChatEditTitle):
|
||||
await self.update_title(action.title, save=True)
|
||||
@@ -1031,6 +1111,8 @@ class Portal:
|
||||
self.peer_type = "channel"
|
||||
self.migrate_and_save(action.channel_id)
|
||||
await sender.intent.send_emote(self.mxid, "upgraded this group to a supergroup.")
|
||||
elif isinstance(action, MessageActionPinMessage):
|
||||
await self.receive_telegram_pin_sender(sender)
|
||||
else:
|
||||
self.log.debug("Unhandled Telegram action in %s: %s", self.title, action)
|
||||
|
||||
@@ -1045,13 +1127,30 @@ class Portal:
|
||||
levels["users"][puppet.mxid] = 50
|
||||
await self.main_intent.set_power_levels(self.mxid, levels)
|
||||
|
||||
async def update_telegram_pin(self, source, id):
|
||||
space = self.tgid if self.peer_type == "channel" else source.tgid
|
||||
message = DBMessage.query.get((id, space))
|
||||
async def receive_telegram_pin_sender(self, sender):
|
||||
self._temp_pinned_message_sender = sender
|
||||
if self._temp_pinned_message_id:
|
||||
await self.update_telegram_pin()
|
||||
|
||||
async def update_telegram_pin(self):
|
||||
intent = (self._temp_pinned_message_sender.intent
|
||||
if self._temp_pinned_message_sender else self.main_intent)
|
||||
id = self._temp_pinned_message_id
|
||||
self._temp_pinned_message_id = None
|
||||
self._temp_pinned_message_sender = None
|
||||
|
||||
message = DBMessage.query.get((id, self.tgid))
|
||||
if message:
|
||||
await self.main_intent.set_pinned_messages(self.mxid, [message.mxid])
|
||||
await intent.set_pinned_messages(self.mxid, [message.mxid])
|
||||
else:
|
||||
await self.main_intent.set_pinned_messages(self.mxid, [])
|
||||
await intent.set_pinned_messages(self.mxid, [])
|
||||
|
||||
async def receive_telegram_pin_id(self, id):
|
||||
if id == 0:
|
||||
return await self.update_telegram_pin()
|
||||
self._temp_pinned_message_id = id
|
||||
if self._temp_pinned_message_sender:
|
||||
await self.update_telegram_pin()
|
||||
|
||||
@staticmethod
|
||||
def _get_level_from_participant(participant, _):
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from aiohttp import web
|
||||
from mako.template import Template
|
||||
import asyncio
|
||||
@@ -46,7 +46,9 @@ class PublicBridgeWebsite:
|
||||
user = (User.get_by_mxid(request.rel_url.query["mxid"], create=False)
|
||||
if "mxid" in request.rel_url.query else None)
|
||||
if not user:
|
||||
return self.render_login(mxid=request.rel_url.query["mxid"], state="request")
|
||||
return self.render_login(
|
||||
mxid=request.rel_url.query["mxid"] if "mxid" in request.rel_url.query else None,
|
||||
state="request")
|
||||
elif not user.whitelisted:
|
||||
return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403)
|
||||
await user.ensure_started()
|
||||
@@ -144,7 +146,7 @@ class PublicBridgeWebsite:
|
||||
if "mxid" not in data:
|
||||
return self.render_login(error="Please enter your Matrix ID.", status=400)
|
||||
|
||||
user = await User.get_by_mxid(data["mxid"]).ensure_started()
|
||||
user = await User.get_by_mxid(data["mxid"]).ensure_started(even_if_no_session=True)
|
||||
if not user.whitelisted:
|
||||
return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403)
|
||||
elif user.logged_in:
|
||||
@@ -153,7 +155,8 @@ class PublicBridgeWebsite:
|
||||
if "phone" in data:
|
||||
return await self.post_login_phone(user, data["phone"])
|
||||
elif "code" in data:
|
||||
resp = await self.post_login_code(user, data["code"], password_in_data="password" in data)
|
||||
resp = await self.post_login_code(user, data["code"],
|
||||
password_in_data="password" in data)
|
||||
if resp or "password" not in data:
|
||||
return resp
|
||||
elif "password" not in data:
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
/*
|
||||
* mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
* Copyright (C) 2018 Tulir Asokan
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
form > div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
<!--
|
||||
mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
Copyright (C) 2018 Tulir Asokan
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from difflib import SequenceMatcher
|
||||
import re
|
||||
import logging
|
||||
@@ -191,6 +191,21 @@ class Puppet:
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def find_by_displayname(cls, displayname):
|
||||
if not displayname:
|
||||
return None
|
||||
|
||||
for _, puppet in cls.cache.items():
|
||||
if puppet.displayname and puppet.displayname == displayname:
|
||||
return puppet
|
||||
|
||||
puppet = DBPuppet.query.filter(DBPuppet.displayname == displayname).one_or_none()
|
||||
if puppet:
|
||||
return cls.from_db(puppet)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def init(context):
|
||||
global config
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from io import BytesIO
|
||||
|
||||
from telethon_aio import TelegramClient
|
||||
@@ -73,6 +73,11 @@ class MautrixTelegramClient(TelegramClient):
|
||||
reply_to_msg_id=reply_to)
|
||||
return self._get_response_message(request, await self(request))
|
||||
|
||||
async def send_media(self, entity, media, caption=None, entities=None, reply_to=None):
|
||||
request = SendMediaRequest(entity, media, message=caption or "", entities=entities or [],
|
||||
reply_to_msg_id=reply_to)
|
||||
return self._get_response_message(request, await self(request))
|
||||
|
||||
async def download_file_bytes(self, location):
|
||||
if isinstance(location, Document):
|
||||
location = InputDocumentFileLocation(location.id, location.access_hash,
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
@@ -3,27 +3,39 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from io import BytesIO
|
||||
import time
|
||||
import logging
|
||||
|
||||
import magic
|
||||
from PIL import Image
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.exc import IntegrityError, InvalidRequestError
|
||||
from sqlalchemy.orm.exc import FlushError
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Image = None
|
||||
try:
|
||||
from moviepy.editor import VideoFileClip
|
||||
import random
|
||||
import string
|
||||
import os
|
||||
import mimetypes
|
||||
except ImportError:
|
||||
VideoFileClip = random = string = os = mimetypes = None
|
||||
|
||||
from telethon_aio.tl.types import (Document, FileLocation, InputFileLocation,
|
||||
InputDocumentFileLocation)
|
||||
InputDocumentFileLocation, PhotoSize, PhotoCachedSize)
|
||||
from telethon_aio.errors import LocationInvalidError
|
||||
|
||||
from ..db import TelegramFile as DBTelegramFile
|
||||
@@ -32,24 +44,89 @@ log = logging.getLogger("mau.util")
|
||||
|
||||
|
||||
def _convert_webp(file, to="png"):
|
||||
if not Image:
|
||||
return "image/webp", file
|
||||
try:
|
||||
image = Image.open(BytesIO(file)).convert("RGBA")
|
||||
new_file = BytesIO()
|
||||
image.save(new_file, to)
|
||||
return f"image/{to}", new_file.getvalue()
|
||||
w, h = image.size
|
||||
return f"image/{to}", new_file.getvalue(), w, h
|
||||
except Exception:
|
||||
log.exception(f"Failed to convert webp to {to}")
|
||||
return "image/webp", file
|
||||
|
||||
|
||||
async def transfer_file_to_matrix(db, client, intent, location):
|
||||
def _temp_file_name(ext):
|
||||
return ("/tmp/mxtg-video-"
|
||||
+ "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10))
|
||||
+ ext)
|
||||
|
||||
|
||||
def _read_video_thumbnail(data, video_ext="mp4", frame_ext="png", max_size=(1024, 720)):
|
||||
# We don't have any way to read the video from memory, so save it to disk.
|
||||
temp_file = _temp_file_name(video_ext)
|
||||
with open(temp_file, "wb") as file:
|
||||
file.write(data)
|
||||
|
||||
# Read temp file and get frame
|
||||
clip = VideoFileClip(temp_file)
|
||||
frame = clip.get_frame(0)
|
||||
|
||||
# Convert to png and save to BytesIO
|
||||
image = Image.fromarray(frame).convert("RGBA")
|
||||
thumbnail_file = BytesIO()
|
||||
if max_size:
|
||||
image.thumbnail(max_size, Image.ANTIALIAS)
|
||||
image.save(thumbnail_file, frame_ext)
|
||||
|
||||
os.remove(temp_file)
|
||||
|
||||
w, h = image.size
|
||||
return thumbnail_file.getvalue(), w, h
|
||||
|
||||
|
||||
def _location_to_id(location):
|
||||
if isinstance(location, (Document, InputDocumentFileLocation)):
|
||||
id = f"{location.id}-{location.version}"
|
||||
return f"{location.id}-{location.version}"
|
||||
elif isinstance(location, (FileLocation, InputFileLocation)):
|
||||
id = f"{location.volume_id}-{location.local_id}"
|
||||
return f"{location.volume_id}-{location.local_id}"
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
async def transfer_thumbnail_to_matrix(client, intent, thumbnail_loc, video, mime):
|
||||
if not Image or not VideoFileClip:
|
||||
return None
|
||||
|
||||
id = _location_to_id(thumbnail_loc)
|
||||
if not id:
|
||||
return None
|
||||
|
||||
video_ext = mimetypes.guess_extension(mime)
|
||||
if VideoFileClip and video_ext:
|
||||
try:
|
||||
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
|
||||
except OSError:
|
||||
return None
|
||||
mime_type = "image/png"
|
||||
else:
|
||||
file = await client.download_file_bytes(thumbnail_loc)
|
||||
width, height = None, None
|
||||
mime_type = magic.from_buffer(file, mime=True)
|
||||
|
||||
uploaded = await intent.upload_file(file, mime_type)
|
||||
|
||||
return DBTelegramFile(id=id, mxc=uploaded["content_uri"], mime_type=mime_type,
|
||||
was_converted=False, timestamp=int(time.time()), size=len(file),
|
||||
width=width, height=height)
|
||||
|
||||
|
||||
async def transfer_file_to_matrix(db, client, intent, location, thumbnail=None):
|
||||
id = _location_to_id(location)
|
||||
if not id:
|
||||
return None
|
||||
|
||||
db_file = DBTelegramFile.query.get(id)
|
||||
if db_file:
|
||||
return db_file
|
||||
@@ -58,24 +135,37 @@ async def transfer_file_to_matrix(db, client, intent, location):
|
||||
file = await client.download_file_bytes(location)
|
||||
except LocationInvalidError:
|
||||
return None
|
||||
width, height = None, None
|
||||
mime_type = magic.from_buffer(file, mime=True)
|
||||
|
||||
image_converted = False
|
||||
if mime_type == "image/webp":
|
||||
mime_type, file = _convert_webp(file, to="png")
|
||||
mime_type, file, width, height = _convert_webp(file, to="png")
|
||||
thumbnail = None
|
||||
image_converted = True
|
||||
|
||||
uploaded = await intent.upload_file(file, mime_type)
|
||||
|
||||
db_file = DBTelegramFile(id=id, mxc=uploaded["content_uri"],
|
||||
mime_type=mime_type, was_converted=image_converted,
|
||||
timestamp=int(time.time()))
|
||||
timestamp=int(time.time()), size=len(file),
|
||||
width=width, height=height)
|
||||
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
|
||||
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
|
||||
thumbnail = thumbnail.location
|
||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(client, intent, thumbnail, file,
|
||||
mime_type)
|
||||
|
||||
try:
|
||||
db.add(db_file)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
except FlushError as e:
|
||||
log.exception(f"{e.__class__.__name__} while saving transferred file data. "
|
||||
"This was probably caused by two simultaneous transfers of the same file, "
|
||||
"and should not cause any problems.")
|
||||
except (IntegrityError, InvalidRequestError) as e:
|
||||
db.rollback()
|
||||
log.exception("Integrity error while saving transferred file data. "
|
||||
log.exception(f"{e.__class__.__name__} while saving transferred file data. "
|
||||
"This was probably caused by two simultaneous transfers of the same file, "
|
||||
"and should not cause any problems.")
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero 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 <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
def format_duration(seconds):
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
aiohttp
|
||||
mautrix-appservice
|
||||
ruamel.yaml
|
||||
python-magic
|
||||
SQLAlchemy
|
||||
alembic
|
||||
Markdown
|
||||
Pillow
|
||||
future-fstrings
|
||||
cryptg
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
lxml
|
||||
cryptg
|
||||
Pillow
|
||||
moviepy
|
||||
@@ -3,6 +3,14 @@ import sys
|
||||
import glob
|
||||
import mautrix_telegram
|
||||
|
||||
extras = {
|
||||
"highlight_edits": ["lxml>=4.1.1,<5"],
|
||||
"fast_crypto": ["cryptg>=0.1,<0.2"],
|
||||
"webp_convert": ["Pillow>=5.0.0,<6"],
|
||||
"hq_thumbnails": ["moviepy>=0.2,<0.3"],
|
||||
}
|
||||
extras["all"] = [deps[0] for deps in extras.values()]
|
||||
|
||||
setuptools.setup(
|
||||
name="mautrix-telegram",
|
||||
version=mautrix_telegram.__version__,
|
||||
@@ -18,22 +26,22 @@ setuptools.setup(
|
||||
|
||||
install_requires=[
|
||||
"aiohttp>=3.0.1,<4",
|
||||
"mautrix-telegram>=0.1,<0.2",
|
||||
"SQLAlchemy>=1.2.3,<2",
|
||||
"alembic>=0.9.8,<0.10",
|
||||
"Markdown>=2.6.11,<3",
|
||||
"ruamel.yaml>=0.15.35,<0.16",
|
||||
"Pillow>=5.0.0,<6",
|
||||
"future-fstrings>=0.4.2",
|
||||
"python-magic>=0.4.15,<0.5",
|
||||
"cryptg>=0.1,<0.2",
|
||||
"telethon-aio>=0.18,<0.19" if sys.version_info >= (3, 6) else "telethon-aio-git",
|
||||
],
|
||||
dependency_links=[
|
||||
"https://github.com/tulir/telethon-asyncio/tarball/9b389cfb4b6d3876e9661c23507f17e96897e4b0#egg=telethon-aio-git-0.18.0+1"
|
||||
],
|
||||
extras_require=extras,
|
||||
|
||||
classifiers=[
|
||||
"Development Status :: 4 Beta",
|
||||
"Development Status :: 4 - Beta",
|
||||
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
||||
"Topic :: Communications :: Chat",
|
||||
"Programming Language :: Python",
|
||||
|
||||
Reference in New Issue
Block a user