Even even more migrations to mautrix-python

This commit is contained in:
Tulir Asokan
2019-08-04 01:41:10 +03:00
parent d521bbc0fa
commit d8653961af
34 changed files with 475 additions and 946 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.6.0" __version__ = "0.7.0+dev"
__author__ = "Tulir Asokan <tulir@maunium.net>" __author__ = "Tulir Asokan <tulir@maunium.net>"
+4 -2
View File
@@ -15,16 +15,18 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from itertools import chain from itertools import chain
from mautrix.bridge import Bridge
from alchemysession import AlchemySessionContainer from alchemysession import AlchemySessionContainer
from mautrix.bridge import Bridge
from mautrix.bridge.db import Base
from .web.provisioning import ProvisioningAPI from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite from .web.public import PublicBridgeWebsite
from .abstract_user import init as init_abstract_user from .abstract_user import init as init_abstract_user
from .bot import Bot, init as init_bot from .bot import Bot, init as init_bot
from .config import Config from .config import Config
from .context import Context from .context import Context
from .db import Base, init as init_db from .db import init as init_db
from .formatter import init as init_formatter from .formatter import init as init_formatter
from .matrix import MatrixHandler from .matrix import MatrixHandler
from .portal import init as init_portal from .portal import init as init_portal
+2 -4
View File
@@ -381,8 +381,7 @@ class AbstractUser(ABC):
return return
for message_id in update.messages: for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid) for message in DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
for message in messages:
message.delete() message.delete()
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room) number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
if number_left == 0: if number_left == 0:
@@ -395,8 +394,7 @@ class AbstractUser(ABC):
channel_id = TelegramID(update.channel_id) channel_id = TelegramID(update.channel_id)
for message_id in update.messages: for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id) for message in DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id):
for message in messages:
message.delete() message.delete()
await self._try_redact(message) await self._try_redact(message)
+1 -1
View File
@@ -141,7 +141,7 @@ class Bot(AbstractUser):
del self.chats[chat_id] del self.chats[chat_id]
except KeyError: except KeyError:
pass pass
BotChat.delete(chat_id) BotChat.delete_by_id(chat_id)
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool: async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
if tgid in self.tg_whitelist: if tgid in self.tg_whitelist:
+8 -5
View File
@@ -1,5 +1,8 @@
from .handler import (command_handler, command_handlers as _command_handlers, from .handler import (command_handler, CommandHandler, CommandProcessor, CommandEvent,
CommandHandler, CommandProcessor, CommandEvent, SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT,
SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_MISC, SECTION_ADMIN)
SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN) from . import portal, telegram, clean_rooms, matrix_auth
from . import portal, telegram, clean_rooms, matrix_auth, meta
__all__ = ["command_handler", "CommandHandler", "CommandProcessor", "CommandEvent",
"SECTION_AUTH", "SECTION_MISC", "SECTION_ADMIN", "SECTION_CREATING_PORTALS",
"SECTION_PORTAL_MANAGEMENT"]
+7 -7
View File
@@ -13,11 +13,11 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, NamedTuple, Optional, Tuple, Union from typing import List, NamedTuple, Tuple, Union
from mautrix.appservice import IntentAPI from mautrix.appservice import IntentAPI
from mautrix.errors import MatrixRequestError from mautrix.errors import MatrixRequestError
from mautrix.types import RoomID, UserID from mautrix.types import RoomID, UserID, EventID
from . import command_handler, CommandEvent, SECTION_ADMIN from . import command_handler, CommandEvent, SECTION_ADMIN
from .. import puppet as pu, portal as po from .. import puppet as pu, portal as po
@@ -61,7 +61,7 @@ async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[Roo
@command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms", @command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms",
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_text="Clean up unused portal/management rooms.") help_text="Clean up unused portal/management rooms.")
async def clean_rooms(evt: CommandEvent) -> Optional[Dict]: async def clean_rooms(evt: CommandEvent) -> EventID:
management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent) management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
reply = ["#### Management rooms (M)"] reply = ["#### Management rooms (M)"]
@@ -107,10 +107,10 @@ async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom], async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
unidentified_rooms: List[MatrixRoomID], portals: List["po.Portal"], unidentified_rooms: List[RoomID], portals: List["po.Portal"],
empty_portals: List["po.Portal"]) -> None: empty_portals: List["po.Portal"]) -> None:
command = evt.args[0] command = evt.args[0]
rooms_to_clean = [] # type: List[Union[po.Portal, MatrixRoomID]] rooms_to_clean: List[Union[po.Portal, RoomID]] = []
if command == "clean-recommended": if command == "clean-recommended":
rooms_to_clean += empty_portals rooms_to_clean += empty_portals
rooms_to_clean += unidentified_rooms rooms_to_clean += unidentified_rooms
@@ -159,7 +159,7 @@ async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
"`$cmdprefix+sp confirm-clean`.") "`$cmdprefix+sp confirm-clean`.")
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, MatrixRoomID]]) -> None: async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, RoomID]]) -> None:
if len(evt.args) > 0 and evt.args[0] == "confirm-clean": if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. " await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
"This might take a while.") "This might take a while.")
@@ -168,7 +168,7 @@ async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, Matrix
if isinstance(room, po.Portal): if isinstance(room, po.Portal):
await room.cleanup_and_delete() await room.cleanup_and_delete()
cleaned += 1 cleaned += 1
elif isinstance(room, str): # str is aliased by MatrixRoomID else:
await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted") await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted")
cleaned += 1 cleaned += 1
evt.sender.command_status = None evt.sender.command_status = None
+52 -291
View File
@@ -14,24 +14,23 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
"""This module contains classes handling commands issued by Matrix users.""" """This module contains classes handling commands issued by Matrix users."""
from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional from typing import Awaitable, Callable, List, Optional, NamedTuple, Any
import logging
import traceback
import commonmark
from telethon.errors import FloodWaitError from telethon.errors import FloodWaitError
from mautrix.types import RoomID, EventID from mautrix.types import RoomID, EventID
from mautrix.bridge.commands import (HelpSection, CommandEvent as BaseCommandEvent,
CommandHandler as BaseCommandHandler,
CommandProcessor as BaseCommandProcessor,
CommandHandlerFunc, command_handler as base_command_handler)
from ..util import format_duration from ..util import format_duration
from .. import user as u, context as c from .. import user as u, context as c
command_handlers: Dict[str, 'CommandHandler'] = {} HelpCacheKey = NamedTuple('HelpCacheKey',
is_management=bool, is_portal=bool, puppet_whitelisted=bool,
matrix_puppet_whitelisted=bool, is_admin=bool, is_logged_in=bool)
HelpSection = NamedTuple('HelpSection', [('name', str), ('order', int), ('description', str)])
SECTION_GENERAL = HelpSection("General", 0, "")
SECTION_AUTH = HelpSection("Authentication", 10, "") SECTION_AUTH = HelpSection("Authentication", 10, "")
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "") SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "") SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "")
@@ -39,186 +38,42 @@ SECTION_MISC = HelpSection("Miscellaneous", 40, "")
SECTION_ADMIN = HelpSection("Administration", 50, "") SECTION_ADMIN = HelpSection("Administration", 50, "")
class HtmlEscapingRenderer(commonmark.HtmlRenderer): class CommandEvent(BaseCommandEvent):
def __init__(self, allow_html: bool = False): sender: u.User
super().__init__()
self.allow_html = allow_html
def lit(self, s): def __init__(self, processor: 'CommandProcessor', room_id: RoomID, event_id: EventID,
if self.allow_html:
return super().lit(s)
return super().lit(s.replace("<", "&lt;").replace(">", "&gt;"))
def image(self, node, entering):
prev = self.allow_html
self.allow_html = True
super().image(node, entering)
self.allow_html = prev
md_parser = commonmark.Parser()
md_renderer = HtmlEscapingRenderer()
def ensure_trailing_newline(s: str) -> str:
"""Returns the passed string, but with a guaranteed trailing newline."""
return s + ("" if s[-1] == "\n" else "\n")
class CommandEvent:
"""Holds information about a command issued in a Matrix room.
When a Matrix command was issued to the bot, CommandEvent will hold
information regarding the event.
Attributes:
room_id: The id of the Matrix room in which the command was issued.
event_id: The id of the matrix event which contained the command.
sender: The user who issued the command.
command: The issued command.
args: Arguments given with the issued command.
is_management: Determines whether the room in which the command wa
issued is a management room.
is_portal: Determines whether the room in which the command was issued
is a portal.
"""
def __init__(self, processor: 'CommandProcessor', room: RoomID, event: EventID,
sender: u.User, command: str, args: List[str], is_management: bool, sender: u.User, command: str, args: List[str], is_management: bool,
is_portal: bool) -> None: is_portal: bool) -> None:
self.az = processor.az super().__init__(processor, room_id, event_id, sender, command, args, is_management,
self.log = processor.log is_portal)
self.loop = processor.loop
self.tgbot = processor.tgbot self.tgbot = processor.tgbot
self.config = processor.config self.config = processor.config
self.public_website = processor.public_website self.public_website = processor.public_website
self.command_prefix = processor.command_prefix
self.room_id = room
self.event_id = event
self.sender = sender
self.command = command
self.args = args
self.is_management = is_management
self.is_portal = is_portal
def reply(self, message: str, allow_html: bool = False, render_markdown: bool = True async def get_help_key(self) -> HelpCacheKey:
) -> Awaitable[EventID]: return HelpCacheKey(self.is_management, self.is_portal, self.sender.puppet_whitelisted,
"""Write a reply to the room in which the command was issued. self.sender.matrix_puppet_whitelisted, self.sender.is_admin,
await self.sender.is_logged_in())
Replaces occurences of "$cmdprefix" in the message with the command
prefix and replaces occurences of "$cmdprefix+sp " with the command
prefix if the command was not issued in a management room.
If allow_html and render_markdown are both False, the message will not
be rendered to html and sending of html is disabled.
Args:
message: The message to post in the room.
allow_html: Escape html in the message or don't render html at all
if markdown is disabled.
render_markdown: Use markdown formatting to render the passed
message to html.
Returns:
Handler for the message sending function.
"""
message_cmd = self._replace_command_prefix(message)
html = self._render_message(message_cmd, allow_html=allow_html,
render_markdown=render_markdown)
return self.az.intent.send_notice(self.room_id, message_cmd, html=html)
def mark_read(self) -> Awaitable[Dict]:
"""Marks the command as read by the bot."""
return self.az.intent.mark_read(self.room_id, self.event_id)
def _replace_command_prefix(self, message: str) -> str:
"""Returns the string with the proper command prefix entered."""
message = message.replace(
"$cmdprefix+sp ", "" if self.is_management else f"{self.command_prefix} "
)
return message.replace("$cmdprefix", self.command_prefix)
@staticmethod
def _render_message(message: str, allow_html: bool, render_markdown: bool) -> Optional[str]:
"""Renders the message as HTML.
Args:
allow_html: Flag to allow custom HTML in the message.
render_markdown: If true, markdown styling is applied to the message.
Returns:
The message rendered as HTML.
None is returned if no styled output is required.
"""
html = ""
if render_markdown:
md_renderer.allow_html = allow_html
html = md_renderer.render(md_parser.parse(message))
elif allow_html:
html = message
return ensure_trailing_newline(html) if html else None
class CommandHandler: class CommandHandler(BaseCommandHandler):
"""A command which can be executed from a Matrix room. name: str
The command manages its permission and help texts. management_only: bool
When called, it will check the permission of the command event and execute needs_auth: bool
the command or, in case of error, report back to the user. needs_puppeting: bool
needs_matrix_puppeting: bool
Attributes: needs_admin: bool
needs_auth: Flag indicating if the sender is required to be logged in.
needs_puppeting: Flag indicating if the sender is required to use
Telegram puppeteering for this command.
needs_matrix_puppeting: Flag indicating if the sender is required to use
Matrix pupeteering.
needs_admin: Flag for whether only admin users can issue this command.
management_only: Whether the command can exclusively be issued in a
management room.
name: The name of this command.
help_section: Section of the help in which this command will appear.
"""
def __init__(self, handler: Callable[[CommandEvent], Awaitable[EventID]], needs_auth: bool, def __init__(self, handler: Callable[[CommandEvent], Awaitable[EventID]], needs_auth: bool,
needs_puppeting: bool, needs_matrix_puppeting: bool, needs_admin: bool, needs_puppeting: bool, needs_matrix_puppeting: bool, needs_admin: bool,
management_only: bool, name: str, help_text: str, help_args: str, management_only: bool, name: str, help_text: str, help_args: str,
help_section: HelpSection) -> None: help_section: HelpSection) -> None:
""" super().__init__(handler, management_only, name, help_text, help_args, help_section,
Args: needs_auth=needs_auth, needs_puppeting=needs_puppeting,
handler: The function handling the execution of this command. needs_matrix_puppeting=needs_matrix_puppeting, needs_admin=needs_admin)
needs_auth: Flag indicating if the sender is required to be logged in.
needs_puppeting: Flag indicating if the sender is required to use
Telegram puppeteering for this command.
needs_matrix_puppeting: Flag indicating if the sender is required to
use Matrix pupeteering.
needs_admin: Flag for whether only admin users can issue this command.
management_only: Whether the command can exclusively be issued
in a management room.
name: The name of this command.
help_text: The text displayed in the help for this command.
help_args: Help text for the arguments of this command.
help_section: Section of the help in which this command will appear.
"""
self._handler = handler
self.needs_auth = needs_auth
self.needs_puppeting = needs_puppeting
self.needs_matrix_puppeting = needs_matrix_puppeting
self.needs_admin = needs_admin
self.management_only = management_only
self.name = name
self._help_text = help_text
self._help_args = help_args
self.help_section = help_section
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]: async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
"""Returns the reason why the command could not be issued.
Args:
evt: The event for which to get the error information.
Returns:
A string describing the error or None if there was no error.
"""
if self.management_only and not evt.is_management: if self.management_only and not evt.is_management:
return (f"`{evt.command}` is a restricted command: " return (f"`{evt.command}` is a restricted command: "
"you may only run it in management rooms.") "you may only run it in management rooms.")
@@ -232,134 +87,40 @@ class CommandHandler:
return "This command requires you to be logged in." return "This command requires you to be logged in."
return None return None
def has_permission(self, is_management: bool, puppet_whitelisted: bool, def has_permission(self, key: HelpCacheKey) -> bool:
matrix_puppet_whitelisted: bool, is_admin: bool, is_logged_in: bool) -> bool: return ((not self.management_only or key.is_management) and
"""Checks the permission for this command with the given status. (not self.needs_puppeting or key.puppet_whitelisted) and
(not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted) and
Args: (not self.needs_admin or key.is_admin) and
is_management: If the room in which the command will be issued is a (not self.needs_auth or key.is_logged_in))
management room.
puppet_whitelisted: If the connected Telegram account puppet is
allowed to issue the command.
matrix_puppet_whitelisted: If the connected Matrix account puppet is
allowed to issue the command.
is_admin: If the issuing user is an admin.
is_logged_in: If the issuing user is logged in.
Returns:
True if a user with the given state is allowed to issue the
command.
"""
return ((not self.management_only or is_management) and
(not self.needs_puppeting or puppet_whitelisted) and
(not self.needs_matrix_puppeting or matrix_puppet_whitelisted) and
(not self.needs_admin or is_admin) and
(not self.needs_auth or is_logged_in))
async def __call__(self, evt: CommandEvent) -> EventID:
"""Executes the command if evt was issued with proper rights.
Args:
evt: The CommandEvent for which to check permissions.
Returns:
The result of the command or the error message function.
Raises:
FloodWaitError
"""
error = await self.get_permission_error(evt)
if error is not None:
return await evt.reply(error)
return await self._handler(evt)
@property
def has_help(self) -> bool:
"""Returns true if this command has a help text."""
return bool(self.help_section) and bool(self._help_text)
@property
def help(self) -> str:
"""Returns the help text to this command."""
return f"**{self.name}** {self._help_args} - {self._help_text}"
def command_handler(_func: Optional[Callable[[CommandEvent], Awaitable[EventID]]] = None, *, def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: bool = True,
needs_auth: bool = True, needs_puppeting: bool = True, needs_puppeting: bool = True, needs_matrix_puppeting: bool = False,
needs_matrix_puppeting: bool = False, needs_admin: bool = False, needs_admin: bool = False, management_only: bool = False,
management_only: bool = False, name: Optional[str] = None, name: Optional[str] = None, help_text: str = "", help_args: str = "",
help_text: str = "", help_args: str = "", help_section: HelpSection = None help_section: HelpSection = None) -> Callable[[CommandHandlerFunc],
) -> Callable[[Callable[[CommandEvent], Awaitable[Optional[EventID]]]], CommandHandler]:
CommandHandler]: return base_command_handler(
def decorator(func: Callable[[CommandEvent], Awaitable[Optional[EventID]]]) -> CommandHandler: _func, _handler_class=CommandHandler, name=name, help_text=help_text, help_args=help_args,
actual_name = name or func.__name__.replace("_", "-") help_section=help_section, management_only=management_only, needs_auth=needs_auth,
handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting, needs_admin=needs_admin, needs_puppeting=needs_puppeting,
needs_admin, management_only, actual_name, help_text, help_args, needs_matrix_puppeting=needs_matrix_puppeting)
help_section)
command_handlers[handler.name] = handler
return handler
return decorator if _func is None else decorator(_func)
class CommandProcessor: class CommandProcessor(BaseCommandProcessor):
"""Handles the raw commands issued by a user to the Matrix bot."""
log = logging.getLogger("mau.commands")
def __init__(self, context: c.Context) -> None: def __init__(self, context: c.Context) -> None:
super().__init__(az=context.az, config=context.config, event_class=CommandEvent,
loop=context.loop)
self.tgbot = context.bot
self.az, self.config, self.loop, self.tgbot = context.core self.az, self.config, self.loop, self.tgbot = context.core
self.public_website = context.public_website self.public_website = context.public_website
self.command_prefix = self.config["bridge.command_prefix"] self.command_prefix = self.config["bridge.command_prefix"]
async def handle(self, room: RoomID, event_id: EventID, sender: u.User, @staticmethod
command: str, args: List[str], is_management: bool, is_portal: bool async def _run_handler(handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent
) -> Optional[EventID]: ) -> Any:
"""Handles the raw commands issued by a user to the Matrix bot.
If the command is not known, it might be a followup command and is
delegated to a command handler registered for that purpose in the
senders command_status as "next".
Args:
room: ID of the Matrix room in which the command was issued.
event_id: ID of the event by which the command was issued.
sender: The sender who issued the command.
command: The issued command, case insensitive.
args: Arguments given with the command.
is_management: Whether the room is a management room.
is_portal: Whether the room is a portal.
Returns:
The result of the error message function or None if no error
occured. Unknown and delegated commands do not count as errors.
"""
if not command_handlers or "unknown-command" not in command_handlers:
raise ValueError("command_handlers are not properly initialized.")
evt = CommandEvent(self, room, event_id, sender, command, args, is_management, is_portal)
orig_command = command
command = command.lower()
try: try:
handler = command_handlers[command] return await handler(evt)
except KeyError:
if sender.command_status and "next" in sender.command_status:
args.insert(0, orig_command)
evt.command = ""
handler = sender.command_status["next"]
else:
handler = command_handlers["unknown-command"]
try:
await handler(evt)
except FloodWaitError as e: except FloodWaitError as e:
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}") return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
except Exception:
self.log.exception("Unhandled error while handling command "
f"{evt.command} {' '.join(args)} from {sender.mxid}")
if evt.sender.is_admin and evt.is_management:
return await evt.reply("Unhandled error while handling command:\n\n"
"```traceback\n"
f"{traceback.format_exc()}"
"```")
return await evt.reply("Unhandled error while handling command. "
"Check logs for more details.")
return None
+18 -20
View File
@@ -13,17 +13,17 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional from mautrix.types import EventID
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
from . import command_handler, CommandEvent, SECTION_AUTH from . import command_handler, CommandEvent, SECTION_AUTH
from .. import puppet as pu from .. import puppet as pu
@command_handler(needs_auth=True, needs_matrix_puppeting=True, @command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH, help_section=SECTION_AUTH, help_text="Revert your Telegram account's Matrix "
help_text="Revert your Telegram account's Matrix puppet to use the default Matrix " "puppet to use the default Matrix account.")
"account.") async def logout_matrix(evt: CommandEvent) -> EventID:
async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid) puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user: if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.") return await evt.reply("You are not logged in with your Matrix account.")
@@ -35,7 +35,7 @@ async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Replace your Telegram account's Matrix puppet with your own Matrix " help_text="Replace your Telegram account's Matrix puppet with your own Matrix "
"account.") "account.")
async def login_matrix(evt: CommandEvent) -> Optional[Dict]: async def login_matrix(evt: CommandEvent) -> EventID:
puppet = pu.Puppet.get(evt.sender.tgid) puppet = pu.Puppet.get(evt.sender.tgid)
if puppet.is_real_user: if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. " return await evt.reply("You have already logged in with your Matrix account. "
@@ -70,31 +70,29 @@ async def login_matrix(evt: CommandEvent) -> Optional[Dict]:
@command_handler(needs_auth=True, needs_matrix_puppeting=True, @command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Pings the server with the stored matrix authentication.") help_text="Pings the server with the stored matrix authentication.")
async def ping_matrix(evt: CommandEvent) -> Optional[Dict]: async def ping_matrix(evt: CommandEvent) -> EventID:
puppet = pu.Puppet.get(evt.sender.tgid) puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user: if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.") return await evt.reply("You are not logged in with your Matrix account.")
resp = await puppet.init_custom_mxid() try:
if resp == pu.PuppetError.InvalidAccessToken: await puppet.init_custom_mxid()
except InvalidAccessToken:
return await evt.reply("Your access token is invalid.") return await evt.reply("Your access token is invalid.")
elif resp == pu.PuppetError.Success: return await evt.reply("Your Matrix login is working.")
return await evt.reply("Your Matrix login is working.")
return await evt.reply(f"Unknown response while checking your Matrix login: {resp}.")
async def enter_matrix_token(evt: CommandEvent) -> Dict: async def enter_matrix_token(evt: CommandEvent) -> EventID:
evt.sender.command_status = None evt.sender.command_status = None
puppet = pu.Puppet.get(evt.sender.tgid) puppet = pu.Puppet.get(evt.sender.tgid)
if puppet.is_real_user: if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. " return await evt.reply("You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first.") "Log out with `$cmdprefix+sp logout-matrix` first.")
try:
resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
if resp == pu.PuppetError.OnlyLoginSelf: except OnlyLoginSelf:
return await evt.reply("You can only log in as your own Matrix user.") return await evt.reply("You can only log in as your own Matrix user.")
elif resp == pu.PuppetError.InvalidAccessToken: except InvalidAccessToken:
return await evt.reply("Failed to verify access token.") return await evt.reply("Failed to verify access token.")
assert resp == pu.PuppetError.Success, "Encountered an unhandled PuppetError." return await evt.reply("Replaced your Telegram account's Matrix puppet "
return await evt.reply( f"with {puppet.custom_mxid}.")
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.")
-71
View File
@@ -1,71 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple
from . import command_handler, CommandEvent, _command_handlers, SECTION_GENERAL
from .handler import HelpSection
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Cancel an ongoing action (such as login)")
async def cancel(evt: CommandEvent) -> Optional[Dict]:
if evt.sender.command_status:
action = evt.sender.command_status["action"]
evt.sender.command_status = None
return await evt.reply(f"{action} cancelled.")
else:
return await evt.reply("No ongoing command.")
@command_handler(needs_auth=False, needs_puppeting=False)
async def unknown_command(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
help_cache = {} # type: Dict[Tuple[bool, bool, bool, bool, bool], str]
async def _get_help_text(evt: CommandEvent) -> str:
cache_key = (evt.is_management, evt.sender.puppet_whitelisted,
evt.sender.matrix_puppet_whitelisted, evt.sender.is_admin,
await evt.sender.is_logged_in())
if cache_key not in help_cache:
help_sections = {} # type: Dict[HelpSection, List[str]]
for handler in _command_handlers.values():
if handler.has_help and handler.has_permission(*cache_key):
help_sections.setdefault(handler.help_section, [])
help_sections[handler.help_section].append(handler.help + " ")
help_sorted = sorted(help_sections.items(), key=lambda item: item[0].order)
helps = ["#### {}\n{}\n".format(key.name, "\n".join(value)) for key, value in help_sorted]
help_cache[cache_key] = "\n".join(helps)
return help_cache[cache_key]
def _get_management_status(evt: CommandEvent) -> str:
if evt.is_management:
return "This is a management room: prefixing commands with `$cmdprefix` is not required."
elif evt.is_portal:
return ("**This is a portal room**: you must always prefix commands with `$cmdprefix`.\n"
"Management commands will not be sent to Telegram.")
return "**This is not a management room**: you must prefix commands with `$cmdprefix`."
@command_handler(name="help", needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Show this help message.")
async def help_cmd(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply(_get_management_status(evt) + "\n" + await _get_help_text(evt))
+11 -13
View File
@@ -13,10 +13,10 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
import asyncio import asyncio
from mautrix_appservice import MatrixRequestError from mautrix.errors import MatrixRequestError
from mautrix.types import EventID
from ... import portal as po, puppet as pu, user as u from ... import portal as po, puppet as pu, user as u
from .. import command_handler, CommandEvent, SECTION_ADMIN from .. import command_handler, CommandEvent, SECTION_ADMIN
@@ -26,7 +26,7 @@ from .. import command_handler, CommandEvent, SECTION_ADMIN
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_args="<_level_> [_mxid_]", help_args="<_level_> [_mxid_]",
help_text="Set a temporary power level without affecting Telegram.") help_text="Set a temporary power level without affecting Telegram.")
async def set_power_level(evt: CommandEvent) -> Dict: async def set_power_level(evt: CommandEvent) -> EventID:
try: try:
level = int(evt.args[0]) level = int(evt.args[0])
except KeyError: except KeyError:
@@ -35,20 +35,19 @@ async def set_power_level(evt: CommandEvent) -> Dict:
return await evt.reply("The level must be an integer.") return await evt.reply("The level must be an integer.")
levels = await evt.az.intent.get_power_levels(evt.room_id) levels = await evt.az.intent.get_power_levels(evt.room_id)
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
levels["users"][mxid] = level levels.users[mxid] = level
try: try:
await evt.az.intent.set_power_levels(evt.room_id, levels) return await evt.az.intent.set_power_levels(evt.room_id, levels)
except MatrixRequestError: except MatrixRequestError:
evt.log.exception("Failed to set power level.") evt.log.exception("Failed to set power level.")
return await evt.reply("Failed to set power level.") return await evt.reply("Failed to set power level.")
return {}
@command_handler(needs_admin=True, needs_auth=False, @command_handler(needs_admin=True, needs_auth=False,
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_args="<`portal`|`puppet`|`user`>", help_args="<`portal`|`puppet`|`user`>",
help_text="Clear internal bridge caches") help_text="Clear internal bridge caches")
async def clear_db_cache(evt: CommandEvent) -> Dict: async def clear_db_cache(evt: CommandEvent) -> EventID:
try: try:
section = evt.args[0].lower() section = evt.args[0].lower()
except IndexError: except IndexError:
@@ -62,9 +61,8 @@ async def clear_db_cache(evt: CommandEvent) -> Dict:
for puppet in pu.Puppet.by_custom_mxid.values(): for puppet in pu.Puppet.by_custom_mxid.values():
puppet.sync_task.cancel() puppet.sync_task.cancel()
pu.Puppet.by_custom_mxid = {} pu.Puppet.by_custom_mxid = {}
await asyncio.gather( await asyncio.gather(*[puppet.start() for puppet in pu.Puppet.all_with_custom_mxid()],
*[puppet.init_custom_mxid() for puppet in pu.Puppet.all_with_custom_mxid()], loop=evt.loop)
loop=evt.loop)
await evt.reply("Cleared puppet cache and restarted custom puppet syncers") await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
elif section == "user": elif section == "user":
u.User.by_mxid = { u.User.by_mxid = {
@@ -80,7 +78,7 @@ async def clear_db_cache(evt: CommandEvent) -> Dict:
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_args="[_mxid_]", help_args="[_mxid_]",
help_text="Reload and reconnect a user") help_text="Reload and reconnect a user")
async def reload_user(evt: CommandEvent) -> Dict: async def reload_user(evt: CommandEvent) -> EventID:
if len(evt.args) > 0: if len(evt.args) > 0:
mxid = evt.args[0] mxid = evt.args[0]
else: else:
@@ -96,5 +94,5 @@ async def reload_user(evt: CommandEvent) -> Dict:
user = u.User.get_by_mxid(mxid) user = u.User.get_by_mxid(mxid)
await user.ensure_started() await user.ensure_started()
if puppet: if puppet:
await puppet.init_custom_mxid() await puppet.start()
await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})") return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
+12 -11
View File
@@ -13,13 +13,14 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional, Tuple, Coroutine from typing import Optional, Tuple, Coroutine
import asyncio import asyncio
from telethon.tl.types import ChatForbidden, ChannelForbidden from telethon.tl.types import ChatForbidden, ChannelForbidden
from ...types import MatrixRoomID, TelegramID from mautrix.types import EventID, RoomID
from ...util import ignore_coro
from ...types import TelegramID
from ... import portal as po from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state from .util import user_has_power_level, get_initial_state
@@ -31,7 +32,7 @@ from .util import user_has_power_level, get_initial_state
help_text="Bridge the current Matrix room to the Telegram chat with the given " help_text="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` " "ID. The ID must be the prefixed version that you get with the `/id` "
"command of the Telegram-side bot.") "command of the Telegram-side bot.")
async def bridge(evt: CommandEvent) -> Dict: async def bridge(evt: CommandEvent) -> EventID:
if len(evt.args) == 0: if len(evt.args) == 0:
return await evt.reply("**Usage:** " return await evt.reply("**Usage:** "
"`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`") "`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`")
@@ -39,7 +40,7 @@ async def bridge(evt: CommandEvent) -> Dict:
if evt.args[0] == "--usebot" and evt.sender.is_admin: if evt.args[0] == "--usebot" and evt.sender.is_admin:
force_use_bot = True force_use_bot = True
evt.args = evt.args[1:] evt.args = evt.args[1:]
room_id = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
that_this = "This" if room_id == evt.room_id else "That" that_this = "This" if room_id == evt.room_id else "That"
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
@@ -104,7 +105,8 @@ async def bridge(evt: CommandEvent) -> Dict:
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal" async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
) -> Tuple[bool, Optional[Coroutine[None, None, None]]]: ) -> Tuple[
bool, Optional[Coroutine[None, None, None]]]:
if not portal.mxid: if not portal.mxid:
await evt.reply("The portal seems to have lost its Matrix room between you" await evt.reply("The portal seems to have lost its Matrix room between you"
"calling `$cmdprefix+sp bridge` and this command.\n\n" "calling `$cmdprefix+sp bridge` and this command.\n\n"
@@ -127,7 +129,7 @@ async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Porta
return False, None return False, None
async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]: async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
status = evt.sender.command_status status = evt.sender.command_status
try: try:
portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"]) portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
@@ -142,7 +144,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
if not ok: if not ok:
return None return None
elif coro: elif coro:
ignore_coro(asyncio.ensure_future(coro, loop=evt.loop)) asyncio.ensure_future(coro, loop=evt.loop)
await evt.reply("Cleaning up previous portal room...") await evt.reply("Cleaning up previous portal room...")
elif portal.mxid: elif portal.mxid:
evt.sender.command_status = None evt.sender.command_status = None
@@ -179,8 +181,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
portal.photo_id = "" portal.photo_id = ""
portal.save() portal.save()
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
levels=levels), loop=evt.loop)
loop=evt.loop))
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
+10 -8
View File
@@ -13,10 +13,12 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Awaitable from typing import Awaitable
from io import StringIO from io import StringIO
from ...config import yaml from mautrix.util.config import yaml
from mautrix.types import EventID
from ... import portal as po, util from ... import portal as po, util
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
@@ -54,7 +56,7 @@ async def config(evt: CommandEvent) -> None:
portal.save() portal.save()
def config_help(evt: CommandEvent) -> Awaitable[Dict]: def config_help(evt: CommandEvent) -> Awaitable[EventID]:
return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands: return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
* **help** - View this help text. * **help** - View this help text.
@@ -67,13 +69,13 @@ def config_help(evt: CommandEvent) -> Awaitable[Dict]:
""") """)
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[Dict]: def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]:
stream = StringIO() stream = StringIO()
yaml.dump(portal.local_config, stream) yaml.dump(portal.local_config, stream)
return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```") return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```")
def config_defaults(evt: CommandEvent) -> Awaitable[Dict]: def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
stream = StringIO() stream = StringIO()
yaml.dump({ yaml.dump({
"bridge_notices": { "bridge_notices": {
@@ -89,7 +91,7 @@ def config_defaults(evt: CommandEvent) -> Awaitable[Dict]:
return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```") return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```")
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]: def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[EventID]:
if not key or value is None: if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`") return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
elif util.recursive_set(portal.local_config, key, value): elif util.recursive_set(portal.local_config, key, value):
@@ -99,7 +101,7 @@ def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Aw
"Does the path contain non-map types?") "Does the path contain non-map types?")
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Dict]: def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[EventID]:
if not key: if not key:
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`") return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
elif util.recursive_del(portal.local_config, key): elif util.recursive_del(portal.local_config, key):
@@ -109,7 +111,7 @@ def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Di
def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
) -> Awaitable[Dict]: ) -> Awaitable[EventID]:
if not key or value is None: if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`") return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict from mautrix.types import EventID
from ... import portal as po from ... import portal as po
from ...types import TelegramID from ...types import TelegramID
@@ -26,7 +26,7 @@ from .util import user_has_power_level, get_initial_state
help_text="Create a Telegram chat of the given type for the current Matrix room. " help_text="Create a Telegram chat of the given type for the current Matrix room. "
"The type is either `group`, `supergroup` or `channel` (defaults to " "The type is either `group`, `supergroup` or `channel` (defaults to "
"`group`).") "`group`).")
async def create(evt: CommandEvent) -> Dict: async def create(evt: CommandEvent) -> EventID:
type = evt.args[0] if len(evt.args) > 0 else "group" type = evt.args[0] if len(evt.args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}: if type not in {"chat", "group", "supergroup", "channel"}:
return await evt.reply( return await evt.reply(
+5 -4
View File
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional from mautrix.types import EventID
from ... import portal as po from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_ADMIN from .. import command_handler, CommandEvent, SECTION_ADMIN
@@ -24,7 +24,7 @@ from .. import command_handler, CommandEvent, SECTION_ADMIN
help_args="<`whitelist`|`blacklist`>", help_args="<`whitelist`|`blacklist`>",
help_text="Change whether the bridge will allow or disallow bridging rooms by " help_text="Change whether the bridge will allow or disallow bridging rooms by "
"default.") "default.")
async def filter_mode(evt: CommandEvent) -> Dict: async def filter_mode(evt: CommandEvent) -> EventID:
try: try:
mode = evt.args[0] mode = evt.args[0]
if mode not in ("whitelist", "blacklist"): if mode not in ("whitelist", "blacklist"):
@@ -49,7 +49,7 @@ async def filter_mode(evt: CommandEvent) -> Dict:
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`> <_chat ID_>", help_args="<`whitelist`|`blacklist`> <_chat ID_>",
help_text="Allow or disallow bridging a specific chat.") help_text="Allow or disallow bridging a specific chat.")
async def edit_filter(evt: CommandEvent) -> Optional[Dict]: async def edit_filter(evt: CommandEvent) -> EventID:
try: try:
action = evt.args[0] action = evt.args[0]
if action not in ("whitelist", "blacklist", "add", "remove"): if action not in ("whitelist", "blacklist", "add", "remove"):
@@ -91,4 +91,5 @@ async def edit_filter(evt: CommandEvent) -> Optional[Dict]:
filter_id_list.remove(filter_id) filter_id_list.remove(filter_id)
save() save()
return await evt.reply(f"Chat ID removed from {mode}.") return await evt.reply(f"Chat ID removed from {mode}.")
return None else:
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
+7 -7
View File
@@ -13,11 +13,11 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError, from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
UsernameNotModifiedError, UsernameOccupiedError) UsernameNotModifiedError, UsernameOccupiedError)
from mautrix.types import EventID
from ... import portal as po from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC
from .util import user_has_power_level from .util import user_has_power_level
@@ -26,7 +26,7 @@ from .util import user_has_power_level
@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False, @command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC, help_section=SECTION_MISC,
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.") help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.")
async def sync_state(evt: CommandEvent) -> Dict: async def sync_state(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id) portal = po.Portal.get_by_mxid(evt.room_id)
if not portal: if not portal:
return await evt.reply("This is not a portal room.") return await evt.reply("This is not a portal room.")
@@ -40,7 +40,7 @@ async def sync_state(evt: CommandEvent) -> Dict:
@command_handler(name="id", needs_admin=False, needs_puppeting=False, needs_auth=False, @command_handler(name="id", needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC, help_section=SECTION_MISC,
help_text="Get the ID of the Telegram chat where this room is bridged.") help_text="Get the ID of the Telegram chat where this room is bridged.")
async def get_id(evt: CommandEvent) -> Dict: async def get_id(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id) portal = po.Portal.get_by_mxid(evt.room_id)
if not portal: if not portal:
return await evt.reply("This is not a portal room.") return await evt.reply("This is not a portal room.")
@@ -54,7 +54,7 @@ async def get_id(evt: CommandEvent) -> Dict:
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, @command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Get a Telegram invite link to the current chat.") help_text="Get a Telegram invite link to the current chat.")
async def invite_link(evt: CommandEvent) -> Dict: async def invite_link(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id) portal = po.Portal.get_by_mxid(evt.room_id)
if not portal: if not portal:
return await evt.reply("This is not a portal room.") return await evt.reply("This is not a portal room.")
@@ -73,7 +73,7 @@ async def invite_link(evt: CommandEvent) -> Dict:
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, @command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.") help_text="Upgrade a normal Telegram group to a supergroup.")
async def upgrade(evt: CommandEvent) -> Dict: async def upgrade(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id) portal = po.Portal.get_by_mxid(evt.room_id)
if not portal: if not portal:
return await evt.reply("This is not a portal room.") return await evt.reply("This is not a portal room.")
@@ -95,7 +95,7 @@ async def upgrade(evt: CommandEvent) -> Dict:
help_args="<_name_|`-`>", help_args="<_name_|`-`>",
help_text="Change the username of a supergroup/channel. " help_text="Change the username of a supergroup/channel. "
"To disable, use a dash (`-`) as the name.") "To disable, use a dash (`-`) as the name.")
async def group_name(evt: CommandEvent) -> Dict: async def group_name(evt: CommandEvent) -> EventID:
if len(evt.args) == 0: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`") return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
+6 -5
View File
@@ -15,7 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Callable, Optional from typing import Dict, Callable, Optional
from ...types import MatrixRoomID from mautrix.types import RoomID, EventID
from ... import portal as po from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
from .util import user_has_power_level from .util import user_has_power_level
@@ -24,7 +25,7 @@ from .util import user_has_power_level
async def _get_portal_and_check_permission(evt: CommandEvent, permission: str, async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
action: Optional[str] = None action: Optional[str] = None
) -> Optional[po.Portal]: ) -> Optional[po.Portal]:
room_id = MatrixRoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
if not portal: if not portal:
@@ -41,7 +42,7 @@ async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str, def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str,
completed_message: str) -> Dict: completed_message: str) -> Dict:
async def post_confirm(confirm) -> Optional[Dict]: async def post_confirm(confirm) -> Optional[EventID]:
confirm.sender.command_status = None confirm.sender.command_status = None
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}": if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
await function() await function()
@@ -62,7 +63,7 @@ def _get_portal_murder_function(action: str, room_id: str, function: Callable, c
help_text="Remove all users from the current portal room and forget the portal. " help_text="Remove all users from the current portal room and forget the portal. "
"Only works for group chats; to delete a private chat portal, simply " "Only works for group chats; to delete a private chat portal, simply "
"leave the room.") "leave the room.")
async def delete_portal(evt: CommandEvent) -> Optional[Dict]: async def delete_portal(evt: CommandEvent) -> Optional[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge") portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal: if not portal:
return None return None
@@ -83,7 +84,7 @@ async def delete_portal(evt: CommandEvent) -> Optional[Dict]:
@command_handler(needs_auth=False, needs_puppeting=False, @command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT, help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove puppets from the current portal room and forget the portal.") help_text="Remove puppets from the current portal room and forget the portal.")
async def unbridge(evt: CommandEvent) -> Optional[Dict]: async def unbridge(evt: CommandEvent) -> Optional[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge") portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal: if not portal:
return None return None
+26 -21
View File
@@ -13,43 +13,48 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Tuple from typing import Tuple, Optional
from mautrix_appservice import MatrixRequestError, IntentAPI from mautrix.errors import MatrixRequestError
from mautrix.appservice import IntentAPI
from mautrix.types import RoomID, EventType, PowerLevelStateEventContent
from ... import user as u from ... import user as u
OptStr = Optional[str]
async def get_initial_state(intent: IntentAPI, room_id: str) -> Tuple[str, str, Dict]:
state = await intent.get_room_state(room_id) async def get_initial_state(intent: IntentAPI, room_id: RoomID
title = None ) -> Tuple[OptStr, OptStr, Optional[PowerLevelStateEventContent]]:
about = None state = await intent.get_state(room_id)
levels = None title: OptStr = None
about: OptStr = None
levels: Optional[PowerLevelStateEventContent] = None
for event in state: for event in state:
try: try:
if event["type"] == "m.room.name": if event.type == EventType.ROOM_NAME:
title = event["content"]["name"] title = event.content.name
elif event["type"] == "m.room.topic": elif event.type == EventType.ROOM_TOPIC:
about = event["content"]["topic"] about = event.content.topic
elif event["type"] == "m.room.power_levels": elif event.type == EventType.ROOM_POWER_LEVELS:
levels = event["content"] levels = event.content
elif event["type"] == "m.room.canonical_alias": elif event.type == EventType.ROOM_CANONICAL_ALIAS:
title = title or event["content"]["alias"] title = title or event.content.canonical_alias
except KeyError: except KeyError:
# Some state event probably has empty content # Some state event probably has empty content
pass pass
return title, about, levels return title, about, levels
async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50 async def user_has_power_level(room_id: RoomID, intent: IntentAPI, sender: u.User,
) -> bool: event: str) -> bool:
if sender.is_admin: if sender.is_admin:
return True return True
# Make sure the state store contains the power levels. # Make sure the state store contains the power levels.
try: try:
await intent.get_power_levels(room) await intent.get_power_levels(room_id)
except MatrixRequestError: except MatrixRequestError:
return False return False
return intent.state_store.has_power_level(room, sender.mxid, event_type = EventType.find(f"net.maunium.telegram.{event}")
event=f"net.maunium.telegram.{event}", event_type.t_class = EventType.Class.STATE
default=default) return intent.state_store.has_power_level(room_id, sender.mxid, event_type)
@@ -21,6 +21,8 @@ from telethon.tl.types import Authorization
from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest, from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest,
ResetAuthorizationRequest, UpdateProfileRequest) ResetAuthorizationRequest, UpdateProfileRequest)
from mautrix.types import EventID
from .. import command_handler, CommandEvent, SECTION_AUTH from .. import command_handler, CommandEvent, SECTION_AUTH
-2
View File
@@ -15,8 +15,6 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Any, Dict, List, NamedTuple from typing import Any, Dict, List, NamedTuple
from ruamel.yaml.comments import CommentedMap from ruamel.yaml.comments import CommentedMap
import random
import string
import os import os
from mautrix.types import UserID from mautrix.types import UserID
+2 -1
View File
@@ -13,7 +13,8 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from .base import Base from mautrix.bridge.db import UserProfile, RoomState
from .bot_chat import BotChat from .bot_chat import BotChat
from .message import Message from .message import Message
from .portal import Portal from .portal import Portal
-58
View File
@@ -1,58 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 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 abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class BaseBase:
db: Engine = None
t: Table = None
__table__: Table = None
c: ImmutableColumnCollection = None
@classmethod
@abstractmethod
def _one_or_none(cls, rows: RowProxy):
pass
@classmethod
def _select_one_or_none(cls, *args):
return cls._one_or_none(cls.db.execute(cls.t.select().where(*args)))
@property
@abstractmethod
def _edit_identity(self):
pass
def update(self, **values) -> None:
with self.db.begin() as conn:
conn.execute(self.t.update()
.where(self._edit_identity)
.values(**values))
for key, value in values.items():
setattr(self, key, value)
def delete(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.delete().where(self._edit_identity))
Base = declarative_base(cls=BaseBase)
-26
View File
@@ -1,26 +0,0 @@
from abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class Base(declarative_base):
db: Engine
t: Table
__table__: Table
c: ImmutableColumnCollection
@classmethod
@abstractmethod
def _one_or_none(cls, rows: RowProxy): ...
@classmethod
def _select_one_or_none(cls, *args): ...
def _edit_identity(self): ...
def update(self, **values) -> None: ...
def delete(self) -> None: ...
+11 -8
View File
@@ -16,28 +16,31 @@
from typing import Iterable from typing import Iterable
from sqlalchemy import Column, Integer, String from sqlalchemy import Column, Integer, String
from sqlalchemy.engine.result import RowProxy
from mautrix.bridge.db import Base
from ..types import TelegramID from ..types import TelegramID
from .base import Base
# Fucking Telegram not telling bots what chats they are in 3:< # Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base): class BotChat(Base):
__tablename__ = "bot_chat" __tablename__ = "bot_chat"
id = Column(Integer, primary_key=True) # type: TelegramID id: TelegramID = Column(Integer, primary_key=True)
type = Column(String, nullable=False) type: str = Column(String, nullable=False)
@classmethod @classmethod
def delete(cls, chat_id: TelegramID) -> None: def delete_by_id(cls, chat_id: TelegramID) -> None:
with cls.db.begin() as conn: with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.id == chat_id)) conn.execute(cls.t.delete().where(cls.c.id == chat_id))
@classmethod
def scan(cls, row: RowProxy) -> 'BotChat':
return cls(id=row[0], type=row[1])
@classmethod @classmethod
def all(cls) -> Iterable['BotChat']: def all(cls) -> Iterable['BotChat']:
rows = cls.db.execute(cls.t.select()) return cls._select_all()
for row in rows:
chat_id, chat_type = row
yield cls(id=chat_id, type=chat_type)
def insert(self) -> None: def insert(self) -> None:
with self.db.begin() as conn: with self.db.begin() as conn:
+19 -26
View File
@@ -13,42 +13,35 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Iterator
from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select
from sqlalchemy.engine.result import RowProxy from sqlalchemy.engine.result import RowProxy
from typing import Optional, List from sqlalchemy.sql.expression import ClauseElement
from ..types import MatrixRoomID, MatrixEventID, TelegramID from mautrix.types import RoomID, EventID
from .base import Base from mautrix.bridge.db import Base
from ..types import TelegramID
class Message(Base): class Message(Base):
__tablename__ = "message" __tablename__ = "message"
mxid = Column(String) # type: MatrixEventID mxid: EventID = Column(String)
mx_room = Column(String) # type: MatrixRoomID mx_room: RoomID = Column(String)
tgid = Column(Integer, primary_key=True) # type: TelegramID tgid: TelegramID = Column(Integer, primary_key=True)
tg_space = Column(Integer, primary_key=True) # type: TelegramID tg_space: TelegramID = Column(Integer, primary_key=True)
edit_index = Column(Integer, primary_key=True) # type: int edit_index: int = Column(Integer, primary_key=True)
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),) __table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
@classmethod @classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Message']: def scan(cls, row: RowProxy) -> 'Message':
try: return cls(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3], edit_index=row[4])
mxid, mx_room, tgid, tg_space, edit_index = next(rows)
return cls(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space,
edit_index=edit_index)
except StopIteration:
return None
@staticmethod
def _all(rows: RowProxy) -> List['Message']:
return [Message(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3],
edit_index=row[4])
for row in rows]
@classmethod @classmethod
def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> List['Message']: def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Iterator['Message']:
return cls._all(cls.db.execute(cls.t.select().where(and_(cls.c.tgid == tgid, return cls._all(cls.db.execute(cls.t.select().where(and_(cls.c.tgid == tgid,
cls.c.tg_space == tg_space)))) cls.c.tg_space == tg_space))))
@@ -68,7 +61,7 @@ class Message(Base):
return cls._one_or_none(cls.db.execute(query)) return cls._one_or_none(cls.db.execute(query))
@classmethod @classmethod
def count_spaces_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID) -> int: def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
rows = cls.db.execute(select([func.count(cls.c.tg_space)]) rows = cls.db.execute(select([func.count(cls.c.tg_space)])
.where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room))) .where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)))
try: try:
@@ -78,7 +71,7 @@ class Message(Base):
return 0 return 0
@classmethod @classmethod
def get_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID, tg_space: TelegramID def get_by_mxid(cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
) -> Optional['Message']: ) -> Optional['Message']:
return cls._select_one_or_none(and_(cls.c.mxid == mxid, return cls._select_one_or_none(and_(cls.c.mxid == mxid,
cls.c.mx_room == mx_room, cls.c.mx_room == mx_room,
@@ -94,14 +87,14 @@ class Message(Base):
.values(**values)) .values(**values))
@classmethod @classmethod
def update_by_mxid(cls, s_mxid: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None: def update_by_mxid(cls, s_mxid: EventID, s_mx_room: RoomID, **values) -> None:
with cls.db.begin() as conn: with cls.db.begin() as conn:
conn.execute(cls.t.update() conn.execute(cls.t.update()
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room)) .where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
.values(**values)) .values(**values))
@property @property
def _edit_identity(self): def _edit_identity(self) -> ClauseElement:
return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space, return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space,
self.c.edit_index == self.edit_index) self.c.edit_index == self.edit_index)
+21 -24
View File
@@ -13,55 +13,52 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean, Text, and_
from sqlalchemy.engine.result import RowProxy
from typing import Optional from typing import Optional
from ..types import MatrixRoomID, TelegramID from sqlalchemy import Column, Integer, String, Boolean, Text, and_
from .base import Base from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.expression import ClauseElement
from mautrix.types import RoomID
from mautrix.bridge.db import Base
from ..types import TelegramID
class Portal(Base): class Portal(Base):
__tablename__ = "portal" __tablename__ = "portal"
# Telegram chat information # Telegram chat information
tgid = Column(Integer, primary_key=True) # type: TelegramID tgid: TelegramID = Column(Integer, primary_key=True)
tg_receiver = Column(Integer, primary_key=True) # type: TelegramID tg_receiver: TelegramID = Column(Integer, primary_key=True)
peer_type = Column(String, nullable=False) peer_type: str = Column(String, nullable=False)
megagroup = Column(Boolean) megagroup: bool = Column(Boolean)
# Matrix portal information # Matrix portal information
mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID] mxid: RoomID = Column(String, unique=True, nullable=True)
config = Column(Text, nullable=True) config: str = Column(Text, nullable=True)
# Telegram chat metadata # Telegram chat metadata
username = Column(String, nullable=True) username: str = Column(String, nullable=True)
title = Column(String, nullable=True) title: str = Column(String, nullable=True)
about = Column(String, nullable=True) about: str = Column(String, nullable=True)
photo_id = Column(String, nullable=True) photo_id: str = Column(String, nullable=True)
@classmethod @classmethod
def scan(cls, row) -> Optional['Portal']: def scan(cls, row: RowProxy) -> Optional['Portal']:
(tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about, (tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about,
photo_id) = row photo_id) = row
return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup, return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup,
mxid=mxid, config=config, username=username, title=title, about=about, mxid=mxid, config=config, username=username, title=title, about=about,
photo_id=photo_id) photo_id=photo_id)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Portal']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
@classmethod @classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']: def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver)) return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver))
@classmethod @classmethod
def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']: def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.mxid == mxid) return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod @classmethod
@@ -69,7 +66,7 @@ class Portal(Base):
return cls._select_one_or_none(cls.c.username == username) return cls._select_one_or_none(cls.c.username == username)
@property @property
def _edit_identity(self): def _edit_identity(self) -> ClauseElement:
return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver) return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver)
def insert(self) -> None: def insert(self) -> None:
+22 -25
View File
@@ -13,31 +13,35 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql import expression
from typing import Optional, Iterable from typing import Optional, Iterable
from ..types import MatrixUserID, MatrixRoomID, TelegramID from sqlalchemy import Column, Integer, String, Boolean
from .base import Base from sqlalchemy.sql import expression
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.expression import ClauseElement
from mautrix.types import UserID
from mautrix.bridge.db import Base
from ..types import TelegramID
class Puppet(Base): class Puppet(Base):
__tablename__ = "puppet" __tablename__ = "puppet"
id = Column(Integer, primary_key=True) # type: TelegramID id: TelegramID = Column(Integer, primary_key=True)
custom_mxid = Column(String, nullable=True) # type: Optional[MatrixUserID] custom_mxid: UserID = Column(String, nullable=True)
access_token = Column(String, nullable=True) access_token: str = Column(String, nullable=True)
displayname = Column(String, nullable=True) displayname: str = Column(String, nullable=True)
displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID] displayname_source: TelegramID = Column(Integer, nullable=True)
username = Column(String, nullable=True) username: str = Column(String, nullable=True)
photo_id = Column(String, nullable=True) photo_id: str = Column(String, nullable=True)
is_bot = Column(Boolean, nullable=True) is_bot: bool = Column(Boolean, nullable=True)
matrix_registered = Column(Boolean, nullable=False, server_default=expression.false()) matrix_registered: bool = Column(Boolean, nullable=False, server_default=expression.false())
disable_updates = Column(Boolean, nullable=False, server_default=expression.false()) disable_updates: bool = Column(Boolean, nullable=False, server_default=expression.false())
@classmethod @classmethod
def scan(cls, row) -> Optional['Puppet']: def scan(cls, row: RowProxy) -> Optional['Puppet']:
(id, custom_mxid, access_token, displayname, displayname_source, username, photo_id, (id, custom_mxid, access_token, displayname, displayname_source, username, photo_id,
is_bot, matrix_registered, disable_updates) = row is_bot, matrix_registered, disable_updates) = row
return cls(id=id, custom_mxid=custom_mxid, access_token=access_token, return cls(id=id, custom_mxid=custom_mxid, access_token=access_token,
@@ -45,13 +49,6 @@ class Puppet(Base):
username=username, photo_id=photo_id, is_bot=is_bot, username=username, photo_id=photo_id, is_bot=is_bot,
matrix_registered=matrix_registered, disable_updates=disable_updates) matrix_registered=matrix_registered, disable_updates=disable_updates)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
@classmethod @classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']: def all_with_custom_mxid(cls) -> Iterable['Puppet']:
rows = cls.db.execute(cls.t.select().where(cls.c.custom_mxid != None)) rows = cls.db.execute(cls.t.select().where(cls.c.custom_mxid != None))
@@ -63,7 +60,7 @@ class Puppet(Base):
return cls._select_one_or_none(cls.c.id == tgid) return cls._select_one_or_none(cls.c.id == tgid)
@classmethod @classmethod
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']: def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.custom_mxid == mxid) return cls._select_one_or_none(cls.c.custom_mxid == mxid)
@classmethod @classmethod
@@ -75,7 +72,7 @@ class Puppet(Base):
return cls._select_one_or_none(cls.c.displayname == displayname) return cls._select_one_or_none(cls.c.displayname == displayname)
@property @property
def _edit_identity(self): def _edit_identity(self) -> ClauseElement:
return self.c.id == self.id return self.c.id == self.id
def insert(self) -> None: def insert(self) -> None:
+22 -22
View File
@@ -13,40 +13,40 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
from typing import Optional from typing import Optional
from mautrix.types import ContentURI from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
from sqlalchemy.engine.result import RowProxy
from .base import Base from mautrix.types import ContentURI
from mautrix.bridge.db import Base
class TelegramFile(Base): class TelegramFile(Base):
__tablename__ = "telegram_file" __tablename__ = "telegram_file"
id = Column(String, primary_key=True) id: str = Column(String, primary_key=True)
mxc: ContentURI = Column(String) mxc: ContentURI = Column(String)
mime_type = Column(String) mime_type: str = Column(String)
was_converted = Column(Boolean) was_converted: bool = Column(Boolean)
timestamp = Column(BigInteger) timestamp: int = Column(BigInteger)
size = Column(Integer, nullable=True) size: int = Column(Integer, nullable=True)
width = Column(Integer, nullable=True) width: int = Column(Integer, nullable=True)
height = Column(Integer, nullable=True) height: int = Column(Integer, nullable=True)
thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True) thumbnail_id: str = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
thumbnail = None # type: Optional[TelegramFile] thumbnail: Optional['TelegramFile'] = None
def scan(cls, row: RowProxy) -> 'TelegramFile':
loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = row
thumb = None
if thumb_id:
thumb = cls.get(thumb_id)
return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
@classmethod @classmethod
def get(cls, loc_id: str) -> Optional['TelegramFile']: def get(cls, loc_id: str) -> Optional['TelegramFile']:
rows = cls.db.execute(cls.t.select().where(cls.c.id == loc_id)) return cls._select_one_or_none(cls.c.id == loc_id)
try:
loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = next(rows)
thumb = None
if thumb_id:
thumb = cls.get(thumb_id)
return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
except StopIteration:
return None
def insert(self) -> None: def insert(self) -> None:
with self.db.begin() as conn: with self.db.begin() as conn:
+26 -29
View File
@@ -13,46 +13,43 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
from sqlalchemy.engine.result import RowProxy
from typing import Optional, Iterable, Tuple from typing import Optional, Iterable, Tuple
from ..types import MatrixUserID, TelegramID from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
from .base import Base from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.expression import ClauseElement
from mautrix.types import UserID
from mautrix.bridge.db import Base
from ..types import TelegramID
class User(Base): class User(Base):
__tablename__ = "user" __tablename__ = "user"
mxid = Column(String, primary_key=True) # type: MatrixUserID mxid: UserID = Column(String, primary_key=True)
tgid = Column(Integer, nullable=True, unique=True) # type: Optional[TelegramID] tgid: Optional[TelegramID] = Column(Integer, nullable=True, unique=True)
tg_username = Column(String, nullable=True) tg_username: str = Column(String, nullable=True)
tg_phone = Column(String, nullable=True) tg_phone: str = Column(String, nullable=True)
saved_contacts = Column(Integer, default=0, nullable=False) saved_contacts: int = Column(Integer, default=0, nullable=False)
@classmethod @classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['User']: def scan(cls, row: RowProxy) -> 'User':
try: mxid, tgid, tg_username, tg_phone, saved_contacts = row
mxid, tgid, tg_username, tg_phone, saved_contacts = next(rows) return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone, saved_contacts=saved_contacts)
saved_contacts=saved_contacts)
except StopIteration:
return None
@classmethod @classmethod
def all(cls) -> Iterable['User']: def all(cls) -> Iterable['User']:
rows = cls.db.execute(cls.t.select()) return cls._select_all()
for row in rows:
mxid, tgid, tg_username, tg_phone, saved_contacts = row
yield cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
saved_contacts=saved_contacts)
@classmethod @classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']: def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
return cls._select_one_or_none(cls.c.tgid == tgid) return cls._select_one_or_none(cls.c.tgid == tgid)
@classmethod @classmethod
def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']: def get_by_mxid(cls, mxid: UserID) -> Optional['User']:
return cls._select_one_or_none(cls.c.mxid == mxid) return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod @classmethod
@@ -60,7 +57,7 @@ class User(Base):
return cls._select_one_or_none(cls.c.tg_username == username) return cls._select_one_or_none(cls.c.tg_username == username)
@property @property
def _edit_identity(self): def _edit_identity(self) -> ClauseElement:
return self.c.mxid == self.mxid return self.c.mxid == self.mxid
def insert(self) -> None: def insert(self) -> None:
@@ -112,10 +109,10 @@ class User(Base):
class UserPortal(Base): class UserPortal(Base):
__tablename__ = "user_portal" __tablename__ = "user_portal"
user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"), user: TelegramID = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE",
primary_key=True) # type: TelegramID ondelete="CASCADE"), primary_key=True)
portal = Column(Integer, primary_key=True) # type: TelegramID portal: TelegramID = Column(Integer, primary_key=True)
portal_receiver = Column(Integer, primary_key=True) # type: TelegramID portal_receiver: TelegramID = Column(Integer, primary_key=True)
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"), __table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"), ("portal.tgid", "portal.tg_receiver"),
@@ -125,5 +122,5 @@ class UserPortal(Base):
class Contact(Base): class Contact(Base):
__tablename__ = "contact" __tablename__ = "contact"
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID user: TelegramID = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID contact: TelegramID = Column(Integer, ForeignKey("puppet.id"), primary_key=True)
+54 -82
View File
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING from typing import List, Optional, TYPE_CHECKING
from html import escape from html import escape
import logging import logging
import re import re
@@ -26,14 +26,15 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader, MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader,
MessageEntityUnderline, PeerUser) MessageEntityUnderline, PeerUser)
from mautrix_appservice import MatrixRequestError from mautrix.errors import MatrixRequestError
from mautrix_appservice.intent_api import IntentAPI from mautrix.appservice import IntentAPI
from mautrix.types import (TextMessageEventContent, RelatesTo, RelationType, Format, MessageType,
MessageEvent)
from .. import user as u, puppet as pu, portal as po from .. import user as u, puppet as pu, portal as po
from ..types import TelegramID from ..types import TelegramID
from ..db import Message as DBMessage from ..db import Message as DBMessage
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html, from .util import (add_surrogates, remove_surrogates)
trim_reply_fallback_text)
if TYPE_CHECKING: if TYPE_CHECKING:
from ..abstract_user import AbstractUser from ..abstract_user import AbstractUser
@@ -41,29 +42,22 @@ if TYPE_CHECKING:
log: logging.Logger = logging.getLogger("mau.fmt.tg") log: logging.Logger = logging.getLogger("mau.fmt.tg")
def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Dict: def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Optional[RelatesTo]:
if evt.reply_to_msg_id: if evt.reply_to_msg_id:
space = (evt.to_id.channel_id space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel) if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid) else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space) msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
if msg: if msg:
return { return RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
"m.in_reply_to": { return None
"event_id": msg.mxid,
"room_id": msg.mx_room,
},
"rel_type": "m.reference",
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
return {}
async def _add_forward_header(source, text: str, html: Optional[str], async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventContent,
fwd_from: MessageFwdHeader) -> Tuple[str, str]: fwd_from: MessageFwdHeader) -> None:
if not html: if not content.formatted_body or content.format != Format.HTML:
html = escape(text) content.format = Format.HTML
content.formatted_body = escape(content.body)
fwd_from_html, fwd_from_text = None, None fwd_from_html, fwd_from_text = None, None
if fwd_from.from_id: if fwd_from.from_id:
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id)) user = u.User.get_by_tgid(TelegramID(fwd_from.from_id))
@@ -106,64 +100,32 @@ async def _add_forward_header(source, text: str, html: Optional[str],
fwd_from_text = "Unknown source" fwd_from_text = "Unknown source"
fwd_from_html = f"<b>{fwd_from_text}</b>" fwd_from_html = f"<b>{fwd_from_text}</b>"
text = "\n".join([f"> {line}" for line in text.split("\n")]) content.body = "\n".join([f"> {line}" for line in content.body.split("\n")])
text = f"Forwarded from {fwd_from_text}:\n{text}" content.body = f"Forwarded from {fwd_from_text}:\n{content.body}"
html = (f"Forwarded message from {fwd_from_html}<br/>" content.formatted_body = (
f"<tg-forward><blockquote>{html}</blockquote></tg-forward>") f"Forwarded message from {fwd_from_html}<br/>"
return text, html f"<tg-forward><blockquote>{content.formatted_body}</blockquote></tg-forward>")
async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message, async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventContent, evt: Message,
relates_to: Dict, main_intent: IntentAPI) -> Tuple[str, str]: main_intent: IntentAPI):
space = (evt.to_id.channel_id space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel) if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid) else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space) msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
if not msg: if not msg:
return text, html return
relates_to["rel_type"] = "m.reference" content.relates_to = RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
relates_to["event_id"] = msg.mxid
relates_to["room_id"] = msg.mx_room
relates_to["m.in_reply_to"] = {
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
try: try:
event = await main_intent.get_event(msg.mx_room, msg.mxid) event: MessageEvent = await main_intent.get_event(msg.mx_room, msg.mxid)
if isinstance(event.content, TextMessageEventContent):
content = event["content"] event.content.trim_reply_fallback()
r_sender = event["sender"] content.set_reply(event)
except MatrixRequestError:
r_text_body = trim_reply_fallback_text(content["body"]) pass
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}'>{escape(r_displayname)}</a>"
except (ValueError, KeyError, MatrixRequestError):
r_sender_link = "unknown user"
r_displayname = "unknown user"
r_text_body = "Failed to fetch message"
r_html_body = "<em>Failed to fetch message</em>"
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>In reply to</a>"
html = (
f"<mx-reply><blockquote>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote></mx-reply>"
+ (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: Message, source: "AbstractUser", async def telegram_to_matrix(evt: Message, source: "AbstractUser",
@@ -171,33 +133,43 @@ async def telegram_to_matrix(evt: Message, source: "AbstractUser",
prefix_text: Optional[str] = None, prefix_html: Optional[str] = None, prefix_text: Optional[str] = None, prefix_html: Optional[str] = None,
override_text: str = None, override_text: str = None,
override_entities: List[TypeMessageEntity] = None, override_entities: List[TypeMessageEntity] = None,
no_reply_fallback: bool = False) -> Tuple[str, str, Dict]: no_reply_fallback: bool = False) -> TextMessageEventContent:
text = add_surrogates(override_text or evt.message) content = TextMessageEventContent(
msgtype=MessageType.TEXT,
body=add_surrogates(override_text or evt.message),
)
entities = override_entities or evt.entities entities = override_entities or evt.entities
html = _telegram_entities_to_matrix_catch(text, entities) if entities else None if entities:
relates_to = {} # type: Dict content.format = Format.HTML
content.formatted_body = _telegram_entities_to_matrix_catch(content.body, entities)
if prefix_html: if prefix_html:
html = prefix_html + (html or escape(text)) if not content.formatted_body:
content.format = Format.HTML
content.formatted_body = escape(content.body)
content.formatted_body = prefix_html + content.formatted_body
if prefix_text: if prefix_text:
text = prefix_text + text content.body = prefix_text + content.body
if evt.fwd_from: if evt.fwd_from:
text, html = await _add_forward_header(source, text, html, evt.fwd_from) await _add_forward_header(source, content, evt.fwd_from)
if evt.reply_to_msg_id and not no_reply_fallback: if evt.reply_to_msg_id and not no_reply_fallback:
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent) await _add_reply_header(source, content, evt, main_intent)
if isinstance(evt, Message) and evt.post and evt.post_author: if isinstance(evt, Message) and evt.post and evt.post_author:
if not html: if not content.formatted_body:
html = escape(text) content.formatted_body = escape(content.body)
text += f"\n- {evt.post_author}" content.body += f"\n- {evt.post_author}"
html += f"<br/><i>- <u>{evt.post_author}</u></i>" content.formatted_body += f"<br/><i>- <u>{evt.post_author}</u></i>"
if html: if content.formatted_body:
html = html.replace("\n", "<br/>") content.formatted_body = content.formatted_body.replace("\n", "<br/>")
return remove_surrogates(text), remove_surrogates(html), relates_to content.body = remove_surrogates(content.body)
content.formatted_body = remove_surrogates(content.formatted_body)
return content
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str: def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
-21
View File
@@ -32,24 +32,3 @@ def remove_surrogates(text: Optional[str]) -> Optional[str]:
if text is None: if text is None:
return None return None
return text.encode("utf-16", "surrogatepass").decode("utf-16") return text.encode("utf-16", "surrogatepass").decode("utf-16")
# trim_reply_fallback_text, html_reply_fallback_regex and trim_reply_fallback_html are Matrix
# reply fallback utility functions.
# You may copy and use them under any OSI-approved license.
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: Pattern = re.compile("^<mx-reply>"
r"[\s\S]+?"
"</mx-reply>")
def trim_reply_fallback_html(html: str) -> str:
return html_reply_fallback_regex.sub("", html)
+117 -135
View File
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import (Awaitable, Dict, List, Optional, Pattern, Tuple, Union, Any, Deque, cast, from typing import (Awaitable, Dict, List, Optional, Pattern, Tuple, Union, Any, Deque,
TYPE_CHECKING) TYPE_CHECKING)
from html import escape as escape_html from html import escape as escape_html
from collections import deque from collections import deque
@@ -64,14 +64,17 @@ from telethon.tl.types import (
UpdateNewChannelMessage, UpdateNewMessage, UpdateUserTyping, User, UserFull, MessageEntityPre, UpdateNewChannelMessage, UpdateNewMessage, UpdateUserTyping, User, UserFull, MessageEntityPre,
InputMediaUploadedDocument, InputPeerPhotoFileLocation) InputMediaUploadedDocument, InputPeerPhotoFileLocation)
from mautrix.errors import MatrixRequestError, IntentError from mautrix.errors import MatrixRequestError, IntentError, MForbidden
from mautrix.appservice import AppService, IntentAPI from mautrix.appservice import AppService, IntentAPI
from mautrix.types import EventID, RoomID, UserID, RoomCreatePreset, ContentURI, MessageType from mautrix.bridge import BasePortal
from mautrix.types import (EventID, RoomID, UserID, RoomCreatePreset, ContentURI, MessageType,
ImageInfo, ThumbnailInfo, EventType, PowerLevelStateEventContent,
RoomAlias, TextMessageEventContent, Format)
from .types import TelegramID from .types import TelegramID
from .context import Context from .context import Context
from .db import Portal as DBPortal, Message as DBMessage, TelegramFile as DBTelegramFile from .db import Portal as DBPortal, Message as DBMessage, TelegramFile as DBTelegramFile
from .util import ignore_coro, sane_mimetypes from .util import sane_mimetypes
from . import puppet as p, user as u, formatter, util from . import puppet as p, user as u, formatter, util
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -88,7 +91,7 @@ DedupMXID = Tuple[EventID, TelegramID]
InviteList = Union[UserID, List[UserID]] InviteList = Union[UserID, List[UserID]]
class Portal: class Portal(BasePortal):
base_log: logging.Logger = logging.getLogger("mau.portal") base_log: logging.Logger = logging.getLogger("mau.portal")
az: AppService = None az: AppService = None
bot: 'Bot' = None bot: 'Bot' = None
@@ -225,7 +228,7 @@ class Portal:
# endregion # endregion
# region Permission checks # region Permission checks
async def can_user_perform(self, user: 'u.User', event: str, default: int = 50) -> bool: async def can_user_perform(self, user: 'u.User', event: str) -> bool:
if user.is_admin: if user.is_admin:
return True return True
if not self.mxid: if not self.mxid:
@@ -235,10 +238,9 @@ class Portal:
await self.main_intent.get_power_levels(self.mxid) await self.main_intent.get_power_levels(self.mxid)
except MatrixRequestError: except MatrixRequestError:
return False return False
return self.main_intent.state_store.has_power_level( evt_type = EventType.find(f"net.maunium.telegram.{event}")
self.mxid, user.mxid, evt_type.t_class = EventType.Class.STATE
event=f"net.maunium.telegram.{event}", return self.main_intent.state_store.has_power_level(self.mxid, user.mxid, event=evt_type)
default=default)
# endregion # endregion
# region Deduplication # region Deduplication
@@ -340,7 +342,8 @@ class Portal:
await self.main_intent.invite_user(self.mxid, users, check_cache=True) await self.main_intent.invite_user(self.mxid, users, check_cache=True)
async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User], async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
direct: bool, puppet: p.Puppet = None, levels: Dict = None, direct: bool, puppet: p.Puppet = None,
levels: PowerLevelStateEventContent = None,
users: List[User] = None, users: List[User] = None,
participants: List[TypeParticipant] = None) -> None: participants: List[TypeParticipant] = None) -> None:
if not direct: if not direct:
@@ -368,7 +371,7 @@ class Portal:
if synchronous: if synchronous:
await update await update
else: else:
ignore_coro(asyncio.ensure_future(update, loop=self.loop)) asyncio.ensure_future(update, loop=self.loop)
await self.invite_to_matrix(invites or []) await self.invite_to_matrix(invites or [])
return self.mxid return self.mxid
async with self._room_create_lock: async with self._room_create_lock:
@@ -388,7 +391,7 @@ class Portal:
entity = await self.get_entity(user) entity = await self.get_entity(user)
self.log.debug("Fetched data: %s", entity) self.log.debug("Fetched data: %s", entity)
self.log.debug(f"Creating room") self.log.debug("Creating room")
try: try:
self.title = entity.title self.title = entity.title
@@ -414,14 +417,14 @@ class Portal:
# TODO? properly handle existing room aliases # TODO? properly handle existing room aliases
await self.main_intent.remove_room_alias(alias) await self.main_intent.remove_room_alias(alias)
power_levels = self._get_base_power_levels({}, entity) power_levels = self._get_base_power_levels(entity=entity)
users = participants = None users = participants = None
if not direct: if not direct:
users, participants = await self._get_users(user, entity) users, participants = await self._get_users(user, entity)
self._participants_to_power_levels(participants, power_levels) self._participants_to_power_levels(participants, power_levels)
initial_state = [{ initial_state = [{
"type": "m.room.power_levels", "type": EventType.ROOM_POWER_LEVELS.serialize(),
"content": power_levels, "content": power_levels.serialize(),
}] }]
if config["appservice.community_id"]: if config["appservice.community_id"]:
initial_state.append({ initial_state.append({
@@ -440,62 +443,56 @@ class Portal:
self.save() self.save()
self.az.state_store.set_power_levels(self.mxid, power_levels) self.az.state_store.set_power_levels(self.mxid, power_levels)
user.register_portal(self) user.register_portal(self)
ignore_coro(asyncio.ensure_future(self.update_matrix_room(user, entity, direct, puppet, asyncio.ensure_future(self.update_matrix_room(user, entity, direct, puppet,
levels=power_levels, users=users, levels=power_levels, users=users,
participants=participants), participants=participants), loop=self.loop)
loop=self.loop))
return self.mxid return self.mxid
def _get_base_power_levels(self, levels: dict = None, entity: TypeChat = None) -> dict: def _get_base_power_levels(self, levels: PowerLevelStateEventContent = None,
levels = levels or {} entity: TypeChat = None) -> PowerLevelStateEventContent:
levels = levels or PowerLevelStateEventContent()
if self.peer_type == "user": if self.peer_type == "user":
levels["ban"] = 100 levels.ban = 100
levels["kick"] = 100 levels.kick = 100
levels["invite"] = 100 levels.invite = 100
levels.setdefault("events", {}) levels.events[EventType.ROOM_NAME] = 0
levels["events"]["m.room.name"] = 0 levels.events[EventType.ROOM_AVATAR] = 0
levels["events"]["m.room.avatar"] = 0 levels.events[EventType.ROOM_TOPIC] = 0
levels["events"]["m.room.topic"] = 0 levels.state_default = 0
levels["state_default"] = 0 levels.users_default = 0
levels["users_default"] = 0 levels.events_default = 0
levels["events_default"] = 0
else: else:
dbr = entity.default_banned_rights dbr = entity.default_banned_rights
if not dbr: if not dbr:
self.log.debug(f"default_banned_rights is None in {entity}") self.log.debug(f"default_banned_rights is None in {entity}")
dbr = ChatBannedRights(invite_users=True, change_info=True, pin_messages=True, dbr = ChatBannedRights(invite_users=True, change_info=True, pin_messages=True,
send_stickers=False, send_messages=False, until_date=0) send_stickers=False, send_messages=False, until_date=0)
levels["ban"] = 99 levels.ban = 99
levels["kick"] = 50 levels.kick = 50
levels["invite"] = 50 if dbr.invite_users else 0 levels.invite = 50 if dbr.invite_users else 0
levels.setdefault("events", {}) levels.events[EventType.ROOM_ENCRYPTED] = 99
levels["events"]["m.room.name"] = 50 if dbr.change_info else 0 levels.events[EventType.ROOM_TOMBSTONE] = 99
levels["events"]["m.room.avatar"] = 50 if dbr.change_info else 0 levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0
levels["events"]["m.room.topic"] = 50 if dbr.change_info else 0 levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0
levels["events"][ levels.events[EventType.ROOM_TOPIC] = 50 if dbr.change_info else 0
"m.room.pinned_events"] = 50 if dbr.pin_messages else 0 levels.events[EventType.ROOM_PINNED_EVENTS] = 50 if dbr.pin_messages else 0
levels["events"]["m.room.power_levels"] = 75 levels.events[EventType.ROOM_POWER_LEVELS] = 75
levels["events"]["m.room.history_visibility"] = 75 levels.events[EventType.ROOM_HISTORY_VISIBILITY] = 75
levels["state_default"] = 50 levels.state_default = 50
levels["users_default"] = 0 levels.users_default = 0
levels["events_default"] = (50 if (self.peer_type == "channel" and not entity.megagroup levels.events_default = (50 if (self.peer_type == "channel" and not entity.megagroup
or entity.default_banned_rights.send_messages) or entity.default_banned_rights.send_messages)
else 0) else 0)
levels["events"]["m.sticker"] = 50 if dbr.send_stickers else levels["events_default"] levels.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default
if "users" not in levels: levels.users[self.main_intent.mxid] = 100
levels["users"] = {
self.main_intent.mxid: 100
}
else:
levels["users"][self.main_intent.mxid] = 100
return levels return levels
@property @property
def alias(self) -> Optional[str]: def alias(self) -> Optional[RoomAlias]:
if not self.username: if not self.username:
return None return None
return f"#{self._get_alias_localpart()}:{self.hs_domain}" return RoomAlias(f"#{self._get_alias_localpart()}:{self.hs_domain}")
def _get_alias_localpart(self, username: Optional[str] = None) -> Optional[str]: def _get_alias_localpart(self, username: Optional[str] = None) -> Optional[str]:
username = username or self.username username = username or self.username
@@ -537,8 +534,7 @@ class Portal:
and Portal.max_initial_member_sync == -1 and Portal.max_initial_member_sync == -1
and (self.megagroup or self.peer_type != "channel")) and (self.megagroup or self.peer_type != "channel"))
if trust_member_list: if trust_member_list:
joined_mxids = cast(List[UserID], joined_mxids = await self.main_intent.get_room_members(self.mxid)
await self.main_intent.get_room_members(self.mxid))
for user_mxid in joined_mxids: for user_mxid in joined_mxids:
if user_mxid == self.az.bot_mxid: if user_mxid == self.az.bot_mxid:
continue continue
@@ -547,7 +543,7 @@ class Portal:
if self.bot and puppet_id == self.bot.tgid: if self.bot and puppet_id == self.bot.tgid:
self.bot.remove_chat(self.tgid) self.bot.remove_chat(self.tgid)
await self.main_intent.kick_user(self.mxid, user_mxid, await self.main_intent.kick_user(self.mxid, user_mxid,
"User had left this Telegram chat.") "User had left this Telegram chat.")
continue continue
mx_user = u.User.get_by_mxid(user_mxid, create=False) mx_user = u.User.get_by_mxid(user_mxid, create=False)
if mx_user and mx_user.is_bot and mx_user.tgid not in allowed_tgids: if mx_user and mx_user.is_bot and mx_user.tgid not in allowed_tgids:
@@ -555,14 +551,14 @@ class Portal:
if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids: if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids:
await self.main_intent.kick_user(self.mxid, mx_user.mxid, await self.main_intent.kick_user(self.mxid, mx_user.mxid,
"You had left this Telegram chat.") "You had left this Telegram chat.")
continue continue
async def add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None async def add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None
) -> None: ) -> None:
puppet = p.Puppet.get(user_id) puppet = p.Puppet.get(user_id)
if source: if source:
entity = await source.client.get_entity(PeerUser(user_id)) # type: User entity: User = await source.client.get_entity(PeerUser(user_id))
await puppet.update_info(source, entity) await puppet.update_info(source, entity)
await puppet.intent.join_room(self.mxid) await puppet.intent.join_room(self.mxid)
@@ -577,12 +573,21 @@ class Portal:
kick_message = (f"Kicked by {sender.displayname}" kick_message = (f"Kicked by {sender.displayname}"
if sender and sender.tgid != puppet.tgid if sender and sender.tgid != puppet.tgid
else "Left Telegram chat") else "Left Telegram chat")
if sender and sender.tgid != puppet.tgid: if sender.tgid != puppet.tgid:
await self.main_intent.kick_user(self.mxid, puppet.mxid, kick_message) try:
await sender.intent.kick_user(self.mxid, puppet.mxid)
except MForbidden:
await self.main_intent.kick_user(self.mxid, puppet.mxid, kick_message)
else: else:
await puppet.intent.leave_room(self.mxid) await puppet.intent.leave_room(self.mxid)
if user: if user:
user.unregister_portal(self) user.unregister_portal(self)
if sender.tgid != puppet.tgid:
try:
await sender.intent.kick_user(self.mxid, puppet.mxid)
return
except MForbidden:
pass
await self.main_intent.kick_user(self.mxid, user.mxid, kick_message) await self.main_intent.kick_user(self.mxid, user.mxid, kick_message)
async def update_info(self, user: 'AbstractUser', entity: TypeChat = None) -> None: async def update_info(self, user: 'AbstractUser', entity: TypeChat = None) -> None:
@@ -706,7 +711,7 @@ class Portal:
return False return False
async def _get_users(self, user: 'AbstractUser', async def _get_users(self, user: 'AbstractUser',
entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser] entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser, InputChannel]
) -> Tuple[List[TypeUser], List[TypeParticipant]]: ) -> Tuple[List[TypeUser], List[TypeParticipant]]:
if self.peer_type == "chat": if self.peer_type == "chat":
chat = await user.client(GetFullChatRequest(chat_id=self.tgid)) chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
@@ -764,13 +769,13 @@ class Portal:
members = await self.main_intent.get_room_members(self.mxid) members = await self.main_intent.get_room_members(self.mxid)
except MatrixRequestError: except MatrixRequestError:
return [] return []
authenticated = [] # type: List[u.User] authenticated: List[u.User] = []
has_bot = self.has_bot has_bot = self.has_bot
for member_str in members: for member_str in members:
member = UserID(member_str) member = UserID(member_str)
if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid: if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
continue continue
user = await u.User.get_by_mxid(member).ensure_started() # type: u.User user = await u.User.get_by_mxid(member).ensure_started()
authenticated_through_bot = has_bot and user.relaybot_whitelisted authenticated_through_bot = has_bot and user.relaybot_whitelisted
if authenticated_through_bot or await user.has_full_access(allow_bot=True): if authenticated_through_bot or await user.has_full_access(allow_bot=True):
authenticated.append(user) authenticated.append(user)
@@ -825,30 +830,28 @@ class Portal:
return local return local
return config[f"bridge.{key}"] return config[f"bridge.{key}"]
async def _get_state_change_message(self, event: str, user: 'u.User', async def _get_state_change_message(self, event: str, user: 'u.User', **kwargs: Any
arguments: Optional[Dict] = None) -> Optional[Dict]: ) -> Optional[str]:
tpl = self.get_config(f"state_event_formats.{event}") tpl = self.get_config(f"state_event_formats.{event}")
if len(tpl) == 0: if len(tpl) == 0:
# Empty format means they don't want the message # Empty format means they don't want the message
return None return None
displayname = await self.get_displayname(user) displayname = await self.get_displayname(user)
tpl_args = dict(mxid=user.mxid, tpl_args = {
username=user.mxid_localpart, "mxid": user.mxid,
displayname=escape_html(displayname)) "username": user.mxid_localpart,
tpl_args = {**tpl_args, **(arguments or {})} "displayname": escape_html(displayname),
message = Template(tpl).safe_substitute(tpl_args) **kwargs,
return {
"format": "org.matrix.custom.html",
"formatted_body": message,
} }
return Template(tpl).safe_substitute(tpl_args)
async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str, async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str,
event_id: EventID) -> None: event_id: EventID) -> None:
async with self.require_send_lock(self.bot.tgid): async with self.require_send_lock(self.bot.tgid):
message = await self._get_state_change_message( message = await self._get_state_change_message(
"name_change", user, "name_change", user,
dict(displayname=displayname, prev_displayname=prev_displayname)) displayname=displayname, prev_displayname=prev_displayname)
if not message: if not message:
return return
response = await self.bot.client.send_message( response = await self.bot.client.send_message(
@@ -858,7 +861,7 @@ class Portal:
self.is_duplicate(response, (event_id, space)) self.is_duplicate(response, (event_id, space))
async def get_displayname(self, user: 'u.User') -> str: async def get_displayname(self, user: 'u.User') -> str:
# FIXME mautrix4 # FIXME this doesn't seem to use cache in mautrix 0.4
return (await self.main_intent.get_displayname(self.mxid, user.mxid) return (await self.main_intent.get_displayname(self.mxid, user.mxid)
or user.mxid) or user.mxid)
@@ -994,13 +997,15 @@ class Portal:
await self._apply_msg_format(sender, msgtype, message["m.new_content"]) await self._apply_msg_format(sender, msgtype, message["m.new_content"])
@staticmethod @staticmethod
def _matrix_event_to_entities(event: Dict[str, Any] def _matrix_event_to_entities(event: Union[str, TextMessageEventContent]
) -> Tuple[str, Optional[List[TypeMessageEntity]]]: ) -> Tuple[str, Optional[List[TypeMessageEntity]]]:
try: try:
if event.get("format", None) == "org.matrix.custom.html": if isinstance(event, str):
message, entities = formatter.matrix_to_telegram(event.get("formatted_body", "")) message, entities = formatter.matrix_to_telegram(event)
elif isinstance(event, TextMessageEventContent) and event.format == Format.HTML:
message, entities = formatter.matrix_to_telegram(event.formatted_body)
else: else:
message, entities = formatter.matrix_text_to_telegram(event.get("body", "")) message, entities = formatter.matrix_text_to_telegram(event.body)
except KeyError: except KeyError:
message, entities = None, None message, entities = None, None
return message, entities return message, entities
@@ -1403,8 +1408,7 @@ class Portal:
self.bot.add_chat(self.tgid, self.peer_type) self.bot.add_chat(self.tgid, self.peer_type)
levels = await self.main_intent.get_power_levels(self.mxid) levels = await self.main_intent.get_power_levels(self.mxid)
bot_level = self._get_bot_level(levels) if levels.get_user_level(self.main_intent.mxid) == 100:
if bot_level == 100:
levels = self._get_base_power_levels(levels, entity) levels = self._get_base_power_levels(levels, entity)
await self.main_intent.set_power_levels(self.mxid, levels) await self.main_intent.set_power_levels(self.mxid, levels)
await self.handle_matrix_power_levels(source, levels["users"], {}) await self.handle_matrix_power_levels(source, levels["users"], {})
@@ -1441,22 +1445,17 @@ class Portal:
return None return None
if self.get_config("inline_images") and (evt.message if self.get_config("inline_images") and (evt.message
or evt.fwd_from or evt.reply_to_msg_id): or evt.fwd_from or evt.reply_to_msg_id):
text, html, relates_to = await formatter.telegram_to_matrix( content = await formatter.telegram_to_matrix(
evt, source, self.main_intent, evt, source, self.main_intent,
prefix_html=f"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>", prefix_html=f"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>",
prefix_text="Inline image: ") prefix_text="Inline image: ")
content.external_url = self.get_external_url(evt)
await intent.set_typing(self.mxid, is_typing=False) await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to, return await intent.send_message(self.mxid, content, timestamp=evt.date)
timestamp=evt.date, info = ImageInfo(
external_url=self.get_external_url(evt)) height=largest_size.h, width=largest_size.w, orientation=0, mimetype=file.mime_type,
info = { size=(len(largest_size.bytes) if (isinstance(largest_size, PhotoCachedSize))
"h": largest_size.h, else largest_size.size))
"w": largest_size.w,
"size": len(largest_size.bytes) if (
isinstance(largest_size, PhotoCachedSize)) else largest_size.size,
"orientation": 0,
"mimetype": file.mime_type,
}
name = f"image{sane_mimetypes.guess_extension(file.mime_type)}" name = f"image{sane_mimetypes.guess_extension(file.mime_type)}"
await intent.set_typing(self.mxid, is_typing=False) await intent.set_typing(self.mxid, is_typing=False)
result = await intent.send_image(self.mxid, file.mxc, info=info, text=name, result = await intent.send_image(self.mxid, file.mxc, info=info, text=name,
@@ -1492,7 +1491,7 @@ class Portal:
@staticmethod @staticmethod
def _parse_telegram_document_meta(evt: Message, file: DBTelegramFile, attrs: Dict, def _parse_telegram_document_meta(evt: Message, file: DBTelegramFile, attrs: Dict,
thumb_size: TypePhotoSize) -> Tuple[Dict, str]: thumb_size: TypePhotoSize) -> Tuple[ImageInfo, str]:
document = evt.media.document document = evt.media.document
name = evt.message or attrs["name"] name = evt.message or attrs["name"]
if attrs["is_sticker"]: if attrs["is_sticker"]:
@@ -1508,26 +1507,21 @@ class Portal:
mime_type = document.mime_type or file.mime_type mime_type = document.mime_type or file.mime_type
else: else:
mime_type = file.mime_type or document.mime_type mime_type = file.mime_type or document.mime_type
info = { info = ImageInfo(size=file.size, mimetype=mime_type)
"size": file.size,
"mimetype": mime_type,
}
if attrs["mime_type"] and not file.was_converted: if attrs["mime_type"] and not file.was_converted:
file.mime_type = attrs["mime_type"] or file.mime_type file.mime_type = attrs["mime_type"] or file.mime_type
if file.width and file.height: if file.width and file.height:
info["w"], info["h"] = file.width, file.height info.width, info.height = file.width, file.height
elif attrs["width"] and attrs["height"]: elif attrs["width"] and attrs["height"]:
info["w"], info["h"] = attrs["width"], attrs["height"] info.width, info.height = attrs["width"], attrs["height"]
if file.thumbnail: if file.thumbnail:
info["thumbnail_url"] = file.thumbnail.mxc info.thumbnail_url = file.thumbnail.mxc
info["thumbnail_info"] = { info.thumbnail_info = ThumbnailInfo(mimetype=file.thumbnail.mime_type,
"mimetype": file.thumbnail.mime_type, height=file.thumbnail.height or thumb_size.h,
"h": file.thumbnail.height or thumb_size.h, width=file.thumbnail.width or thumb_size.w,
"w": file.thumbnail.width or thumb_size.w, size=file.thumbnail.size)
"size": file.thumbnail.size,
}
return info, name return info, name
@@ -1948,7 +1942,7 @@ class Portal:
await self.update_telegram_pin() await self.update_telegram_pin()
@staticmethod @staticmethod
def _get_level_from_participant(participant: TypeParticipant, _: Dict) -> int: def _get_level_from_participant(participant: TypeParticipant) -> int:
# TODO use the power level requirements to get better precision in channels # TODO use the power level requirements to get better precision in channels
if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)): if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
return 50 return 50
@@ -1957,28 +1951,16 @@ class Portal:
return 0 return 0
@staticmethod @staticmethod
def _participant_to_power_levels(levels: dict, user: Union['u.User', p.Puppet], new_level: int, def _participant_to_power_levels(levels: PowerLevelStateEventContent,
user: Union['u.User', p.Puppet], new_level: int,
bot_level: int) -> bool: bot_level: int) -> bool:
new_level = min(new_level, bot_level) new_level = min(new_level, bot_level)
default_level = levels["users_default"] if "users_default" in levels else 0 user_level = levels.get_user_level(user.mxid)
try:
user_level = int(levels["users"][user.mxid])
except (ValueError, KeyError):
user_level = default_level
if user_level != new_level and user_level < bot_level: if user_level != new_level and user_level < bot_level:
levels["users"][user.mxid] = new_level levels.users[user.mxid] = new_level
return True return True
return False return False
def _get_bot_level(self, levels: dict) -> int:
try:
return levels["users"][self.main_intent.mxid]
except KeyError:
try:
return levels["users_default"]
except KeyError:
return 0
@staticmethod @staticmethod
def _get_powerlevel_level(levels: dict) -> int: def _get_powerlevel_level(levels: dict) -> int:
try: try:
@@ -1989,21 +1971,21 @@ class Portal:
except KeyError: except KeyError:
return 50 return 50
def _participants_to_power_levels(self, participants: List[TypeParticipant], levels: Dict def _participants_to_power_levels(self, participants: List[TypeParticipant],
) -> bool: levels: PowerLevelStateEventContent) -> bool:
bot_level = self._get_bot_level(levels) bot_level = levels.get_user_level(self.main_intent.mxid)
if bot_level < self._get_powerlevel_level(levels): if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
return False return False
changed = False changed = False
admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level) admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level)
if levels["events"]["m.room.power_levels"] != admin_power_level: if levels.events[EventType.ROOM_POWER_LEVELS] != admin_power_level:
changed = True changed = True
levels["events"]["m.room.power_levels"] = admin_power_level levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
for participant in participants: for participant in participants:
puppet = p.Puppet.get(TelegramID(participant.user_id)) puppet = p.Puppet.get(TelegramID(participant.user_id))
user = u.User.get_by_tgid(TelegramID(participant.user_id)) user = u.User.get_by_tgid(TelegramID(participant.user_id))
new_level = self._get_level_from_participant(participant, levels) new_level = self._get_level_from_participant(participant)
if user: if user:
user.register_portal(self) user.register_portal(self)
-6
View File
@@ -1,10 +1,4 @@
from asyncio import Future
from .file_transfer import transfer_file_to_matrix, convert_image from .file_transfer import transfer_file_to_matrix, convert_image
from .format_duration import format_duration from .format_duration import format_duration
from .signed_token import sign_token, verify_token from .signed_token import sign_token, verify_token
from .recursive_dict import recursive_del, recursive_set, recursive_get from .recursive_dict import recursive_del, recursive_set, recursive_get
def ignore_coro(_: Future) -> None:
pass
+2 -2
View File
@@ -26,7 +26,7 @@ from telethon.errors import *
from mautrix.bridge import OnlyLoginSelf, InvalidAccessToken from mautrix.bridge import OnlyLoginSelf, InvalidAccessToken
from ...commands.telegram.auth import enter_password from ...commands.telegram.auth import enter_password
from ...util import format_duration, ignore_coro from ...util import format_duration
from ...puppet import Puppet from ...puppet import Puppet
from ...user import User from ...user import User
@@ -119,7 +119,7 @@ class AuthAPI(abc.ABC):
existing_user = User.get_by_tgid(user_info.id) existing_user = User.get_by_tgid(user_info.id)
if existing_user and existing_user != user: if existing_user and existing_user != user:
await existing_user.log_out() await existing_user.log_out()
ignore_coro(asyncio.ensure_future(user.post_login(user_info), loop=self.loop)) asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
if user.command_status and user.command_status["action"] == "Login": if user.command_status and user.command_status["action"] == "Login":
user.command_status = None user.command_status = None
@@ -30,7 +30,6 @@ from mautrix.types import UserID
from ...types import TelegramID from ...types import TelegramID
from ...user import User from ...user import User
from ...portal import Portal from ...portal import Portal
from ...util import ignore_coro
from ...commands.portal.util import user_has_power_level, get_initial_state from ...commands.portal.util import user_has_power_level, get_initial_state
from ..common import AuthAPI from ..common import AuthAPI
@@ -188,9 +187,8 @@ class ProvisioningAPI(AuthAPI):
portal.photo_id = "" portal.photo_id = ""
portal.save() portal.save()
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
levels=levels), loop=self.loop)
loop=self.loop))
return web.Response(status=202, body="{}") return web.Response(status=202, body="{}")
@@ -269,7 +267,8 @@ class ProvisioningAPI(AuthAPI):
require_puppeting=False, require_user=False) require_puppeting=False, require_user=False)
if err is not None: if err is not None:
return err return err
elif user and not await user_has_power_level(portal.mxid, self.az.intent, user, "unbridge"): elif user and not await user_has_power_level(portal.mxid, self.az.intent, user,
"unbridge"):
return self.get_error_response(403, "not_enough_permissions", return self.get_error_response(403, "not_enough_permissions",
"You do not have the permissions to unbridge that room.") "You do not have the permissions to unbridge that room.")
@@ -284,7 +283,7 @@ class ProvisioningAPI(AuthAPI):
self.log.exception("Failed to disconnect chat") self.log.exception("Failed to disconnect chat")
return self.get_error_response(500, "exception", "Failed to disconnect chat") return self.get_error_response(500, "exception", "Failed to disconnect chat")
else: else:
ignore_coro(asyncio.ensure_future(coro, loop=self.loop)) asyncio.ensure_future(coro, loop=self.loop)
return web.json_response({}, status=200 if sync else 202) return web.json_response({}, status=200 if sync else 202)
async def get_user_info(self, request: web.Request) -> web.Response: async def get_user_info(self, request: web.Request) -> web.Response: