diff --git a/.dockerignore b/.dockerignore index ec191c92..1f625fe0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,5 @@ .codeclimate.yml *.png *.md +logs +.venv diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000..6da06937 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,16 @@ +[settings] +line_length=99 +indent=4 + +multi_line_output=5 + +sections=FUTURE,STDLIB,THIRDPARTY,TELETHON,MAUTRIX,FIRSTPARTY,LOCALFOLDER +no_lines_before=LOCALFOLDER +default_section=FIRSTPARTY + +known_thirdparty=aiohttp,sqlalchemy,alembic,commonmark,ruamel.yaml,PIL,moviepy,prometheus_client,yarl,mako,pkg_resources +known_telethon=telethon,alchemysession,cryptg +known_mautrix=mautrix + +balanced_wrapping=True +length_sort=True diff --git a/Dockerfile b/Dockerfile index b26dec67..6fa38a13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,8 @@ RUN apk add --no-cache \ ffmpeg \ ca-certificates \ su-exec \ - && pip3 install .[all] + && pip3 install .[fast_crypto,hq_thumbnails,metrics] \ + && pip3 install --upgrade 'https://github.com/LonamiWebs/Telethon/tarball/master#egg=telethon' VOLUME /data diff --git a/alembic/env.py b/alembic/env.py index def7f3cd..ef158c7d 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -7,7 +7,8 @@ from os.path import abspath, dirname sys.path.insert(0, dirname(dirname(abspath(__file__)))) -from mautrix_telegram.db import Base +from mautrix.bridge.db import Base +import mautrix_telegram.db from mautrix_telegram.config import Config from alchemysession import AlchemySessionContainer @@ -18,17 +19,10 @@ config = context.config mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml") mxtg_config = Config(mxtg_config_path, None, None) mxtg_config.load() -config.set_main_option("sqlalchemy.url", - mxtg_config.get("appservice.database", "sqlite:///mautrix-telegram.db")) +config.set_main_option("sqlalchemy.url", mxtg_config["appservice.database"]) -class FakeDB: - @staticmethod - def query_property(): - return None - - -AlchemySessionContainer.create_table_classes(FakeDB(), "telethon_", Base) +AlchemySessionContainer.create_table_classes(None, "telethon_", Base) # Interpret the config file for Python logging. # This line sets up loggers basically. diff --git a/alembic/versions/4f7d7ed5792a_switch_mx_user_profile_to_native_enum.py b/alembic/versions/4f7d7ed5792a_switch_mx_user_profile_to_native_enum.py new file mode 100644 index 00000000..b64ab95c --- /dev/null +++ b/alembic/versions/4f7d7ed5792a_switch_mx_user_profile_to_native_enum.py @@ -0,0 +1,25 @@ +"""Switch mx_user_profile to native enum + +Revision ID: 4f7d7ed5792a +Revises: 9e9c89b0b877 +Create Date: 2019-08-04 17:47:36.568120 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '4f7d7ed5792a' +down_revision = '9e9c89b0b877' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + conn.execute("UPDATE mx_user_profile SET membership=UPPER(membership)") + + +def downgrade(): + conn = op.get_bind() + conn.execute("UPDATE mx_user_profile SET membership=LOWER(membership)") diff --git a/alembic/versions/6ca3d74d51e4_move_state_store_to_main_database.py b/alembic/versions/6ca3d74d51e4_move_state_store_to_main_database.py index b26f447f..a66b21d5 100644 --- a/alembic/versions/6ca3d74d51e4_move_state_store_to_main_database.py +++ b/alembic/versions/6ca3d74d51e4_move_state_store_to_main_database.py @@ -5,14 +5,16 @@ Revises: 2228d49c383f Create Date: 2018-06-26 21:31:26.911307 """ -from alembic import context, op -import sqlalchemy.orm as orm -import sqlalchemy as sa import json import re +from alembic import context, op +import sqlalchemy.orm as orm +import sqlalchemy as sa + +from mautrix.bridge.db import Base + from mautrix_telegram.config import Config -from mautrix_telegram.db import Base # revision identifiers, used by Alembic. revision = "6ca3d74d51e4" @@ -22,7 +24,6 @@ depends_on = None class RoomState(Base): - query = None __tablename__ = "mx_room_state" __table_args__ = {"extend_existing": True} @@ -31,7 +32,6 @@ class RoomState(Base): class UserProfile(Base): - query = None __tablename__ = "mx_user_profile" __table_args__ = {"extend_existing": True} @@ -43,7 +43,6 @@ class UserProfile(Base): class Puppet(Base): - query = None __tablename__ = "puppet" __table_args__ = {"extend_existing": True} @@ -83,7 +82,7 @@ def upgrade(): def migrate_state_store(): conn = op.get_bind() - session = orm.sessionmaker(bind=conn)() # type: orm.Session + session: orm.Session = orm.sessionmaker(bind=conn)() try: with open("mx-state.json") as file: diff --git a/alembic/versions/a7c04a56041b_store_custom_puppet_next_batch_in_.py b/alembic/versions/a7c04a56041b_store_custom_puppet_next_batch_in_.py new file mode 100644 index 00000000..4ea75c6e --- /dev/null +++ b/alembic/versions/a7c04a56041b_store_custom_puppet_next_batch_in_.py @@ -0,0 +1,26 @@ +"""Store custom puppet next_batch in database + +Revision ID: a7c04a56041b +Revises: 4f7d7ed5792a +Create Date: 2019-08-06 23:08:51.087651 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "a7c04a56041b" +down_revision = "4f7d7ed5792a" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("puppet") as batch_op: + batch_op.add_column(sa.Column("next_batch", sa.String(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("puppet") as batch_op: + batch_op.drop_column("next_batch") diff --git a/example-config.yaml b/example-config.yaml index a07555e2..56e891c4 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -103,6 +103,8 @@ bridge: - full name - username - phone number + # Maximum length of displayname + displayname_max_length: 100 # Maximum number of members to sync per portal when starting up. Other members will be # synced when they send messages. The maximum is 10000, after which the Telegram server @@ -119,9 +121,10 @@ bridge: # their Telegram account at startup. startup_sync: true # Number of most recently active dialogs to check when syncing chats. - # Dialogs include groups and private chats, but only groups are synced. # Set to 0 to remove limit. sync_dialog_limit: 30 + # Whether or not to sync and create portals for direct chats at startup. + sync_direct_chats: false # The maximum number of simultaneous Telegram deletions to handle. # A large number of simultaneous redactions could put strain on your homeserver. max_telegram_delete: 10 @@ -177,6 +180,7 @@ bridge: # The formats to use when sending messages to Telegram via the relay bot. + # Text msgtypes (m.text, m.notice and m.emote) support HTML, media msgtypes don't. # # Telegram doesn't have built-in emotes, so the m.emote format is also used for non-relaybot users. # @@ -184,15 +188,17 @@ bridge: # $sender_displayname - The display name of the sender (e.g. Example User) # $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser) # $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com) - # $message - The message content as HTML + # $body - The plaintext body (file name for media msgtypes) + # $formatted_body - The message content as HTML (for text msgtypes) message_formats: - m.text: "$sender_displayname: $message" - m.emote: "* $sender_displayname $message" - m.file: "$sender_displayname sent a file: $message" - m.image: "$sender_displayname sent an image: $message" - m.audio: "$sender_displayname sent an audio file: $message" - m.video: "$sender_displayname sent a video: $message" - m.location: "$sender_displayname sent a location: $message" + m.text: "$sender_displayname: $formatted_body" + m.notice: "$sender_displayname: $formatted_body" + m.emote: "* $sender_displayname $formatted_body" + m.file: "$sender_displayname sent a file: $body" + m.image: "$sender_displayname sent an image: $body" + m.audio: "$sender_displayname sent an audio file: $body" + m.video: "$sender_displayname sent a video: $body" + m.location: "$sender_displayname sent a location: $body" # The formats to use when sending state events to Telegram via the relay bot. # @@ -307,14 +313,14 @@ telegram: # Telethon proxy configuration. # You must install PySocks from pip for proxies to work. proxy: - # Allowed types: disabled, socks4, socks5, http + # Allowed types: disabled, socks4, socks5, http, mtproxy type: disabled # Proxy IP address and port. address: 127.0.0.1 port: 1080 - # Whether or not to perform DNS resolving remotely. + # Whether or not to perform DNS resolving remotely. Only for socks/http proxies. rdns: true - # Proxy authentication (optional). + # Proxy authentication (optional). Put MTProxy secret in password field. username: "" password: "" @@ -325,18 +331,21 @@ telegram: logging: version: 1 formatters: - precise: + colored: + (): mautrix_telegram.util.ColorFormatter + format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" + normal: format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" handlers: file: class: logging.handlers.RotatingFileHandler - formatter: precise + formatter: normal filename: ./mautrix-telegram.log maxBytes: 10485760 backupCount: 10 console: class: logging.StreamHandler - formatter: precise + formatter: colored loggers: mau: level: DEBUG diff --git a/mautrix_telegram/__init__.py b/mautrix_telegram/__init__.py index 4300d91b..ceba3d65 100644 --- a/mautrix_telegram/__init__.py +++ b/mautrix_telegram/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.6.0" +__version__ = "0.7.0+dev" __author__ = "Tulir Asokan " diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py index ecd253f7..35714151 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,32 +13,25 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Awaitable, List, Any -from time import time -import argparse -import asyncio -import logging.config +from itertools import chain import sys -import copy -import signal -import os -import sqlalchemy as sql - -from mautrix_appservice import AppService from alchemysession import AlchemySessionContainer +from mautrix.bridge import Bridge +from mautrix.bridge.db import Base + from .web.provisioning import ProvisioningAPI from .web.public import PublicBridgeWebsite from .abstract_user import init as init_abstract_user -from .bot import init as init_bot +from .bot import Bot, init as init_bot from .config import Config 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 .matrix import MatrixHandler from .portal import init as init_portal -from .puppet import init as init_puppet +from .puppet import Puppet, init as init_puppet from .sqlstatestore import SQLStateStore from .user import User, init as init_user from . import __version__ @@ -49,115 +41,65 @@ try: except ImportError: prometheus = None -parser = argparse.ArgumentParser( - description="A Matrix-Telegram puppeting bridge.", - prog="python -m mautrix-telegram") -parser.add_argument("-c", "--config", type=str, default="config.yaml", - metavar="", help="the path to your config file") -parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml", - metavar="", help="the path to the example config " - "(for automatic config updates)") -parser.add_argument("-g", "--generate-registration", action="store_true", - help="generate registration and quit") -parser.add_argument("-r", "--registration", type=str, default="registration.yaml", - metavar="", help="the path to save the generated registration to") -args = parser.parse_args() -config = Config(args.config, args.registration, args.base_config, os.environ) -config.load() -config.update() +class TelegramBridge(Bridge): + name = "mautrix-telegram" + command = "python -m mautrix-telegram" + description = "A Matrix-Telegram puppeting bridge." + real_user_content_key = "net.maunium.telegram.puppet" + version = __version__ + config_class = Config + matrix_class = MatrixHandler + state_store_class = SQLStateStore -if args.generate_registration: - config.generate_registration() - config.save() - print(f"Registration generated and saved to {config.registration_path}") - sys.exit(0) + config: Config + session_container: AlchemySessionContainer + bot: Bot -logging.config.dictConfig(copy.deepcopy(config["logging"])) -log = logging.getLogger("mau.init") # type: logging.Logger -log.debug(f"Initializing mautrix-telegram {__version__}") + def prepare_db(self) -> None: + super().prepare_db() + init_db(self.db) + self.session_container = AlchemySessionContainer( + engine=self.db, table_base=Base, session=False, + table_prefix="telethon_", manage_tables=False) -db_engine = sql.create_engine(config["appservice.database"] or "sqlite:///mautrix-telegram.db") -Base.metadata.bind = db_engine + def _prepare_website(self, context: Context) -> None: + if self.config["appservice.public.enabled"]: + public_website = PublicBridgeWebsite(self.loop) + self.az.app.add_subapp(self.config["appservice.public.prefix"], public_website.app) + context.public_website = public_website -session_container = AlchemySessionContainer(engine=db_engine, table_base=Base, session=False, - table_prefix="telethon_", manage_tables=False) -session_container.core_mode = True + if self.config["appservice.provisioning.enabled"]: + provisioning_api = ProvisioningAPI(context) + self.az.app.add_subapp(self.config["appservice.provisioning.prefix"], + provisioning_api.app) + context.provisioning_api = provisioning_api -try: - import uvloop + if self.config["metrics.enabled"]: + if prometheus: + prometheus.start_http_server(self.config["metrics.listen_port"]) + else: + self.log.warning("Metrics are enabled in the config, " + "but prometheus_client is not installed.") - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) - log.debug("Using uvloop for asyncio") -except ImportError: - pass + def prepare_bridge(self) -> None: + self.bot = init_bot(self.config) + context = Context(self.az, self.config, self.loop, self.session_container, self.bot) + self._prepare_website(context) + self.matrix = context.mx = MatrixHandler(context) -loop = asyncio.get_event_loop() # type: asyncio.AbstractEventLoop + init_abstract_user(context) + init_formatter(context) + init_portal(context) + puppet_startup = init_puppet(context) + user_startup = init_user(context) + bot_startup = [self.bot.start()] if self.bot else [] + self.startup_actions = chain(puppet_startup, user_startup, bot_startup) -state_store = SQLStateStore() -mebibyte = 1024 ** 2 -appserv = AppService(config["homeserver.address"], config["homeserver.domain"], - config["appservice.as_token"], config["appservice.hs_token"], - config["appservice.bot_username"], log="mau.as", loop=loop, - verify_ssl=config["homeserver.verify_ssl"], state_store=state_store, - real_user_content_key="net.maunium.telegram.puppet", - aiohttp_params={ - "client_max_size": config["appservice.max_body_size"] * mebibyte - }) -bot = init_bot(config) -context = Context(appserv, config, loop, session_container, bot) + def prepare_stop(self) -> None: + for puppet in Puppet.by_custom_mxid.values(): + puppet.stop() + self.shutdown_actions = (user.stop() for user in User.by_tgid.values()) -if config["appservice.public.enabled"]: - public_website = PublicBridgeWebsite(loop) - appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app) - context.public_website = public_website -if config["appservice.provisioning.enabled"]: - provisioning_api = ProvisioningAPI(context) - appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning", - provisioning_api.app) - context.provisioning_api = provisioning_api - -context.mx = MatrixHandler(context) - -if config["metrics.enabled"]: - if prometheus: - prometheus.start_http_server(config["metrics.listen_port"]) - else: - log.warn("Metrics are enabled in the config, but prometheus-async is not installed.") - -with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start: - start_ts = time() - init_db(db_engine) - init_abstract_user(context) - init_formatter(context) - init_portal(context) - startup_actions = (init_puppet(context) + - init_user(context) + - [start, context.mx.init_as_bot()]) # type: List[Awaitable[Any]] - - if context.bot: - startup_actions.append(context.bot.start()) - - signal.signal(signal.SIGINT, signal.default_int_handler) - signal.signal(signal.SIGTERM, signal.default_int_handler) - - end_ts = time() - try: - log.debug(f"Initialization complete in {round(end_ts - start_ts, 2)} seconds," - " running startup actions") - start_ts = time() - loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop)) - end_ts = time() - log.debug(f"Startup actions complete in {round(end_ts - start_ts, 2)} seconds," - " now running forever") - loop.run_forever() - except KeyboardInterrupt: - log.debug("Interrupt received, stopping clients") - loop.run_until_complete( - asyncio.gather(*[user.stop() for user in User.by_tgid.values()], loop=loop)) - log.debug("Clients stopped, shutting down") - sys.exit(0) - except Exception as e: - log.exception("Unexpected error") - sys.exit(1) +TelegramBridge().run() diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index dd9f3d02..40cefbb7 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,28 +13,33 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Tuple, Optional, List, Union, Dict, TYPE_CHECKING +from typing import Tuple, Optional, Union, Dict, Type, Any, TYPE_CHECKING from abc import ABC, abstractmethod import asyncio import logging import platform import time +from telethon.sessions import Session +from telethon.network import (ConnectionTcpMTProxyRandomizedIntermediate, ConnectionTcpFull, + Connection) from telethon.tl.patched import MessageService, Message from telethon.tl.types import ( - Channel, ChannelForbidden, Chat, ChatForbidden, MessageActionChannelMigrateFrom, PeerUser, - TypeUpdate, UpdateChannelPinnedMessage, UpdateChatPinnedMessage, UpdateChatParticipantAdmin, - UpdateChatParticipants, UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateDeleteMessages, - UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateNewMessage, - UpdateReadHistoryOutbox, UpdateShortChatMessage, UpdateShortMessage, UpdateUserName, - UpdateUserPhoto, UpdateUserStatus, UpdateUserTyping, User, UserStatusOffline, UserStatusOnline) + Channel, Chat, MessageActionChannelMigrateFrom, PeerUser, TypeUpdate, UpdateChatPinnedMessage, + UpdateChannelPinnedMessage, UpdateChatParticipantAdmin, UpdateChatParticipants, + UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateNewMessage, UpdateDeleteMessages, + UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateReadHistoryOutbox, + UpdateShortChatMessage, UpdateShortMessage, UpdateUserName, UpdateUserPhoto, UpdateUserStatus, + UpdateUserTyping, User, UserStatusOffline, UserStatusOnline) -from mautrix_appservice import MatrixRequestError, AppService +from mautrix.types import UserID, PresenceState +from mautrix.errors import MatrixError +from mautrix.appservice import AppService from alchemysession import AlchemySessionContainer from . import portal as po, puppet as pu, __version__ from .db import Message as DBMessage -from .types import TelegramID, MatrixUserID +from .types import TelegramID from .tgclient import MautrixTelegramClient if TYPE_CHECKING: @@ -43,9 +47,9 @@ if TYPE_CHECKING: from .config import Config from .bot import Bot -config = None # type: Config +config: Optional['Config'] = None # Value updated from config in init() -MAX_DELETIONS = 10 # type: int +MAX_DELETIONS: int = 10 UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage, UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage] @@ -60,47 +64,67 @@ except ImportError: Histogram = None UPDATE_TIME = None + class AbstractUser(ABC): - session_container = None # type: AlchemySessionContainer - loop = None # type: asyncio.AbstractEventLoop - log = None # type: logging.Logger - az = None # type: AppService - bot = None # type: Bot - ignore_incoming_bot_events = True # type: bool + session_container: AlchemySessionContainer = None + loop: asyncio.AbstractEventLoop = None + log: logging.Logger + az: AppService + relaybot: Optional['Bot'] + ignore_incoming_bot_events: bool = True + + client: Optional[MautrixTelegramClient] + mxid: Optional[UserID] + + tgid: Optional[TelegramID] + username: Optional['str'] + is_bot: bool + + is_relaybot: bool + + puppet_whitelisted: bool + whitelisted: bool + relaybot_whitelisted: bool + matrix_puppet_whitelisted: bool + is_admin: bool def __init__(self) -> None: - self.is_admin = False # type: bool - self.matrix_puppet_whitelisted = False # type: bool - self.puppet_whitelisted = False # type: bool - self.whitelisted = False # type: bool - self.relaybot_whitelisted = False # type: bool - self.client = None # type: MautrixTelegramClient - self.tgid = None # type: TelegramID - self.mxid = None # type: MatrixUserID - self.is_relaybot = False # type: bool - self.is_bot = False # type: bool - self.relaybot = None # type: Optional[Bot] + self.is_admin = False + self.matrix_puppet_whitelisted = False + self.puppet_whitelisted = False + self.whitelisted = False + self.relaybot_whitelisted = False + self.client = None + self.is_relaybot = False + self.is_bot = False + self.relaybot = None @property def connected(self) -> bool: return self.client and self.client.is_connected() @property - def _proxy_settings(self) -> Optional[Tuple[int, str, str, str, str, str]]: + def _proxy_settings(self) -> Tuple[Type[Connection], Optional[Tuple[Any, ...]]]: proxy_type = config["telegram.proxy.type"].lower() + connection = ConnectionTcpFull + connection_data = (config["telegram.proxy.address"], + config["telegram.proxy.port"], + config["telegram.proxy.rdns"], + config["telegram.proxy.username"], + config["telegram.proxy.password"]) if proxy_type == "disabled": - return None + connection_data = None elif proxy_type == "socks4": - proxy_type = 1 + connection_data = (1,) + connection_data elif proxy_type == "socks5": - proxy_type = 2 + connection_data = (2,) + connection_data elif proxy_type == "http": - proxy_type = 3 + connection_data = (3,) + connection_data + elif proxy_type == "mtproxy": + connection = ConnectionTcpMTProxyRandomizedIntermediate + connection_data = (connection_data[0], connection_data[1], connection_data[4]) - return (proxy_type, - config["telegram.proxy.address"], config["telegram.proxy.port"], - config["telegram.proxy.rdns"], - config["telegram.proxy.username"], config["telegram.proxy.password"]) + return connection, connection_data def _init_client(self) -> None: self.log.debug(f"Initializing client for {self.name}") @@ -119,6 +143,9 @@ class AbstractUser(ABC): device = config["telegram.device_info.device_model"] sysversion = config["telegram.device_info.system_version"] appversion = config["telegram.device_info.app_version"] + connection, proxy = self._proxy_settings + + assert isinstance(self.session, Session) self.client = MautrixTelegramClient( session=self.session, @@ -127,16 +154,18 @@ class AbstractUser(ABC): api_hash=config["telegram.api_hash"], app_version=__version__ if appversion == "auto" else appversion, - system_version=MautrixTelegramClient.__version__ if sysversion == "auto" else sysversion, - device_model=f"{platform.system()} {platform.release()}" if device == "auto" else device, + system_version=(MautrixTelegramClient.__version__ + if sysversion == "auto" else sysversion), + device_model=(f"{platform.system()} {platform.release()}" + if device == "auto" else device), timeout=config["telegram.connection.timeout"], connection_retries=config["telegram.connection.retries"], retry_delay=config["telegram.connection.retry_delay"], flood_sleep_threshold=config["telegram.connection.flood_sleep_threshold"], request_retries=config["telegram.connection.request_retries"], - - proxy=self._proxy_settings, + connection=connection, + proxy=proxy, loop=self.loop, base_logger=base_logger @@ -165,26 +194,18 @@ class AbstractUser(ABC): if not await self.update(update): await self._update(update) except Exception: - self.log.exception("Failed to handle Telegram update") + self.log.exception(f"Failed to handle Telegram update {update}") if UPDATE_TIME: UPDATE_TIME.labels(update_type=type(update).__name__).observe(time.time() - start_time) - async def get_dialogs(self, limit: int = None) -> List[Union[Chat, Channel]]: - if self.is_bot: - return [] - dialogs = await self.client.get_dialogs(limit=limit) - return [dialog.entity for dialog in dialogs if ( - not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden)) - and not (isinstance(dialog.entity, Chat) - and (dialog.entity.deactivated or dialog.entity.left)))] - @property @abstractmethod def name(self) -> str: raise NotImplementedError() async def is_logged_in(self) -> bool: - return self.client and self.client.is_connected() and await self.client.is_user_authorized() + return (self.client and self.client.is_connected() + and await self.client.is_user_authorized()) async def has_full_access(self, allow_bot: bool = False) -> bool: return (self.puppet_whitelisted @@ -195,14 +216,15 @@ class AbstractUser(ABC): if not self.client: self._init_client() await self.client.connect() - self.log.debug("%s connected: %s", self.mxid, self.connected) + self.log.debug(f"{self.mxid if not self.is_bot else 'Bot'} connected: {self.connected}") return self async def ensure_started(self, even_if_no_session=False) -> 'AbstractUser': - if not self.puppet_whitelisted or self.connected: + if self.connected: return self - self.log.debug("ensure_started(%s, even_if_no_session=%s)", self.mxid, even_if_no_session) if even_if_no_session or self.session_container.has_session(self.mxid): + self.log.debug("Starting client due to ensure_started" + f"(even_if_no_session={even_if_no_session})") await self.start(delete_unless_authenticated=not even_if_no_session) return self @@ -317,9 +339,9 @@ class AbstractUser(ABC): async def update_status(self, update: UpdateUserStatus) -> None: puppet = pu.Puppet.get(TelegramID(update.user_id)) if isinstance(update.status, UserStatusOnline): - await puppet.default_mxid_intent.set_presence("online") + await puppet.default_mxid_intent.set_presence(PresenceState.ONLINE) elif isinstance(update.status, UserStatusOffline): - await puppet.default_mxid_intent.set_presence("offline") + await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE) else: self.log.warning("Unexpected user status update: %s", update) return @@ -355,7 +377,7 @@ class AbstractUser(ABC): return try: await portal.main_intent.redact(message.mx_room, message.mxid) - except MatrixRequestError: + except MatrixError: pass async def delete_message(self, update: UpdateDeleteMessages) -> None: @@ -363,12 +385,10 @@ class AbstractUser(ABC): return for message_id in update.messages: - messages = DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid) - for message in messages: + for message in DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid): message.delete() number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room) if number_left == 0: - portal = po.Portal.get_by_mxid(message.mx_room) await self._try_redact(message) async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None: @@ -378,8 +398,7 @@ class AbstractUser(ABC): channel_id = TelegramID(update.channel_id) for message_id in update.messages: - messages = DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id) - for message in messages: + for message in DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id): message.delete() await self._try_redact(message) @@ -391,7 +410,7 @@ class AbstractUser(ABC): portal.tgid_log) return - if self.ignore_incoming_bot_events and self.bot and sender.id == self.bot.tgid: + if self.ignore_incoming_bot_events and self.relaybot and sender.id == self.relaybot.tgid: self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log) return @@ -415,7 +434,7 @@ class AbstractUser(ABC): # endregion -def init(context: "Context") -> None: +def init(context: 'Context') -> None: global config, MAX_DELETIONS AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"] diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py index 6758d0a3..cf87543e 100644 --- a/mautrix_telegram/bot.py +++ b/mautrix_telegram/bot.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,9 +13,8 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Awaitable, Callable, Dict, List, Optional, Pattern, Tuple, TYPE_CHECKING +from typing import Awaitable, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING import logging -import re from telethon.tl.patched import Message, MessageService from telethon.tl.types import ( @@ -28,7 +26,8 @@ from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest from telethon.errors import ChannelInvalidError, ChannelPrivateError -from .types import MatrixUserID +from mautrix.types import UserID + from .abstract_user import AbstractUser from .db import BotChat from .types import TelegramID @@ -36,34 +35,41 @@ from . import puppet as pu, portal as po, user as u if TYPE_CHECKING: from .config import Config - from .context import Context -config = None # type: Config +config: Optional['Config'] = None ReplyFunc = Callable[[str], Awaitable[Message]] class Bot(AbstractUser): - log = logging.getLogger("mau.bot") # type: logging.Logger - mxid_regex = re.compile("@.+:.+") # type: Pattern + log: logging.Logger = logging.getLogger("mau.user.bot") + + token: str + chats: Dict[int, str] + tg_whitelist: List[int] + whitelist_group_admins: bool + _me_info: Optional[User] + _me_mxid: Optional[UserID] def __init__(self, token: str) -> None: super().__init__() - self.token = token # type: str - self.puppet_whitelisted = True # type: bool - self.whitelisted = True # type: bool - self.relaybot_whitelisted = True # type: bool - self.username = None # type: str - self.is_relaybot = True # type: bool - self.is_bot = True # type: bool - self.chats = {} # type: Dict[int, str] - self.tg_whitelist = [] # type: List[int] + self.token = token + self.tgid = None + self.mxid = None + self.puppet_whitelisted = True + self.whitelisted = True + self.relaybot_whitelisted = True + self.username = None + self.is_relaybot = True + self.is_bot = True + self.chats = {} + self.tg_whitelist = [] self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"] - or False) # type: bool - self._me_info = None # type: Optional[User] - self._me_mxid = None # type: Optional[MatrixUserID] + or False) + self._me_info = None + self._me_mxid = None - async def get_me(self, use_cache: bool = True) -> Tuple[User, MatrixUserID]: + async def get_me(self, use_cache: bool = True) -> Tuple[User, UserID]: if not use_cache or not self._me_mxid: self._me_info = await self.client.get_me() self._me_mxid = pu.Puppet.get_mxid_from_id(TelegramID(self._me_info.id)) @@ -92,7 +98,7 @@ class Bot(AbstractUser): async def post_login(self) -> None: await self.init_permissions() info = await self.client.get_me() - self.tgid = info.id + self.tgid = TelegramID(info.id) self.username = info.username self.mxid = pu.Puppet.get_mxid_from_id(self.tgid) @@ -102,9 +108,9 @@ class Bot(AbstractUser): if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated: self.remove_chat(TelegramID(chat.id)) - channel_ids = [InputChannel(chat_id, 0) + channel_ids = (InputChannel(chat_id, 0) for chat_id, chat_type in self.chats.items() - if chat_type == "channel"] + if chat_type == "channel") for channel_id in channel_ids: try: await self.client(GetChannelsRequest([channel_id])) @@ -133,7 +139,7 @@ class Bot(AbstractUser): del self.chats[chat_id] except KeyError: pass - BotChat.delete(chat_id) + BotChat.delete_by_id(chat_id) async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool: if tgid in self.tg_whitelist: @@ -157,7 +163,7 @@ class Bot(AbstractUser): return False async def check_can_use_commands(self, event: Message, reply: ReplyFunc) -> bool: - if not await self._can_use_commands(event.to_id, event.from_id): + if not await self._can_use_commands(event.to_id, TelegramID(event.from_id)): await reply("You do not have the permission to use that command.") return False return True @@ -166,7 +172,7 @@ class Bot(AbstractUser): if not config["bridge.relaybot.authless_portals"]: return await reply("This bridge doesn't allow portal creation from Telegram.") - if not portal.allow_bridging(): + if not portal.allow_bridging: return await reply("This bridge doesn't allow bridging this chat.") await portal.create_matrix_room(self) @@ -179,15 +185,15 @@ class Bot(AbstractUser): "Portal is not public. Use `/invite ` to get an invite.") async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc, - mxid_input: MatrixUserID) -> Message: + mxid_input: UserID) -> Message: if len(mxid_input) == 0: return await reply("Usage: `/invite `") elif not portal.mxid: return await reply("Portal does not have Matrix room. " "Create one with /portal first.") - if not self.mxid_regex.match(mxid_input): + if mxid_input[0] != '@' or mxid_input.find(':') < 2: return await reply("That doesn't look like a Matrix ID.") - user = await u.User.get_by_mxid(MatrixUserID(mxid_input)).ensure_started() + user = await u.User.get_by_mxid(mxid_input).ensure_started() if not user.relaybot_whitelisted: return await reply("That user is not whitelisted to use the bridge.") elif await user.is_logged_in(): @@ -195,7 +201,7 @@ class Bot(AbstractUser): return await reply("That user seems to be logged in. " f"Just invite [{displayname}](tg://user?id={user.tgid})") else: - await portal.main_intent.invite(portal.mxid, user.mxid) + await portal.main_intent.invite_user(portal.mxid, user.mxid) return await reply(f"Invited `{user.mxid}` to the portal.") @staticmethod @@ -244,15 +250,15 @@ class Bot(AbstractUser): mxid = text[text.index(" ") + 1:] except ValueError: mxid = "" - await self.handle_command_invite(portal, reply, mxid_input=mxid) + await self.handle_command_invite(portal, reply, mxid_input=UserID(mxid)) def handle_service_message(self, message: MessageService) -> None: - to_id = message.to_id # type: TelegramID - if isinstance(to_id, PeerChannel): - to_id = to_id.channel_id + to_peer = message.to_id + if isinstance(to_peer, PeerChannel): + to_id = TelegramID(to_peer.channel_id) chat_type = "channel" - elif isinstance(to_id, PeerChat): - to_id = to_id.chat_id + elif isinstance(to_peer, PeerChat): + to_id = TelegramID(to_peer.chat_id) chat_type = "chat" else: return diff --git a/mautrix_telegram/commands/__init__.py b/mautrix_telegram/commands/__init__.py index cb11c5f0..b33fe63b 100644 --- a/mautrix_telegram/commands/__init__.py +++ b/mautrix_telegram/commands/__init__.py @@ -1,5 +1,8 @@ -from .handler import (command_handler, command_handlers as _command_handlers, - CommandHandler, CommandProcessor, CommandEvent, - SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS, - SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN) -from . import portal, telegram, clean_rooms, matrix_auth, meta +from .handler import (command_handler, CommandHandler, CommandProcessor, CommandEvent, + SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT, + SECTION_MISC, SECTION_ADMIN) +from . import portal, telegram, clean_rooms, matrix_auth + +__all__ = ["command_handler", "CommandHandler", "CommandProcessor", "CommandEvent", + "SECTION_AUTH", "SECTION_MISC", "SECTION_ADMIN", "SECTION_CREATING_PORTALS", + "SECTION_PORTAL_MANAGEMENT"] diff --git a/mautrix_telegram/commands/clean_rooms.py b/mautrix_telegram/commands/clean_rooms.py index 6f584fd4..8182f746 100644 --- a/mautrix_telegram/commands/clean_rooms.py +++ b/mautrix_telegram/commands/clean_rooms.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,41 +13,41 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, List, NewType, Optional, Tuple, Union +from typing import List, NamedTuple, Tuple, Union -from mautrix_appservice import MatrixRequestError, IntentAPI +from mautrix.appservice import IntentAPI +from mautrix.errors import MatrixRequestError +from mautrix.types import RoomID, UserID, EventID -from ..types import MatrixRoomID, MatrixUserID from . import command_handler, CommandEvent, SECTION_ADMIN from .. import puppet as pu, portal as po -ManagementRoom = NewType('ManagementRoom', Tuple[MatrixRoomID, MatrixUserID]) +ManagementRoom = NamedTuple('ManagementRoom', room_id=RoomID, user_id=UserID) -async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[MatrixRoomID], +async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[RoomID], List['po.Portal'], List['po.Portal']]: - management_rooms = [] # type: List[ManagementRoom] - unidentified_rooms = [] # type: List[MatrixRoomID] - portals = [] # type: List[po.Portal] - empty_portals = [] # type: List[po.Portal] + management_rooms: List[ManagementRoom] = [] + unidentified_rooms: List[RoomID] = [] + portals: List[po.Portal] = [] + empty_portals: List[po.Portal] = [] rooms = await intent.get_joined_rooms() - for room_str in rooms: - room = MatrixRoomID(room_str) - portal = po.Portal.get_by_mxid(room) + for room_id in rooms: + portal = po.Portal.get_by_mxid(room_id) if not portal: try: - members = await intent.get_room_members(room) + members = await intent.get_room_members(room_id) except MatrixRequestError: members = [] if len(members) == 2: - other_member = MatrixUserID(members[0] if members[0] != intent.mxid else members[1]) + other_member = members[0] if members[0] != intent.mxid else members[1] if pu.Puppet.get_id_from_mxid(other_member): - unidentified_rooms.append(room) + unidentified_rooms.append(room_id) else: - management_rooms.append(ManagementRoom((room, other_member))) + management_rooms.append(ManagementRoom(room_id, other_member)) else: - unidentified_rooms.append(room) + unidentified_rooms.append(room_id) else: members = await portal.get_authenticated_matrix_users() if len(members) == 0: @@ -62,7 +61,7 @@ async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[Mat @command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms", help_section=SECTION_ADMIN, 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) reply = ["#### Management rooms (M)"] @@ -108,10 +107,10 @@ async def clean_rooms(evt: CommandEvent) -> Optional[Dict]: 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: 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": rooms_to_clean += empty_portals rooms_to_clean += unidentified_rooms @@ -160,7 +159,7 @@ async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom], "`$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": await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. " "This might take a while.") @@ -169,7 +168,7 @@ async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, Matrix if isinstance(room, po.Portal): await room.cleanup_and_delete() cleaned += 1 - elif isinstance(room, str): # str is aliased by MatrixRoomID + else: await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted") cleaned += 1 evt.sender.command_status = None diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py index fe820a56..63a6f17b 100644 --- a/mautrix_telegram/commands/handler.py +++ b/mautrix_telegram/commands/handler.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -15,23 +14,23 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """This module contains classes handling commands issued by Matrix users.""" -from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional -import logging -import traceback - -import commonmark +from typing import Awaitable, Callable, List, Optional, NamedTuple, Any from telethon.errors import FloodWaitError -from ..types import MatrixRoomID, MatrixEventID +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 .. import user as u, context as c -command_handlers = {} # type: 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_CREATING_PORTALS = HelpSection("Creating portals", 20, "") SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "") @@ -39,186 +38,42 @@ SECTION_MISC = HelpSection("Miscellaneous", 40, "") SECTION_ADMIN = HelpSection("Administration", 50, "") -class HtmlEscapingRenderer(commonmark.HtmlRenderer): - def __init__(self, allow_html: bool = False): - super().__init__() - self.allow_html = allow_html +class CommandEvent(BaseCommandEvent): + sender: u.User - def lit(self, s): - if self.allow_html: - return super().lit(s) - return super().lit(s.replace("<", "<").replace(">", ">")) - - 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: MatrixRoomID, event: MatrixEventID, + def __init__(self, processor: 'CommandProcessor', room_id: RoomID, event_id: EventID, sender: u.User, command: str, args: List[str], is_management: bool, is_portal: bool) -> None: - self.az = processor.az - self.log = processor.log - self.loop = processor.loop + super().__init__(processor, room_id, event_id, sender, command, args, is_management, + is_portal) self.tgbot = processor.tgbot self.config = processor.config 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 - ) -> Awaitable[Dict]: - """Write a reply to the room in which the command was issued. - - 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 + async def get_help_key(self) -> HelpCacheKey: + return HelpCacheKey(self.is_management, self.is_portal, self.sender.puppet_whitelisted, + self.sender.matrix_puppet_whitelisted, self.sender.is_admin, + await self.sender.is_logged_in()) -class CommandHandler: - """A command which can be executed from a Matrix room. +class CommandHandler(BaseCommandHandler): + name: str - The command manages its permission and help texts. - When called, it will check the permission of the command event and execute - the command or, in case of error, report back to the user. + management_only: bool + needs_auth: bool + needs_puppeting: bool + needs_matrix_puppeting: bool + needs_admin: bool - Attributes: - 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[Dict]], needs_auth: bool, - needs_puppeting: bool, needs_matrix_puppeting: bool, needs_admin: bool, + def __init__(self, handler: Callable[[CommandEvent], Awaitable[EventID]], management_only: bool, name: str, help_text: str, help_args: str, - help_section: HelpSection) -> None: - """ - Args: - handler: The function handling the execution of this command. - 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 + help_section: HelpSection, needs_auth: bool, needs_puppeting: bool, + needs_matrix_puppeting: bool, needs_admin: bool,) -> None: + super().__init__(handler, management_only, name, help_text, help_args, help_section, + needs_auth=needs_auth, needs_puppeting=needs_puppeting, + needs_matrix_puppeting=needs_matrix_puppeting, needs_admin=needs_admin) 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: return (f"`{evt.command}` is a restricted command: " "you may only run it in management rooms.") @@ -232,134 +87,40 @@ class CommandHandler: return "This command requires you to be logged in." return None - def has_permission(self, is_management: bool, puppet_whitelisted: bool, - matrix_puppet_whitelisted: bool, is_admin: bool, is_logged_in: bool) -> bool: - """Checks the permission for this command with the given status. - - Args: - is_management: If the room in which the command will be issued is a - 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) -> Dict: - """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 has_permission(self, key: HelpCacheKey) -> bool: + return ((not self.management_only or key.is_management) and + (not self.needs_puppeting or key.puppet_whitelisted) and + (not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted) and + (not self.needs_admin or key.is_admin) and + (not self.needs_auth or key.is_logged_in)) -def command_handler(_func: Optional[Callable[[CommandEvent], Awaitable[Dict]]] = None, *, - needs_auth: bool = True, needs_puppeting: bool = True, - needs_matrix_puppeting: bool = False, needs_admin: bool = False, - management_only: bool = False, name: Optional[str] = None, - help_text: str = "", help_args: str = "", help_section: HelpSection = None - ) -> Callable[[Callable[[CommandEvent], Awaitable[Optional[Dict]]]], - CommandHandler]: - def decorator(func: Callable[[CommandEvent], Awaitable[Optional[Dict]]]) -> CommandHandler: - actual_name = name or func.__name__.replace("_", "-") - handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting, - needs_admin, management_only, actual_name, help_text, help_args, - help_section) - command_handlers[handler.name] = handler - return handler - - return decorator if _func is None else decorator(_func) +def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: bool = True, + needs_puppeting: bool = True, needs_matrix_puppeting: bool = False, + needs_admin: bool = False, management_only: bool = False, + name: Optional[str] = None, help_text: str = "", help_args: str = "", + help_section: HelpSection = None) -> Callable[[CommandHandlerFunc], + CommandHandler]: + return base_command_handler( + _func, _handler_class=CommandHandler, name=name, help_text=help_text, help_args=help_args, + help_section=help_section, management_only=management_only, needs_auth=needs_auth, + needs_admin=needs_admin, needs_puppeting=needs_puppeting, + needs_matrix_puppeting=needs_matrix_puppeting) -class CommandProcessor: - """Handles the raw commands issued by a user to the Matrix bot.""" - log = logging.getLogger("mau.commands") - +class CommandProcessor(BaseCommandProcessor): 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.public_website = context.public_website self.command_prefix = self.config["bridge.command_prefix"] - async def handle(self, room: MatrixRoomID, event_id: MatrixEventID, sender: u.User, - command: str, args: List[str], is_management: bool, is_portal: bool - ) -> Optional[Dict]: - """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() + @staticmethod + async def _run_handler(handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent + ) -> Any: try: - handler = command_handlers[command] - 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) + return await handler(evt) except FloodWaitError as e: 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 diff --git a/mautrix_telegram/commands/matrix_auth.py b/mautrix_telegram/commands/matrix_auth.py index 5250224b..6fca9f6b 100644 --- a/mautrix_telegram/commands/matrix_auth.py +++ b/mautrix_telegram/commands/matrix_auth.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,17 +13,17 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -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 puppet as pu @command_handler(needs_auth=True, needs_matrix_puppeting=True, - help_section=SECTION_AUTH, - help_text="Revert your Telegram account's Matrix puppet to use the default Matrix " - "account.") -async def logout_matrix(evt: CommandEvent) -> Optional[Dict]: + help_section=SECTION_AUTH, help_text="Revert your Telegram account's Matrix " + "puppet to use the default Matrix account.") +async def logout_matrix(evt: CommandEvent) -> EventID: puppet = pu.Puppet.get(evt.sender.tgid) if not puppet.is_real_user: return await evt.reply("You are not logged in with your Matrix account.") @@ -36,7 +35,7 @@ async def logout_matrix(evt: CommandEvent) -> Optional[Dict]: help_section=SECTION_AUTH, help_text="Replace your Telegram account's Matrix puppet with your own Matrix " "account.") -async def login_matrix(evt: CommandEvent) -> Optional[Dict]: +async def login_matrix(evt: CommandEvent) -> EventID: puppet = pu.Puppet.get(evt.sender.tgid) if puppet.is_real_user: return await evt.reply("You have already logged in with your Matrix account. " @@ -71,31 +70,44 @@ async def login_matrix(evt: CommandEvent) -> Optional[Dict]: @command_handler(needs_auth=True, needs_matrix_puppeting=True, help_section=SECTION_AUTH, 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) if not puppet.is_real_user: return await evt.reply("You are not logged in with your Matrix account.") - resp = await puppet.init_custom_mxid() - if resp == pu.PuppetError.InvalidAccessToken: + try: + await puppet.start() + except InvalidAccessToken: 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(f"Unknown response while checking your Matrix login: {resp}.") + return await evt.reply("Your Matrix login is working.") -async def enter_matrix_token(evt: CommandEvent) -> Dict: +@command_handler(needs_auth=True, needs_matrix_puppeting=True, help_section=SECTION_AUTH, + help_text="Clear the Matrix sync token stored for your custom puppet.") +async def clear_cache_matrix(evt: CommandEvent) -> EventID: + puppet = pu.Puppet.get(evt.sender.tgid) + if not puppet.is_real_user: + return await evt.reply("You are not logged in with your Matrix account.") + try: + puppet.stop() + puppet.next_batch = None + await puppet.start() + except InvalidAccessToken: + return await evt.reply("Your access token is invalid.") + return await evt.reply("Cleared cache successfully.") + + +async def enter_matrix_token(evt: CommandEvent) -> EventID: evt.sender.command_status = None puppet = pu.Puppet.get(evt.sender.tgid) if puppet.is_real_user: return await evt.reply("You have already logged in with your Matrix account. " "Log out with `$cmdprefix+sp logout-matrix` first.") - - resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) - if resp == pu.PuppetError.OnlyLoginSelf: + try: + await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) + except OnlyLoginSelf: 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.") - assert resp == pu.PuppetError.Success, "Encountered an unhandled PuppetError." - return await evt.reply( - f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.") + return await evt.reply("Replaced your Telegram account's Matrix puppet " + f"with {puppet.custom_mxid}.") diff --git a/mautrix_telegram/commands/meta.py b/mautrix_telegram/commands/meta.py deleted file mode 100644 index 303bfd4f..00000000 --- a/mautrix_telegram/commands/meta.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: future_fstrings -*- -# 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 . -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)) diff --git a/mautrix_telegram/commands/portal/admin.py b/mautrix_telegram/commands/portal/admin.py index 376549c7..7388251b 100644 --- a/mautrix_telegram/commands/portal/admin.py +++ b/mautrix_telegram/commands/portal/admin.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,10 +13,10 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict 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 command_handler, CommandEvent, SECTION_ADMIN @@ -27,7 +26,7 @@ from .. import command_handler, CommandEvent, SECTION_ADMIN help_section=SECTION_ADMIN, help_args="<_level_> [_mxid_]", 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: level = int(evt.args[0]) except KeyError: @@ -36,20 +35,19 @@ async def set_power_level(evt: CommandEvent) -> Dict: return await evt.reply("The level must be an integer.") levels = await evt.az.intent.get_power_levels(evt.room_id) mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid - levels["users"][mxid] = level + levels.users[mxid] = level 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: evt.log.exception("Failed to set power level.") return await evt.reply("Failed to set power level.") - return {} @command_handler(needs_admin=True, needs_auth=False, help_section=SECTION_ADMIN, help_args="<`portal`|`puppet`|`user`>", help_text="Clear internal bridge caches") -async def clear_db_cache(evt: CommandEvent) -> Dict: +async def clear_db_cache(evt: CommandEvent) -> EventID: try: section = evt.args[0].lower() except IndexError: @@ -63,9 +61,8 @@ async def clear_db_cache(evt: CommandEvent) -> Dict: for puppet in pu.Puppet.by_custom_mxid.values(): puppet.sync_task.cancel() pu.Puppet.by_custom_mxid = {} - await asyncio.gather( - *[puppet.init_custom_mxid() for puppet in pu.Puppet.all_with_custom_mxid()], - loop=evt.loop) + await asyncio.gather(*[puppet.start() for puppet in pu.Puppet.all_with_custom_mxid()], + loop=evt.loop) await evt.reply("Cleared puppet cache and restarted custom puppet syncers") elif section == "user": u.User.by_mxid = { @@ -81,7 +78,7 @@ async def clear_db_cache(evt: CommandEvent) -> Dict: help_section=SECTION_ADMIN, help_args="[_mxid_]", 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: mxid = evt.args[0] else: @@ -97,5 +94,5 @@ async def reload_user(evt: CommandEvent) -> Dict: user = u.User.get_by_mxid(mxid) await user.ensure_started() if puppet: - await puppet.init_custom_mxid() - await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})") + await puppet.start() + return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})") diff --git a/mautrix_telegram/commands/portal/bridge.py b/mautrix_telegram/commands/portal/bridge.py index f160c53d..6d02b1a5 100644 --- a/mautrix_telegram/commands/portal/bridge.py +++ b/mautrix_telegram/commands/portal/bridge.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,13 +13,14 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, Optional, Tuple, Coroutine +from typing import Optional, Tuple, Coroutine import asyncio from telethon.tl.types import ChatForbidden, ChannelForbidden -from ...types import MatrixRoomID, TelegramID -from ...util import ignore_coro +from mautrix.types import EventID, RoomID + +from ...types import TelegramID from ... import portal as po from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS from .util import user_has_power_level, get_initial_state @@ -32,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 " "ID. The ID must be the prefixed version that you get with the `/id` " "command of the Telegram-side bot.") -async def bridge(evt: CommandEvent) -> Dict: +async def bridge(evt: CommandEvent) -> EventID: if len(evt.args) == 0: return await evt.reply("**Usage:** " "`$cmdprefix+sp bridge [Matrix room ID]`") @@ -40,7 +40,7 @@ async def bridge(evt: CommandEvent) -> Dict: if evt.args[0] == "--usebot" and evt.sender.is_admin: force_use_bot = True 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" portal = po.Portal.get_by_mxid(room_id) @@ -65,7 +65,7 @@ async def bridge(evt: CommandEvent) -> Dict: "Bridging private chats to existing rooms is not allowed.") portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type) - if not portal.allow_bridging(): + if not portal.allow_bridging: return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n" "If you're the bridge admin, try " "`$cmdprefix+sp filter whitelist ` first.") @@ -105,7 +105,8 @@ async def bridge(evt: CommandEvent) -> Dict: 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: await evt.reply("The portal seems to have lost its Matrix room between you" "calling `$cmdprefix+sp bridge` and this command.\n\n" @@ -128,7 +129,7 @@ async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Porta return False, None -async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]: +async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]: status = evt.sender.command_status try: portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"]) @@ -143,7 +144,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]: if not ok: return None 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...") elif portal.mxid: evt.sender.command_status = None @@ -180,8 +181,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]: portal.photo_id = "" portal.save() - ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, - levels=levels), - loop=evt.loop)) + asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels), + loop=evt.loop) return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") diff --git a/mautrix_telegram/commands/portal/config.py b/mautrix_telegram/commands/portal/config.py index 95fcfabb..2ce9c283 100644 --- a/mautrix_telegram/commands/portal/config.py +++ b/mautrix_telegram/commands/portal/config.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,10 +13,12 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, Awaitable +from typing import Awaitable 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 command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT @@ -55,7 +56,7 @@ async def config(evt: CommandEvent) -> None: portal.save() -def config_help(evt: CommandEvent) -> Awaitable[Dict]: +def config_help(evt: CommandEvent) -> Awaitable[EventID]: return evt.reply("""**Usage:** `$cmdprefix config [...]`. Subcommands: * **help** - View this help text. @@ -68,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() yaml.dump(portal.local_config, stream) 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() yaml.dump({ "bridge_notices": { @@ -90,7 +91,7 @@ def config_defaults(evt: CommandEvent) -> Awaitable[Dict]: 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: return evt.reply(f"**Usage:** `$cmdprefix+sp config set `") elif util.recursive_set(portal.local_config, key, value): @@ -100,7 +101,7 @@ def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Aw "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: return evt.reply(f"**Usage:** `$cmdprefix+sp config unset `") elif util.recursive_del(portal.local_config, key): @@ -110,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 - ) -> Awaitable[Dict]: + ) -> Awaitable[EventID]: if not key or value is None: return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") diff --git a/mautrix_telegram/commands/portal/create_chat.py b/mautrix_telegram/commands/portal/create_chat.py index 4d3eb4ff..535d7200 100644 --- a/mautrix_telegram/commands/portal/create_chat.py +++ b/mautrix_telegram/commands/portal/create_chat.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict +from mautrix.types import EventID from ... import portal as po from ...types import TelegramID @@ -27,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. " "The type is either `group`, `supergroup` or `channel` (defaults to " "`group`).") -async def create(evt: CommandEvent) -> Dict: +async def create(evt: CommandEvent) -> EventID: type = evt.args[0] if len(evt.args) > 0 else "group" if type not in {"chat", "group", "supergroup", "channel"}: return await evt.reply( diff --git a/mautrix_telegram/commands/portal/filter.py b/mautrix_telegram/commands/portal/filter.py index baa0b2ea..d02b12e4 100644 --- a/mautrix_telegram/commands/portal/filter.py +++ b/mautrix_telegram/commands/portal/filter.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, Optional +from mautrix.types import EventID from ... import portal as po from .. import command_handler, CommandEvent, SECTION_ADMIN @@ -25,7 +24,7 @@ from .. import command_handler, CommandEvent, SECTION_ADMIN help_args="<`whitelist`|`blacklist`>", help_text="Change whether the bridge will allow or disallow bridging rooms by " "default.") -async def filter_mode(evt: CommandEvent) -> Dict: +async def filter_mode(evt: CommandEvent) -> EventID: try: mode = evt.args[0] if mode not in ("whitelist", "blacklist"): @@ -50,7 +49,7 @@ async def filter_mode(evt: CommandEvent) -> Dict: help_section=SECTION_ADMIN, help_args="<`whitelist`|`blacklist`> <_chat ID_>", 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: action = evt.args[0] if action not in ("whitelist", "blacklist", "add", "remove"): @@ -92,4 +91,5 @@ async def edit_filter(evt: CommandEvent) -> Optional[Dict]: filter_id_list.remove(filter_id) save() return await evt.reply(f"Chat ID removed from {mode}.") - return None + else: + return await evt.reply("**Usage:** `$cmdprefix+sp filter `") diff --git a/mautrix_telegram/commands/portal/misc.py b/mautrix_telegram/commands/portal/misc.py index 802dec12..bc74b4d2 100644 --- a/mautrix_telegram/commands/portal/misc.py +++ b/mautrix_telegram/commands/portal/misc.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,11 +13,11 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict - from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError) +from mautrix.types import EventID + from ... import portal as po from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC from .util import user_has_power_level @@ -27,7 +26,7 @@ from .util import user_has_power_level @command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False, help_section=SECTION_MISC, 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) if not portal: return await evt.reply("This is not a portal room.") @@ -38,10 +37,31 @@ async def sync_state(evt: CommandEvent) -> Dict: await evt.reply("Synchronization complete") +@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False, + help_section=SECTION_MISC) +async def sync_full(evt: CommandEvent) -> EventID: + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + return await evt.reply("This is not a portal room.") + + if evt.args[0] == "--usebot" and evt.sender.is_admin: + src = evt.tgbot + else: + src = evt.tgbot if await evt.sender.needs_relaybot(portal) else evt.sender + + try: + entity = await src.client.get_entity(portal.peer) + except ValueError: + return await evt.reply("Failed to get portal info from Telegram.") + + await portal.update_matrix_room(src, entity) + return await evt.reply("Portal synced successfully.") + + @command_handler(name="id", needs_admin=False, needs_puppeting=False, needs_auth=False, help_section=SECTION_MISC, 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) if not portal: return await evt.reply("This is not a portal room.") @@ -55,7 +75,7 @@ async def get_id(evt: CommandEvent) -> Dict: @command_handler(help_section=SECTION_PORTAL_MANAGEMENT, 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) if not portal: return await evt.reply("This is not a portal room.") @@ -74,7 +94,7 @@ async def invite_link(evt: CommandEvent) -> Dict: @command_handler(help_section=SECTION_PORTAL_MANAGEMENT, 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) if not portal: return await evt.reply("This is not a portal room.") @@ -96,7 +116,7 @@ async def upgrade(evt: CommandEvent) -> Dict: help_args="<_name_|`-`>", help_text="Change the username of a supergroup/channel. " "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: return await evt.reply("**Usage:** `$cmdprefix+sp group-name `") diff --git a/mautrix_telegram/commands/portal/unbridge.py b/mautrix_telegram/commands/portal/unbridge.py index 9bc1e3c1..a947e7d3 100644 --- a/mautrix_telegram/commands/portal/unbridge.py +++ b/mautrix_telegram/commands/portal/unbridge.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -16,7 +15,8 @@ # along with this program. If not, see . from typing import Dict, Callable, Optional -from ...types import MatrixRoomID +from mautrix.types import RoomID, EventID + from ... import portal as po from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT from .util import user_has_power_level @@ -25,7 +25,7 @@ from .util import user_has_power_level async def _get_portal_and_check_permission(evt: CommandEvent, permission: str, action: Optional[str] = None ) -> 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) if not portal: @@ -42,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, completed_message: str) -> Dict: - async def post_confirm(confirm) -> Optional[Dict]: + async def post_confirm(confirm) -> Optional[EventID]: confirm.sender.command_status = None if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}": await function() @@ -63,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. " "Only works for group chats; to delete a private chat portal, simply " "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") if not portal: return None @@ -84,7 +84,7 @@ async def delete_portal(evt: CommandEvent) -> Optional[Dict]: @command_handler(needs_auth=False, needs_puppeting=False, help_section=SECTION_PORTAL_MANAGEMENT, 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") if not portal: return None diff --git a/mautrix_telegram/commands/portal/util.py b/mautrix_telegram/commands/portal/util.py index b0556df5..05c020a9 100644 --- a/mautrix_telegram/commands/portal/util.py +++ b/mautrix_telegram/commands/portal/util.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,43 +13,48 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -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 +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) - title = None - about = None - levels = None + +async def get_initial_state(intent: IntentAPI, room_id: RoomID + ) -> Tuple[OptStr, OptStr, Optional[PowerLevelStateEventContent]]: + state = await intent.get_state(room_id) + title: OptStr = None + about: OptStr = None + levels: Optional[PowerLevelStateEventContent] = None for event in state: try: - if event["type"] == "m.room.name": - title = event["content"]["name"] - elif event["type"] == "m.room.topic": - about = event["content"]["topic"] - elif event["type"] == "m.room.power_levels": - levels = event["content"] - elif event["type"] == "m.room.canonical_alias": - title = title or event["content"]["alias"] + if event.type == EventType.ROOM_NAME: + title = event.content.name + elif event.type == EventType.ROOM_TOPIC: + about = event.content.topic + elif event.type == EventType.ROOM_POWER_LEVELS: + levels = event.content + elif event.type == EventType.ROOM_CANONICAL_ALIAS: + title = title or event.content.canonical_alias except KeyError: # Some state event probably has empty content pass return title, about, levels -async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50 - ) -> bool: +async def user_has_power_level(room_id: RoomID, intent: IntentAPI, sender: u.User, + event: str) -> bool: if sender.is_admin: return True # Make sure the state store contains the power levels. try: - await intent.get_power_levels(room) + await intent.get_power_levels(room_id) except MatrixRequestError: return False - return intent.state_store.has_power_level(room, sender.mxid, - event=f"net.maunium.telegram.{event}", - default=default) + event_type = EventType.find(f"net.maunium.telegram.{event}") + event_type.t_class = EventType.Class.STATE + return intent.state_store.has_power_level(room_id, sender.mxid, event_type) diff --git a/mautrix_telegram/commands/telegram/account.py b/mautrix_telegram/commands/telegram/account.py index e5eaf003..68706520 100644 --- a/mautrix_telegram/commands/telegram/account.py +++ b/mautrix_telegram/commands/telegram/account.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, Optional +from typing import Optional from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError, HashInvalidError, AuthKeyError, FirstNameInvalidError) @@ -22,6 +21,8 @@ from telethon.tl.types import Authorization from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest, ResetAuthorizationRequest, UpdateProfileRequest) +from mautrix.types import EventID + from .. import command_handler, CommandEvent, SECTION_AUTH @@ -29,7 +30,7 @@ from .. import command_handler, CommandEvent, SECTION_AUTH help_section=SECTION_AUTH, help_args="<_new username_>", help_text="Change your Telegram username.") -async def username(evt: CommandEvent) -> Optional[Dict]: +async def username(evt: CommandEvent) -> EventID: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp username `") if evt.sender.is_bot: @@ -55,7 +56,7 @@ async def username(evt: CommandEvent) -> Optional[Dict]: @command_handler(needs_auth=True, help_section=SECTION_AUTH, help_args="<_new displayname_>", help_text="Change your Telegram displayname.") -async def displayname(evt: CommandEvent) -> Optional[Dict]: +async def displayname(evt: CommandEvent) -> EventID: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp displayname `") if evt.sender.is_bot: @@ -69,7 +70,7 @@ async def displayname(evt: CommandEvent) -> Optional[Dict]: except FirstNameInvalidError: return await evt.reply("Invalid first name") await evt.sender.update_info() - await evt.reply("Displayname updated") + return await evt.reply("Displayname updated") def _format_session(sess: Authorization) -> str: @@ -83,7 +84,7 @@ def _format_session(sess: Authorization) -> str: help_section=SECTION_AUTH, help_args="<`list`|`terminate`> [_hash_]", help_text="View or delete other Telegram sessions.") -async def session(evt: CommandEvent) -> Optional[Dict]: +async def session(evt: CommandEvent) -> EventID: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp session [hash]`") elif evt.sender.is_bot: diff --git a/mautrix_telegram/commands/telegram/auth.py b/mautrix_telegram/commands/telegram/auth.py index 754301c3..a47b987a 100644 --- a/mautrix_telegram/commands/telegram/auth.py +++ b/mautrix_telegram/commands/telegram/auth.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -17,21 +16,23 @@ from typing import Any, Dict, Optional import asyncio -from telethon.errors import ( +from telethon.errors import ( # isort: skip AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError, PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError, PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError, PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError) -from ... import puppet as pu, user as u +from mautrix.types import EventID + +from ... import user as u from ...commands import command_handler, CommandEvent, SECTION_AUTH -from ...util import format_duration, ignore_coro +from ...util import format_duration @command_handler(needs_auth=False, help_section=SECTION_AUTH, help_text="Check if you're logged into Telegram.") -async def ping(evt: CommandEvent) -> Optional[Dict]: +async def ping(evt: CommandEvent) -> EventID: me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None if me: human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}" @@ -43,7 +44,7 @@ async def ping(evt: CommandEvent) -> Optional[Dict]: @command_handler(needs_auth=False, needs_puppeting=False, help_section=SECTION_AUTH, help_text="Get the info of the message relay Telegram bot.") -async def ping_bot(evt: CommandEvent) -> Optional[Dict]: +async def ping_bot(evt: CommandEvent) -> EventID: if not evt.tgbot: return await evt.reply("Telegram message relay bot not configured.") info, mxid = await evt.tgbot.get_me(use_cache=False) @@ -56,7 +57,7 @@ async def ping_bot(evt: CommandEvent) -> Optional[Dict]: help_section=SECTION_AUTH, help_args="<_phone_> <_full name_>", help_text="Register to Telegram") -async def register(evt: CommandEvent) -> Optional[Dict]: +async def register(evt: CommandEvent) -> Optional[EventID]: if await evt.sender.is_logged_in(): return await evt.reply("You are already logged in.") elif len(evt.args) < 1: @@ -76,14 +77,14 @@ async def register(evt: CommandEvent) -> Optional[Dict]: return None -async def enter_code_register(evt: CommandEvent) -> Dict: +async def enter_code_register(evt: CommandEvent) -> EventID: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp `") try: await evt.sender.ensure_started(even_if_no_session=True) first_name, last_name = evt.sender.command_status["full_name"] user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name) - ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)) + asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop) evt.sender.command_status = None return await evt.reply(f"Successfully registered to Telegram.") except PhoneNumberOccupiedError: @@ -105,7 +106,7 @@ async def enter_code_register(evt: CommandEvent) -> Dict: @command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, help_text="Get instructions on how to log in.") -async def login(evt: CommandEvent) -> Optional[Dict]: +async def login(evt: CommandEvent) -> EventID: override_sender = False if len(evt.args) > 0 and evt.sender.is_admin: evt.sender = await u.User.get_by_mxid(evt.args[0]).ensure_started() @@ -142,7 +143,7 @@ async def login(evt: CommandEvent) -> Optional[Dict]: async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, Any] - ) -> Dict: + ) -> EventID: ok = False try: await evt.sender.ensure_started(even_if_no_session=True) @@ -174,7 +175,7 @@ async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[ @command_handler(needs_auth=False) -async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]: +async def enter_phone_or_token(evt: CommandEvent) -> Optional[EventID]: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token `") elif not evt.config.get("bridge.allow_matrix_login", True): @@ -198,7 +199,7 @@ async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]: @command_handler(needs_auth=False) -async def enter_code(evt: CommandEvent) -> Optional[Dict]: +async def enter_code(evt: CommandEvent) -> Optional[EventID]: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp enter-code `") elif not evt.config.get("bridge.allow_matrix_login", True): @@ -214,7 +215,7 @@ async def enter_code(evt: CommandEvent) -> Optional[Dict]: @command_handler(needs_auth=False) -async def enter_password(evt: CommandEvent) -> Optional[Dict]: +async def enter_password(evt: CommandEvent) -> Optional[EventID]: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp enter-password `") elif not evt.config.get("bridge.allow_matrix_login", True): @@ -233,7 +234,7 @@ async def enter_password(evt: CommandEvent) -> Optional[Dict]: return None -async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict: +async def _sign_in(evt: CommandEvent, **sign_in_info) -> EventID: try: await evt.sender.ensure_started(even_if_no_session=True) user = await evt.sender.client.sign_in(**sign_in_info) @@ -243,7 +244,7 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict: await evt.reply(f"[{existing_user.displayname}]" f"(https://matrix.to/#/{existing_user.mxid})" " was logged out from the account.") - ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)) + asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop) evt.sender.command_status = None name = f"@{user.username}" if user.username else f"+{user.phone}" return await evt.reply(f"Successfully logged in as {name}") @@ -265,7 +266,7 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict: @command_handler(needs_auth=True, help_section=SECTION_AUTH, help_text="Log out from Telegram.") -async def logout(evt: CommandEvent) -> Optional[Dict]: +async def logout(evt: CommandEvent) -> EventID: if await evt.sender.log_out(): return await evt.reply("Logged out successfully.") return await evt.reply("Failed to log out.") diff --git a/mautrix_telegram/commands/telegram/misc.py b/mautrix_telegram/commands/telegram/misc.py index 5555bd1c..60b12181 100644 --- a/mautrix_telegram/commands/telegram/misc.py +++ b/mautrix_telegram/commands/telegram/misc.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,13 +13,14 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, List, Optional, Tuple +from typing import List, Optional, Tuple +import logging import codecs import base64 import re from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError, - UserAlreadyParticipantError) + UserAlreadyParticipantError, ChatIdInvalidError) from telethon.tl.patched import Message from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll, TypePeer) @@ -29,6 +29,8 @@ from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatIn GetBotCallbackAnswerRequest, SendVoteRequest) from telethon.tl.functions.channels import JoinChannelRequest +from mautrix.types import EventID + from ... import puppet as pu, portal as po from ...abstract_user import AbstractUser from ...db import Message as DBMessage @@ -39,7 +41,7 @@ from ...commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CRE @command_handler(help_section=SECTION_MISC, help_args="[_-r|--remote_] <_query_>", help_text="Search your contacts or the Telegram servers for users.") -async def search(evt: CommandEvent) -> Optional[Dict]: +async def search(evt: CommandEvent) -> EventID: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] `") @@ -60,7 +62,7 @@ async def search(evt: CommandEvent) -> Optional[Dict]: "Minimum length of remote query is 5 characters.") return await evt.reply("No results 3:") - reply = [] # type: List[str] + reply: List[str] = [] if remote: reply += ["**Results from Telegram server:**", ""] else: @@ -80,7 +82,7 @@ async def search(evt: CommandEvent) -> Optional[Dict]: "either the internal user ID, the username or the phone number. " "**N.B.** The phone numbers you start chats with must already be in " "your contacts.") -async def pm(evt: CommandEvent) -> Optional[Dict]: +async def pm(evt: CommandEvent) -> EventID: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp pm `") @@ -99,7 +101,7 @@ async def pm(evt: CommandEvent) -> Optional[Dict]: f"{pu.Puppet.get_displayname(user, False)}") -async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[Dict]]: +async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[EventID]]: if arg.startswith("joinchat/"): invite_hash = arg[len("joinchat/"):] try: @@ -122,7 +124,7 @@ async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Opt @command_handler(help_section=SECTION_CREATING_PORTALS, help_args="<_link_>", help_text="Join a chat with an invite link.") -async def join(evt: CommandEvent) -> Optional[Dict]: +async def join(evt: CommandEvent) -> Optional[EventID]: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp join `") @@ -142,7 +144,11 @@ async def join(evt: CommandEvent) -> Optional[Dict]: return await evt.reply(f"Invited you to portal of {portal.title}") else: await evt.reply(f"Creating room for {chat.title}... This might take a while.") - await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid]) + try: + await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid]) + except ChatIdInvalidError as e: + logging.getLogger("mau.commands").info(updates.stringify()) + raise e return await evt.reply(f"Created room for {portal.title}") return None @@ -150,7 +156,7 @@ async def join(evt: CommandEvent) -> Optional[Dict]: @command_handler(help_section=SECTION_MISC, help_args="[`chats`|`contacts`|`me`]", help_text="Synchronize your chat portals, contacts and/or own info.") -async def sync(evt: CommandEvent) -> Optional[Dict]: +async def sync(evt: CommandEvent) -> EventID: if len(evt.args) > 0: sync_only = evt.args[0] if sync_only not in ("chats", "contacts", "me"): @@ -212,7 +218,7 @@ async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str @command_handler(help_section=SECTION_MISC, help_args="<_play ID_>", help_text="Play a Telegram game.") -async def play(evt: CommandEvent) -> Optional[Dict]: +async def play(evt: CommandEvent) -> EventID: if len(evt.args) < 1: return await evt.reply("**Usage:** `$cmdprefix+sp play `") elif not await evt.sender.is_logged_in(): @@ -232,14 +238,14 @@ async def play(evt: CommandEvent) -> Optional[Dict]: if not isinstance(game, BotCallbackAnswer): return await evt.reply("Game request response invalid") - await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n" + return await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n" f"{msg.media.game.description}") @command_handler(help_section=SECTION_MISC, help_args="<_poll ID_> <_choice number_>", help_text="Vote in a Telegram poll.") -async def vote(evt: CommandEvent) -> Optional[Dict]: +async def vote(evt: CommandEvent) -> EventID: if len(evt.args) < 1: return await evt.reply("**Usage:** `$cmdprefix+sp vote `") elif not await evt.sender.is_logged_in(): diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index 4aa165fd..eb42b33f 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,149 +13,27 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Any, Dict, Optional, Tuple -from ruamel.yaml import YAML +from typing import Any, Dict, List, NamedTuple from ruamel.yaml.comments import CommentedMap -import random -import string +import os -yaml = YAML() # type: YAML -yaml.indent(4) +from mautrix.types import UserID +from mautrix.client import Client +from mautrix.bridge.config import BaseBridgeConfig, ConfigUpdateHelper + +Permissions = NamedTuple("Permissions", relaybot=bool, user=bool, puppeting=bool, + matrix_puppeting=bool, admin=bool, level=str) -class DictWithRecursion: - def __init__(self, data: Optional[CommentedMap] = None) -> None: - self._data = data or CommentedMap() # type: CommentedMap - - @staticmethod - def _parse_key(key: str) -> Tuple[str, Optional[str]]: - if '.' not in key: - return key, None - key, next_key = key.split('.', 1) - if len(key) > 0 and key[0] == "[": - end_index = next_key.index("]") - key = key[1:] + "." + next_key[:end_index] - next_key = next_key[end_index + 2:] if len(next_key) > end_index + 1 else None - return key, next_key - - def _recursive_get(self, data: CommentedMap, key: str, default_value: Any) -> Any: - key, next_key = self._parse_key(key) - if next_key is not None: - next_data = data.get(key, CommentedMap()) - return self._recursive_get(next_data, next_key, default_value) - return data.get(key, default_value) - - def get(self, key: str, default_value: Any, allow_recursion: bool = True) -> Any: - if allow_recursion and '.' in key: - return self._recursive_get(self._data, key, default_value) - return self._data.get(key, default_value) - - def __getitem__(self, key: str) -> Any: - return self.get(key, None) - - def __contains__(self, key: str) -> bool: - return self[key] is not None - - def _recursive_set(self, data: CommentedMap, key: str, value: Any) -> None: - key, next_key = self._parse_key(key) - if next_key is not None: - if key not in data: - data[key] = CommentedMap() - next_data = data.get(key, CommentedMap()) - return self._recursive_set(next_data, next_key, value) - data[key] = value - - def set(self, key: str, value: Any, allow_recursion: bool = True) -> None: - if allow_recursion and '.' in key: - self._recursive_set(self._data, key, value) - return - self._data[key] = value - - def __setitem__(self, key: str, value: Any) -> None: - self.set(key, value) - - def _recursive_del(self, data: CommentedMap, key: str) -> None: - key, next_key = self._parse_key(key) - if next_key is not None: - if key not in data: - return - next_data = data[key] - return self._recursive_del(next_data, next_key) - try: - del data[key] - del data.ca.items[key] - except KeyError: - pass - - def delete(self, key: str, allow_recursion: bool = True) -> None: - if allow_recursion and '.' in key: - self._recursive_del(self._data, key) - return - try: - del self._data[key] - del self._data.ca.items[key] - except KeyError: - pass - - def __delitem__(self, key: str) -> None: - self.delete(key) - - -class Config(DictWithRecursion): - def __init__(self, path: str, registration_path: str, base_path: str, - overrides: Dict[str, Any] = None) -> None: - super().__init__() - self.path = path # type: str - self.registration_path = registration_path # type: str - self.base_path = base_path # type: str - self._registration = None # type: Optional[Dict] - self._overrides = overrides or {} # type: Dict[str, Any] - +class Config(BaseBridgeConfig): def __getitem__(self, key: str) -> Any: try: - return self._overrides[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"] + return os.environ[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"] except KeyError: return super().__getitem__(key) - def load(self) -> None: - with open(self.path, 'r') as stream: - self._data = yaml.load(stream) - - def load_base(self) -> Optional[DictWithRecursion]: - try: - with open(self.base_path, 'r') as stream: - return DictWithRecursion(yaml.load(stream)) - except OSError: - pass - return None - - def save(self) -> None: - with open(self.path, 'w') as stream: - yaml.dump(self._data, stream) - if self._registration and self.registration_path: - with open(self.registration_path, 'w') as stream: - yaml.dump(self._registration, stream) - - @staticmethod - def _new_token() -> str: - return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64)) - - def update(self) -> None: - base = self.load_base() - if not base: - return - - def copy(from_path, to_path=None) -> None: - if from_path in self: - base[to_path or from_path] = self[from_path] - - def copy_dict(from_path, to_path=None, override_existing_map=True) -> None: - if from_path in self: - to_path = to_path or from_path - if override_existing_map or to_path not in base: - base[to_path] = CommentedMap() - for key, value in self[from_path].items(): - base[to_path][key] = value + def do_update(self, helper: ConfigUpdateHelper) -> None: + copy, copy_dict, base = helper copy("homeserver.address") copy("homeserver.domain") @@ -202,12 +79,14 @@ class Config(DictWithRecursion): copy("bridge.displayname_template") copy("bridge.displayname_preference") + copy("bridge.displayname_max_length") copy("bridge.max_initial_member_sync") copy("bridge.sync_channel_members") copy("bridge.skip_deleted_members") copy("bridge.startup_sync") copy("bridge.sync_dialog_limit") + copy("bridge.sync_direct_chats") copy("bridge.max_telegram_delete") copy("bridge.sync_matrix_state") copy("bridge.allow_matrix_login") @@ -302,58 +181,43 @@ class Config(DictWithRecursion): else: copy("logging") - self._data = base._data - self.save() - - def _get_permissions(self, key: str) -> Tuple[bool, bool, bool, bool, bool, bool]: + def _get_permissions(self, key: str) -> Permissions: level = self["bridge.permissions"].get(key, "") admin = level == "admin" matrix_puppeting = level == "full" or admin puppeting = level == "puppeting" or matrix_puppeting user = level == "user" or puppeting relaybot = level == "relaybot" or user - return relaybot, user, puppeting, matrix_puppeting, admin, level + return Permissions(relaybot, user, puppeting, matrix_puppeting, admin, level) - def get_permissions(self, mxid: str) -> Tuple[bool, bool, bool, bool, bool, bool]: - permissions = self["bridge.permissions"] or {} + def get_permissions(self, mxid: UserID) -> Permissions: + permissions = self["bridge.permissions"] if mxid in permissions: return self._get_permissions(mxid) - homeserver = mxid[mxid.index(":") + 1:] + _, homeserver = Client.parse_user_id(mxid) if homeserver in permissions: return self._get_permissions(homeserver) return self._get_permissions("*") - def generate_registration(self) -> None: + @property + def namespaces(self) -> Dict[str, List[Dict[str, Any]]]: homeserver = self["homeserver.domain"] - username_format = self.get("bridge.username_template", "telegram_{userid}") \ - .format(userid=".+") - alias_format = self.get("bridge.alias_template", "telegram_{groupname}") \ - .format(groupname=".+") + username_format = self["bridge.username_template"].format(userid=".+") + alias_format = self["bridge.alias_template"].format(groupname=".+") + group_id = ({"group_id": self["appservice.community_id"]} + if self["appservice.community_id"] else {}) - self.set("appservice.as_token", self._new_token()) - self.set("appservice.hs_token", self._new_token()) - - self._registration = { - "id": self["appservice.id"] or "telegram", - "as_token": self["appservice.as_token"], - "hs_token": self["appservice.hs_token"], - "namespaces": { - "users": [{ - "exclusive": True, - "regex": f"@{username_format}:{homeserver}" - }], - "aliases": [{ - "exclusive": True, - "regex": f"#{alias_format}:{homeserver}" - }] - }, - "url": self["appservice.address"], - "sender_localpart": self["appservice.bot_username"], - "rate_limited": False + return { + "users": [{ + "exclusive": True, + "regex": f"@{username_format}:{homeserver}", + **group_id, + }], + "aliases": [{ + "exclusive": True, + "regex": f"#{alias_format}:{homeserver}", + }] } - if self["appservice.community_id"]: - self._registration["namespaces"]["users"][0]["group_id"] \ - = self["appservice.community_id"] diff --git a/mautrix_telegram/context.py b/mautrix_telegram/context.py index 88c332d4..4566de3f 100644 --- a/mautrix_telegram/context.py +++ b/mautrix_telegram/context.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -15,13 +14,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Optional, Tuple, TYPE_CHECKING +import asyncio + +from alchemysession import AlchemySessionContainer + +from mautrix.appservice import AppService if TYPE_CHECKING: - import asyncio - - from alchemysession import AlchemySessionContainer - from mautrix_appservice import AppService - from .web import PublicBridgeWebsite, ProvisioningAPI from .config import Config from .bot import Bot @@ -29,17 +28,26 @@ if TYPE_CHECKING: class Context: - def __init__(self, az: 'AppService', config: 'Config', loop: 'asyncio.AbstractEventLoop', - session_container: 'AlchemySessionContainer', bot: Optional['Bot']) -> None: - self.az = az # type: AppService - self.config = config # type: Config - self.loop = loop # type: asyncio.AbstractEventLoop - self.bot = bot # type: Optional[Bot] - self.mx = None # type: Optional[MatrixHandler] - self.session_container = session_container # type: AlchemySessionContainer - self.public_website = None # type: Optional[PublicBridgeWebsite] - self.provisioning_api = None # type: Optional[ProvisioningAPI] + az: AppService + config: 'Config' + loop: asyncio.AbstractEventLoop + bot: Optional['Bot'] + mx: Optional['MatrixHandler'] + session_container: AlchemySessionContainer + public_website: Optional['PublicBridgeWebsite'] + provisioning_api: Optional['ProvisioningAPI'] + + def __init__(self, az: AppService, config: 'Config', loop: asyncio.AbstractEventLoop, + session_container: AlchemySessionContainer, bot: Optional['Bot']) -> None: + self.az = az + self.config = config + self.loop = loop + self.bot = bot + self.mx = None + self.session_container = session_container + self.public_website = None + self.provisioning_api = None @property - def core(self) -> Tuple['AppService', 'Config', 'asyncio.AbstractEventLoop', Optional['Bot']]: + def core(self) -> Tuple[AppService, 'Config', asyncio.AbstractEventLoop, Optional['Bot']]: return self.az, self.config, self.loop, self.bot diff --git a/mautrix_telegram/db/__init__.py b/mautrix_telegram/db/__init__.py index 724af6a2..28106767 100644 --- a/mautrix_telegram/db/__init__.py +++ b/mautrix_telegram/db/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,18 +13,19 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from .base import Base +from sqlalchemy.engine.base import Engine + +from mautrix.bridge.db import UserProfile, RoomState + from .bot_chat import BotChat from .message import Message from .portal import Portal from .puppet import Puppet -from .room_state import RoomState from .telegram_file import TelegramFile from .user import User, UserPortal, Contact -from .user_profile import UserProfile -def init(db_engine) -> None: +def init(db_engine: Engine) -> None: for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile, RoomState, BotChat): table.db = db_engine diff --git a/mautrix_telegram/db/base.py b/mautrix_telegram/db/base.py deleted file mode 100644 index bbca82a7..00000000 --- a/mautrix_telegram/db/base.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: future_fstrings -*- -# 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 . -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 = None # type: Engine - t = None # type: Table - __table__ = None # type: Table - c = None # type: ImmutableColumnCollection - - @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) diff --git a/mautrix_telegram/db/base.pyi b/mautrix_telegram/db/base.pyi deleted file mode 100644 index 8575893d..00000000 --- a/mautrix_telegram/db/base.pyi +++ /dev/null @@ -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: ... diff --git a/mautrix_telegram/db/bot_chat.py b/mautrix_telegram/db/bot_chat.py index c7363cc5..9903a630 100644 --- a/mautrix_telegram/db/bot_chat.py +++ b/mautrix_telegram/db/bot_chat.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -17,28 +16,31 @@ from typing import Iterable from sqlalchemy import Column, Integer, String +from sqlalchemy.engine.result import RowProxy + +from mautrix.bridge.db import Base from ..types import TelegramID -from .base import Base # Fucking Telegram not telling bots what chats they are in 3:< class BotChat(Base): __tablename__ = "bot_chat" - id = Column(Integer, primary_key=True) # type: TelegramID - type = Column(String, nullable=False) + id: TelegramID = Column(Integer, primary_key=True) + type: str = Column(String, nullable=False) @classmethod - def delete(cls, chat_id: TelegramID) -> None: + def delete_by_id(cls, chat_id: TelegramID) -> None: with cls.db.begin() as conn: 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 def all(cls) -> Iterable['BotChat']: - rows = cls.db.execute(cls.t.select()) - for row in rows: - chat_id, chat_type = row - yield cls(id=chat_id, type=chat_type) + return cls._select_all() def insert(self) -> None: with self.db.begin() as conn: diff --git a/mautrix_telegram/db/message.py b/mautrix_telegram/db/message.py index 327ba508..83082718 100644 --- a/mautrix_telegram/db/message.py +++ b/mautrix_telegram/db/message.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,42 +13,35 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import Optional, Iterator + from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select from sqlalchemy.engine.result import RowProxy -from typing import Optional, List +from sqlalchemy.sql.expression import ClauseElement -from ..types import MatrixRoomID, MatrixEventID, TelegramID -from .base import Base +from mautrix.types import RoomID, EventID +from mautrix.bridge.db import Base + +from ..types import TelegramID class Message(Base): __tablename__ = "message" - mxid = Column(String) # type: MatrixEventID - mx_room = Column(String) # type: MatrixRoomID - tgid = Column(Integer, primary_key=True) # type: TelegramID - tg_space = Column(Integer, primary_key=True) # type: TelegramID - edit_index = Column(Integer, primary_key=True) # type: int + mxid: EventID = Column(String) + mx_room: RoomID = Column(String) + tgid: TelegramID = Column(Integer, primary_key=True) + tg_space: TelegramID = Column(Integer, primary_key=True) + 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_2"),) @classmethod - def _one_or_none(cls, rows: RowProxy) -> Optional['Message']: - try: - 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] + def scan(cls, row: RowProxy) -> 'Message': + return cls(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3], edit_index=row[4]) @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, cls.c.tg_space == tg_space)))) @@ -69,7 +61,7 @@ class Message(Base): return cls._one_or_none(cls.db.execute(query)) @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)]) .where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room))) try: @@ -79,7 +71,7 @@ class Message(Base): return 0 @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']: return cls._select_one_or_none(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room, @@ -95,14 +87,14 @@ class Message(Base): .values(**values)) @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: conn.execute(cls.t.update() .where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room)) .values(**values)) @property - def _edit_identity(self): + def _edit_identity(self) -> ClauseElement: return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space, self.c.edit_index == self.edit_index) diff --git a/mautrix_telegram/db/portal.py b/mautrix_telegram/db/portal.py index fd4a1ba1..c5a04846 100644 --- a/mautrix_telegram/db/portal.py +++ b/mautrix_telegram/db/portal.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,55 +13,52 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from sqlalchemy import Column, Integer, String, Boolean, Text, and_ -from sqlalchemy.engine.result import RowProxy from typing import Optional -from ..types import MatrixRoomID, TelegramID -from .base import Base +from sqlalchemy import Column, Integer, String, Boolean, Text, and_ +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): __tablename__ = "portal" # Telegram chat information - tgid = Column(Integer, primary_key=True) # type: TelegramID - tg_receiver = Column(Integer, primary_key=True) # type: TelegramID - peer_type = Column(String, nullable=False) - megagroup = Column(Boolean) + tgid: TelegramID = Column(Integer, primary_key=True) + tg_receiver: TelegramID = Column(Integer, primary_key=True) + peer_type: str = Column(String, nullable=False) + megagroup: bool = Column(Boolean) # 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 - username = Column(String, nullable=True) - title = Column(String, nullable=True) - about = Column(String, nullable=True) - photo_id = Column(String, nullable=True) + username: str = Column(String, nullable=True) + title: str = Column(String, nullable=True) + about: str = Column(String, nullable=True) + photo_id: str = Column(String, nullable=True) @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, photo_id) = row return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup, mxid=mxid, config=config, username=username, title=title, about=about, 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 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)) @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) @classmethod @@ -70,7 +66,7 @@ class Portal(Base): return cls._select_one_or_none(cls.c.username == username) @property - def _edit_identity(self): + def _edit_identity(self) -> ClauseElement: return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver) def insert(self) -> None: diff --git a/mautrix_telegram/db/puppet.py b/mautrix_telegram/db/puppet.py index 489ef672..8b3027e2 100644 --- a/mautrix_telegram/db/puppet.py +++ b/mautrix_telegram/db/puppet.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,44 +13,42 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from sqlalchemy import Column, Integer, String, Boolean -from sqlalchemy.engine.result import RowProxy -from sqlalchemy.sql import expression from typing import Optional, Iterable -from ..types import MatrixUserID, MatrixRoomID, TelegramID -from .base import Base +from sqlalchemy import Column, Integer, String, Boolean +from sqlalchemy.sql import expression +from sqlalchemy.engine.result import RowProxy +from sqlalchemy.sql.expression import ClauseElement + +from mautrix.types import UserID, SyncToken +from mautrix.bridge.db import Base + +from ..types import TelegramID class Puppet(Base): __tablename__ = "puppet" - id = Column(Integer, primary_key=True) # type: TelegramID - custom_mxid = Column(String, nullable=True) # type: Optional[MatrixUserID] - access_token = Column(String, nullable=True) - displayname = Column(String, nullable=True) - displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID] - username = Column(String, nullable=True) - photo_id = Column(String, nullable=True) - is_bot = Column(Boolean, nullable=True) - matrix_registered = Column(Boolean, nullable=False, server_default=expression.false()) - disable_updates = Column(Boolean, nullable=False, server_default=expression.false()) + id: TelegramID = Column(Integer, primary_key=True) + custom_mxid: UserID = Column(String, nullable=True) + access_token: str = Column(String, nullable=True) + next_batch: SyncToken = Column(String, nullable=True) + displayname: str = Column(String, nullable=True) + displayname_source: TelegramID = Column(Integer, nullable=True) + username: str = Column(String, nullable=True) + photo_id: str = Column(String, nullable=True) + is_bot: bool = Column(Boolean, nullable=True) + matrix_registered: bool = Column(Boolean, nullable=False, server_default=expression.false()) + disable_updates: bool = Column(Boolean, nullable=False, server_default=expression.false()) @classmethod - def scan(cls, row) -> Optional['Puppet']: - (id, custom_mxid, access_token, displayname, displayname_source, username, photo_id, - is_bot, matrix_registered, disable_updates) = row - return cls(id=id, custom_mxid=custom_mxid, access_token=access_token, - displayname=displayname, displayname_source=displayname_source, - username=username, photo_id=photo_id, is_bot=is_bot, - 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 + def scan(cls, row: RowProxy) -> Optional['Puppet']: + (id, custom_mxid, access_token, next_batch, displayname, displayname_source, username, + photo_id, is_bot, matrix_registered, disable_updates) = row + return cls(id=id, custom_mxid=custom_mxid, access_token=access_token, username=username, + next_batch=next_batch, displayname=displayname, photo_id=photo_id, + displayname_source=displayname_source, matrix_registered=matrix_registered, + disable_updates=disable_updates, is_bot=is_bot) @classmethod def all_with_custom_mxid(cls) -> Iterable['Puppet']: @@ -64,7 +61,7 @@ class Puppet(Base): return cls._select_one_or_none(cls.c.id == tgid) @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) @classmethod @@ -76,13 +73,14 @@ class Puppet(Base): return cls._select_one_or_none(cls.c.displayname == displayname) @property - def _edit_identity(self): + def _edit_identity(self) -> ClauseElement: return self.c.id == self.id def insert(self) -> None: with self.db.begin() as conn: conn.execute(self.t.insert().values( id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token, - displayname=self.displayname, displayname_source=self.displayname_source, - username=self.username, photo_id=self.photo_id, is_bot=self.is_bot, - matrix_registered=self.matrix_registered, disable_updates=self.disable_updates)) + next_batch=self.next_batch, displayname=self.displayname, username=self.username, + displayname_source=self.displayname_source, photo_id=self.photo_id, + is_bot=self.is_bot, matrix_registered=self.matrix_registered, + disable_updates=self.disable_updates)) diff --git a/mautrix_telegram/db/room_state.py b/mautrix_telegram/db/room_state.py deleted file mode 100644 index 0aa9aa84..00000000 --- a/mautrix_telegram/db/room_state.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- coding: future_fstrings -*- -# 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 . -from sqlalchemy import Column, String, Text -from typing import Dict, Optional -import json - -from ..types import MatrixRoomID -from .base import Base - - -class RoomState(Base): - __tablename__ = "mx_room_state" - - room_id = Column(String, primary_key=True) # type: MatrixRoomID - power_levels = Column("power_levels", Text, nullable=True) # type: Optional[Dict] - - @property - def _power_levels_text(self) -> Optional[str]: - return json.dumps(self.power_levels) if self.power_levels else None - - @property - def has_power_levels(self) -> bool: - return bool(self.power_levels) - - @classmethod - def get(cls, room_id: MatrixRoomID) -> Optional['RoomState']: - rows = cls.db.execute(cls.t.select().where(cls.c.room_id == room_id)) - try: - room_id, power_levels_text = next(rows) - return cls(room_id=room_id, power_levels=(json.loads(power_levels_text) - if power_levels_text else None)) - except StopIteration: - return None - - def update(self) -> None: - with self.db.begin() as conn: - conn.execute(self.t.update() - .where(self.c.room_id == self.room_id) - .values(power_levels=self._power_levels_text)) - - @property - def _edit_identity(self): - return self.c.room_id == self.room_id - - def insert(self) -> None: - with self.db.begin() as conn: - conn.execute(self.t.insert().values(room_id=self.room_id, - power_levels=self._power_levels_text)) diff --git a/mautrix_telegram/db/telegram_file.py b/mautrix_telegram/db/telegram_file.py index 4b36b10a..909bd782 100644 --- a/mautrix_telegram/db/telegram_file.py +++ b/mautrix_telegram/db/telegram_file.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,38 +13,41 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean from typing import Optional -from .base import Base +from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean +from sqlalchemy.engine.result import RowProxy + +from mautrix.types import ContentURI +from mautrix.bridge.db import Base class TelegramFile(Base): __tablename__ = "telegram_file" - id = Column(String, primary_key=True) - mxc = Column(String) - mime_type = Column(String) - was_converted = Column(Boolean) - timestamp = Column(BigInteger) - size = Column(Integer, nullable=True) - width = Column(Integer, nullable=True) - height = Column(Integer, nullable=True) - thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True) - thumbnail = None # type: Optional[TelegramFile] + id: str = Column(String, primary_key=True) + mxc: ContentURI = Column(String) + mime_type: str = Column(String) + was_converted: bool = Column(Boolean) + timestamp: int = Column(BigInteger) + size: int = Column(Integer, nullable=True) + width: int = Column(Integer, nullable=True) + height: int = Column(Integer, nullable=True) + thumbnail_id: str = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True) + thumbnail: Optional['TelegramFile'] = None + + @classmethod + 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 def get(cls, loc_id: str) -> Optional['TelegramFile']: - rows = cls.db.execute(cls.t.select().where(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 + return cls._select_one_or_none(cls.c.id == loc_id) def insert(self) -> None: with self.db.begin() as conn: diff --git a/mautrix_telegram/db/user.py b/mautrix_telegram/db/user.py index 61b39263..1580e277 100644 --- a/mautrix_telegram/db/user.py +++ b/mautrix_telegram/db/user.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,46 +13,43 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String -from sqlalchemy.engine.result import RowProxy from typing import Optional, Iterable, Tuple -from ..types import MatrixUserID, TelegramID -from .base import Base +from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String +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): __tablename__ = "user" - mxid = Column(String, primary_key=True) # type: MatrixUserID - tgid = Column(Integer, nullable=True, unique=True) # type: Optional[TelegramID] - tg_username = Column(String, nullable=True) - tg_phone = Column(String, nullable=True) - saved_contacts = Column(Integer, default=0, nullable=False) + mxid: UserID = Column(String, primary_key=True) + tgid: Optional[TelegramID] = Column(Integer, nullable=True, unique=True) + tg_username: str = Column(String, nullable=True) + tg_phone: str = Column(String, nullable=True) + saved_contacts: int = Column(Integer, default=0, nullable=False) @classmethod - def _one_or_none(cls, rows: RowProxy) -> Optional['User']: - try: - mxid, tgid, tg_username, tg_phone, saved_contacts = next(rows) - return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone, - saved_contacts=saved_contacts) - except StopIteration: - return None + def scan(cls, row: RowProxy) -> 'User': + mxid, tgid, tg_username, tg_phone, saved_contacts = row + return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone, + saved_contacts=saved_contacts) @classmethod - def all(cls) -> Iterable['User']: - rows = cls.db.execute(cls.t.select()) - 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) + def all_with_tgid(cls) -> Iterable['User']: + return cls._select_all(cls.c.tgid != None) @classmethod def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']: return cls._select_one_or_none(cls.c.tgid == tgid) @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) @classmethod @@ -61,7 +57,7 @@ class User(Base): return cls._select_one_or_none(cls.c.tg_username == username) @property - def _edit_identity(self): + def _edit_identity(self) -> ClauseElement: return self.c.mxid == self.mxid def insert(self) -> None: @@ -113,10 +109,10 @@ class User(Base): class UserPortal(Base): __tablename__ = "user_portal" - user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"), - primary_key=True) # type: TelegramID - portal = Column(Integer, primary_key=True) # type: TelegramID - portal_receiver = Column(Integer, primary_key=True) # type: TelegramID + user: TelegramID = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", + ondelete="CASCADE"), primary_key=True) + portal: TelegramID = Column(Integer, primary_key=True) + portal_receiver: TelegramID = Column(Integer, primary_key=True) __table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"), ("portal.tgid", "portal.tg_receiver"), @@ -126,5 +122,5 @@ class UserPortal(Base): class Contact(Base): __tablename__ = "contact" - user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID - contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID + user: TelegramID = Column(Integer, ForeignKey("user.tgid"), primary_key=True) + contact: TelegramID = Column(Integer, ForeignKey("puppet.id"), primary_key=True) diff --git a/mautrix_telegram/db/user_profile.py b/mautrix_telegram/db/user_profile.py deleted file mode 100644 index d09262b5..00000000 --- a/mautrix_telegram/db/user_profile.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: future_fstrings -*- -# 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 . -from sqlalchemy import Column, String, and_ -from typing import Dict, Optional - -from ..types import MatrixUserID, MatrixRoomID -from .base import Base - - -class UserProfile(Base): - __tablename__ = "mx_user_profile" - - room_id = Column(String, primary_key=True) # type: MatrixRoomID - user_id = Column(String, primary_key=True) # type: MatrixUserID - membership = Column(String, nullable=False, default="leave") - displayname = Column(String, nullable=True) - avatar_url = Column(String, nullable=True) - - def dict(self) -> Dict[str, str]: - return { - "membership": self.membership, - "displayname": self.displayname, - "avatar_url": self.avatar_url, - } - - @classmethod - def get(cls, room_id: MatrixRoomID, user_id: MatrixUserID) -> Optional['UserProfile']: - rows = cls.db.execute( - cls.t.select().where(and_(cls.c.room_id == room_id, cls.c.user_id == user_id))) - try: - room_id, user_id, membership, displayname, avatar_url = next(rows) - return cls(room_id=room_id, user_id=user_id, membership=membership, - displayname=displayname, avatar_url=avatar_url) - except StopIteration: - return None - - @classmethod - def delete_all(cls, room_id: MatrixRoomID) -> None: - with cls.db.begin() as conn: - conn.execute(cls.t.delete().where(cls.c.room_id == room_id)) - - def update(self) -> None: - super().update(membership=self.membership, displayname=self.displayname, - avatar_url=self.avatar_url) - - @property - def _edit_identity(self): - return and_(self.c.room_id == self.room_id, self.c.user_id == self.user_id) - - def insert(self) -> None: - with self.db.begin() as conn: - conn.execute(self.t.insert().values(room_id=self.room_id, user_id=self.user_id, - membership=self.membership, - displayname=self.displayname, - avatar_url=self.avatar_url)) diff --git a/mautrix_telegram/formatter/from_matrix/__init__.py b/mautrix_telegram/formatter/from_matrix/__init__.py index cfcb8ba2..4cac62b4 100644 --- a/mautrix_telegram/formatter/from_matrix/__init__.py +++ b/mautrix_telegram/formatter/from_matrix/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,29 +13,30 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING, Dict, Any +from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING import re import logging from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic, TypeMessageEntity) +from telethon.helpers import add_surrogate, del_surrogate + +from mautrix.types import RoomID, MessageEventContent from ... import puppet as pu -from ...types import TelegramID, MatrixRoomID +from ...types import TelegramID from ...db import Message as DBMessage -from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html, - trim_reply_fallback_text) from .parser import ParsedMessage, parse_html if TYPE_CHECKING: from ...context import Context -log = logging.getLogger("mau.fmt.mx") # type: logging.Logger -should_bridge_plaintext_highlights = False # type: bool +log: logging.Logger = logging.getLogger("mau.fmt.mx") +should_bridge_plaintext_highlights: bool = False -command_regex = re.compile(r"^!([A-Za-z0-9@]+)") # type: Pattern -not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") # type: Pattern -plain_mention_regex = None # type: Optional[Pattern] +command_regex: Pattern = re.compile(r"^!([A-Za-z0-9@]+)") +not_command_regex: Pattern = re.compile(r"^\\(![A-Za-z0-9@]+)") +plain_mention_regex: Optional[Pattern] = None def plain_mention_to_html(match: Match) -> str: @@ -49,17 +49,22 @@ def plain_mention_to_html(match: Match) -> str: return "".join(match.groups()) +MAX_LENGTH = 4096 +CUTOFF_TEXT = " [message cut]" +CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT) + + def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage: - if len(message) > 4096: - message = message[0:4082] + " [message cut]" + if len(message) > MAX_LENGTH: + message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT new_entities = [] for entity in entities: - if entity.offset > 4082: + if entity.offset > CUT_MAX_LENGTH: continue - if entity.offset + entity.length > 4082: - entity.length = 4082 - entity.offset + if entity.offset + entity.length > CUT_MAX_LENGTH: + entity.length = CUT_MAX_LENGTH - entity.offset new_entities.append(entity) - new_entities.append(MessageEntityItalic(4082, len(" [message cut]"))) + new_entities.append(MessageEntityItalic(CUT_MAX_LENGTH, len(CUTOFF_TEXT))) entities = new_entities return message, entities @@ -76,8 +81,8 @@ def matrix_to_telegram(html: str) -> ParsedMessage: if should_bridge_plaintext_highlights: html = plain_mention_regex.sub(plain_mention_to_html, html) - text, entities = parse_html(add_surrogates(html)) - text = remove_surrogates(text.strip()) + text, entities = parse_html(add_surrogate(html)) + text = del_surrogate(text.strip()) text, entities = cut_long_message(text, entities) return text, entities @@ -85,26 +90,12 @@ def matrix_to_telegram(html: str) -> ParsedMessage: raise FormatError(f"Failed to convert Matrix format: {html}") from e -def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID, - room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]: - relates_to = content.get("m.relates_to", None) or {} - if not relates_to: - return None - reply = (relates_to if relates_to.get("rel_type", None) == "m.reference" - else relates_to.get("m.in_reply_to", None) or {}) - if not reply: - return None - room_id = room_id or reply.get("room_id", None) - event_id = reply.get("event_id", None) +def matrix_reply_to_telegram(content: MessageEventContent, tg_space: TelegramID, + room_id: Optional[RoomID] = None) -> Optional[TelegramID]: + event_id = content.get_reply_to() if not event_id: return - - try: - if content["format"] == "org.matrix.custom.html": - content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"]) - except KeyError: - pass - content["body"] = trim_reply_fallback_text(content["body"]) + content.trim_reply_fallback() message = DBMessage.get_by_mxid(event_id, room_id, tg_space) if message: @@ -124,10 +115,10 @@ def matrix_text_to_telegram(text: str) -> ParsedMessage: return text, entities -def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]: +def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[Match], str]]: entities = [] - def replacer(match) -> str: + def replacer(match: Match) -> str: puppet = pu.Puppet.find_by_displayname(match.group(2)) if puppet: offset = match.start() @@ -148,7 +139,7 @@ def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], st def init_mx(context: "Context") -> None: global plain_mention_regex, should_bridge_plaintext_highlights config = context.config - dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)") + dn_template = config["bridge.displayname_template"] dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+") plain_mention_regex = re.compile(f"^({dn_template})") - should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False + should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] diff --git a/mautrix_telegram/formatter/from_matrix/html_reader.py b/mautrix_telegram/formatter/from_matrix/html_reader.py deleted file mode 100644 index d707537c..00000000 --- a/mautrix_telegram/formatter/from_matrix/html_reader.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: future_fstrings -*- -# 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 . -from typing import Dict, List, Tuple - -from html.parser import HTMLParser - - -class HTMLNode(list): - def __init__(self, tag: str, attrs: List[Tuple[str, str]]): - super().__init__() - self.tag = tag # type: str - self.text = "" # type: str - self.tail = "" # type: str - self.attrib = dict(attrs) # type: Dict[str, str] - - -class NodeifyingParser(HTMLParser): - # From https://www.w3.org/TR/html5/syntax.html#writing-html-documents-elements - void_tags = ("area", "base", "br", "col", "command", "embed", "hr", "img", "input", "link", - "meta", "param", "source", "track", "wbr") - - def __init__(self): - super().__init__() - self.stack = [HTMLNode("html", [])] # type: List[HTMLNode] - - def handle_starttag(self, tag, attrs): - node = HTMLNode(tag, attrs) - self.stack[-1].append(node) - if tag not in self.void_tags: - self.stack.append(node) - - def handle_startendtag(self, tag, attrs): - self.stack[-1].append(HTMLNode(tag, attrs)) - - def handle_endtag(self, tag): - if tag == self.stack[-1].tag: - self.stack.pop() - - def handle_data(self, data): - if len(self.stack[-1]) > 0: - self.stack[-1][-1].tail += data - else: - self.stack[-1].text += data - - def error(self, message): - pass - - -def read_html(data: str) -> HTMLNode: - parser = NodeifyingParser() - parser.feed(data) - return parser.stack[0] diff --git a/mautrix_telegram/formatter/from_matrix/html_reader.pyi b/mautrix_telegram/formatter/from_matrix/html_reader.pyi deleted file mode 100644 index d292ff3c..00000000 --- a/mautrix_telegram/formatter/from_matrix/html_reader.pyi +++ /dev/null @@ -1,11 +0,0 @@ -from typing import Dict, List - - -class HTMLNode(List['HTMLNode']): - tag: str - text: str - tail: str - attrib: Dict[str, str] - - -def read_html(data: str) -> HTMLNode: ... diff --git a/mautrix_telegram/formatter/from_matrix/parser.py b/mautrix_telegram/formatter/from_matrix/parser.py index ad019098..fdcf5aac 100644 --- a/mautrix_telegram/formatter/from_matrix/parser.py +++ b/mautrix_telegram/formatter/from_matrix/parser.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,240 +13,77 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import List, Tuple, Pattern -import re +from typing import List, Tuple, Optional -from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command, - MessageEntityMentionName as MentionName, MessageEntityUrl as URL, - MessageEntityEmail as Email, MessageEntityTextUrl as TextURL, - MessageEntityBold as Bold, MessageEntityItalic as Italic, - MessageEntityCode as Code, MessageEntityPre as Pre, - MessageEntityStrike as Strike, MessageEntityUnderline as Underline, - MessageEntityBlockquote as Blockquote, TypeMessageEntity) +from telethon.tl.types import TypeMessageEntity + +from mautrix.types import UserID, RoomID +from mautrix.util.formatter import MatrixParser as BaseMatrixParser, RecursionContext +from mautrix.util.formatter.html_reader_htmlparser import read_html, HTMLNode from ... import user as u, puppet as pu, portal as po -from ...types import MatrixUserID -from .telegram_message import TelegramMessage, Entity, offset_length_multiply +from .telegram_message import TelegramMessage, TelegramEntityType -from .html_reader import HTMLNode, read_html ParsedMessage = Tuple[str, List[TypeMessageEntity]] def parse_html(input_html: str) -> ParsedMessage: - return MatrixParser.parse(input_html) + msg = MatrixParser.parse(input_html) + return msg.text, msg.telegram_entities -class RecursionContext: - def __init__(self, strip_linebreaks: bool = True, ul_depth: int = 0): - self.strip_linebreaks = strip_linebreaks # type: bool - self.ul_depth = ul_depth # type: int - self._inited = True # type: bool - - def __setattr__(self, key, value): - if getattr(self, "_inited", False) is True: - raise TypeError("'RecursionContext' object is immutable") - super(RecursionContext, self).__setattr__(key, value) - - def enter_list(self) -> 'RecursionContext': - return RecursionContext(strip_linebreaks=self.strip_linebreaks, ul_depth=self.ul_depth + 1) - - def enter_code_block(self) -> 'RecursionContext': - return RecursionContext(strip_linebreaks=False, ul_depth=self.ul_depth) - - -class MatrixParser: - mention_regex = re.compile("https://matrix.to/#/(@.+:.+)") # type: Pattern - room_regex = re.compile("https://matrix.to/#/(#.+:.+)") # type: Pattern - block_tags = ("p", "pre", "blockquote", - "ol", "ul", "li", - "h1", "h2", "h3", "h4", "h5", "h6", - "div", "hr", "table") # type: Tuple[str, ...] - list_bullets = ("●", "○", "■", "‣") # type: Tuple[str, ...] +class MatrixParser(BaseMatrixParser[TelegramMessage]): + e = TelegramEntityType + fs = TelegramMessage + read_html = read_html @classmethod - def list_bullet(cls, depth: int) -> str: - return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " " - - @classmethod - def list_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: - ordered = node.tag == "ol" - tagged_children = cls.node_to_tagged_tmessages(node, ctx) - counter = 1 - indent_length = 0 - if ordered: - try: - counter = int(node.attrib.get("start", "1")) - except ValueError: - counter = 1 - - longest_index = counter - 1 + len(tagged_children) - indent_length = len(str(longest_index)) - indent = (indent_length + 4) * " " - children = [] # type: List[TelegramMessage] - for child, tag in tagged_children: - if tag != "li": - continue - - if ordered: - prefix = f"{counter}. " - counter += 1 - else: - prefix = cls.list_bullet(ctx.ul_depth) - child = child.prepend(prefix) - parts = child.split("\n") - parts = parts[:1] + [part.prepend(indent) for part in parts[1:]] - child = TelegramMessage.join(parts, "\n") - children.append(child) - return TelegramMessage.join(children, "\n") - - @classmethod - def header_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: - children = cls.node_to_tmessages(node, ctx) - length = int(node.tag[1]) - prefix = "#" * length + " " - return TelegramMessage.join(children, "").prepend(prefix).format(Bold) - - @classmethod - def basic_format_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: + def custom_node_to_fstring(cls, node: HTMLNode, ctx: RecursionContext + ) -> Optional[TelegramMessage]: msg = cls.tag_aware_parse_node(node, ctx) - if node.tag in ("b", "strong"): - msg.format(Bold) - elif node.tag in ("i", "em"): - msg.format(Italic) - elif node.tag in ("s", "strike", "del"): - msg.format(Strike) - elif node.tag in ("u", "ins"): - msg.format(Underline) - elif node == "blockquote": - msg.format(Blockquote) - elif node.tag == "command": - msg.format(Command) + if node.tag == "command": + msg.format(TelegramEntityType.COMMAND) + return None + @classmethod + def user_pill_to_fstring(cls, msg: TelegramMessage, user_id: UserID) -> TelegramMessage: + user = (pu.Puppet.get_by_mxid(user_id) + or u.User.get_by_mxid(user_id, create=False)) + if not user: + return msg + if user.username: + return TelegramMessage(f"@{user.username}").format(TelegramEntityType.MENTION) + elif user.tgid: + displayname = user.plain_displayname or msg.text + return TelegramMessage(displayname).format(TelegramEntityType.MENTION_NAME, + user_id=user.tgid) return msg @classmethod - def link_to_tstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: - msg = cls.tag_aware_parse_node(node, ctx) - href = node.attrib.get("href", "") - if not href: - return msg - - if href.startswith("mailto:"): - return TelegramMessage(href[len("mailto:"):]).format(Email) - - mention = cls.mention_regex.match(href) - if mention: - mxid = MatrixUserID(mention.group(1)) - user = (pu.Puppet.get_by_mxid(mxid) - or u.User.get_by_mxid(mxid, create=False)) - if not user: - return msg - if user.username: - return TelegramMessage(f"@{user.username}").format(Mention) - elif user.tgid: - displayname = user.plain_displayname or msg.text - return TelegramMessage(displayname).format(MentionName, user_id=user.tgid) - return msg - - room = cls.room_regex.match(href) - if room: - username = po.Portal.get_username_from_mx_alias(room.group(1)) - portal = po.Portal.find_by_username(username) - if portal and portal.username: - return TelegramMessage(f"@{portal.username}").format(Mention) - - return (msg.format(URL) - if msg.text == href - else msg.format(TextURL, url=href)) + def url_to_fstring(cls, msg: TelegramMessage, url: str) -> TelegramMessage: + if url == msg.text: + return msg.format(cls.e.URL) + else: + return msg.format(cls.e.INLINE_URL, url=url) @classmethod - def blockquote_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: + def room_pill_to_fstring(cls, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage: + username = po.Portal.get_username_from_mx_alias(room_id) + portal = po.Portal.find_by_username(username) + if portal and portal.username: + return TelegramMessage(f"@{portal.username}").format(TelegramEntityType.MENTION) + + @classmethod + def header_to_fstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: + children = cls.node_to_fstrings(node, ctx) + length = int(node.tag[1]) + prefix = "#" * length + " " + return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD) + + @classmethod + def blockquote_to_fstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: msg = cls.tag_aware_parse_node(node, ctx) children = msg.trim().split("\n") children = [child.prepend("> ") for child in children] return TelegramMessage.join(children, "\n") - - @classmethod - def node_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: - if node.tag == "mx-reply": - return TelegramMessage("") - elif node.tag == "ol": - return cls.list_to_tmessage(node, ctx) - elif node.tag == "ul": - return cls.list_to_tmessage(node, ctx.enter_list()) - elif node.tag in ("h1", "h2", "h3", "h4", "h5", "h6"): - return cls.header_to_tmessage(node, ctx) - elif node.tag == "br": - return TelegramMessage("\n") - elif node.tag in ("b", "strong", "i", "em", "s", "del", "u", "ins", "command"): - return cls.basic_format_to_tmessage(node, ctx) - elif node.tag == "blockquote": - # Telegram already has blockquote entities in the protocol schema, but it strips them - # server-side and none of the official clients support them. - # TODO once Telegram changes that, use the above if block for blockquotes too. - return cls.blockquote_to_tmessage(node, ctx) - elif node.tag == "a": - return cls.link_to_tstring(node, ctx) - elif node.tag == "p": - return cls.tag_aware_parse_node(node, ctx).append("\n") - elif node.tag == "pre": - lang = "" - try: - if node[0].tag == "code": - node = node[0] - lang = node.attrib["class"][len("language-"):] - except (IndexError, KeyError): - pass - return cls.parse_node(node, ctx.enter_code_block()).format(Pre, language=lang) - elif node.tag == "code": - return cls.parse_node(node, ctx.enter_code_block()).format(Code) - return cls.tag_aware_parse_node(node, ctx) - - @staticmethod - def text_to_tmessage(text: str, ctx: RecursionContext) -> TelegramMessage: - if ctx.strip_linebreaks: - text = text.replace("\n", "") - return TelegramMessage(text) - - @classmethod - def node_to_tagged_tmessages(cls, node: HTMLNode, ctx: RecursionContext - ) -> List[Tuple[TelegramMessage, str]]: - output = [] - - if node.text: - output.append((cls.text_to_tmessage(node.text, ctx), "text")) - for child in node: - output.append((cls.node_to_tmessage(child, ctx), child.tag)) - if child.tail: - output.append((cls.text_to_tmessage(child.tail, ctx), "text")) - return output - - @classmethod - def node_to_tmessages(cls, node: HTMLNode, ctx: RecursionContext - ) -> List[TelegramMessage]: - return [msg for (msg, tag) in cls.node_to_tagged_tmessages(node, ctx)] - - @classmethod - def tag_aware_parse_node(cls, node: HTMLNode, ctx: RecursionContext - ) -> TelegramMessage: - msgs = cls.node_to_tagged_tmessages(node, ctx) - output = TelegramMessage() - prev_was_block = False - for msg, tag in msgs: - if tag in cls.block_tags: - msg = msg.append("\n") - if not prev_was_block: - msg = msg.prepend("\n") - prev_was_block = True - output = output.append(msg) - return output.trim() - - @classmethod - def parse_node(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: - return TelegramMessage.join(cls.node_to_tmessages(node, ctx)) - - @classmethod - def parse(cls, data: str) -> ParsedMessage: - msg = cls.node_to_tmessage(read_html(f"{data}"), RecursionContext()) - return msg.text, msg.entities diff --git a/mautrix_telegram/formatter/from_matrix/telegram_message.py b/mautrix_telegram/formatter/from_matrix/telegram_message.py index dd4af9da..9ee1b94e 100644 --- a/mautrix_telegram/formatter/from_matrix/telegram_message.py +++ b/mautrix_telegram/formatter/from_matrix/telegram_message.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,145 +13,87 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Callable, List, Optional, Sequence, Type, Union +from typing import Optional, Union, Any, List, Type, Dict +from enum import Enum -from telethon.tl.types import (MessageEntityMentionName as MentionName, - MessageEntityTextUrl as TextURL, MessageEntityPre as Pre, - TypeMessageEntity, InputMessageEntityMentionName as InputMentionName) +from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command, + MessageEntityMentionName as MentionName, MessageEntityUrl as URL, + MessageEntityEmail as Email, MessageEntityTextUrl as TextURL, + MessageEntityBold as Bold, MessageEntityItalic as Italic, + MessageEntityCode as Code, MessageEntityPre as Pre, + MessageEntityStrike as Strike, MessageEntityUnderline as Underline, + MessageEntityBlockquote as Blockquote, TypeMessageEntity, + InputMessageEntityMentionName as InputMentionName) + +from mautrix.util.formatter import EntityString, SemiAbstractEntity -class Entity: - @staticmethod - def copy(entity: TypeMessageEntity) -> Optional[TypeMessageEntity]: - if not entity: - return None - kwargs = { - "offset": entity.offset, - "length": entity.length, - } - if isinstance(entity, Pre): - kwargs["language"] = entity.language - elif isinstance(entity, TextURL): - kwargs["url"] = entity.url - elif isinstance(entity, (MentionName, InputMentionName)): - kwargs["user_id"] = entity.user_id - return entity.__class__(**kwargs) +class TelegramEntityType(Enum): + """EntityType is a Matrix formatting entity type.""" + BOLD = Bold + ITALIC = Italic + STRIKETHROUGH = Strike + UNDERLINE = Underline + URL = URL + INLINE_URL = TextURL + EMAIL = Email + PREFORMATTED = Pre + INLINE_CODE = Code + BLOCKQUOTE = Blockquote + MENTION = Mention + MENTION_NAME = MentionName + COMMAND = Command - @classmethod - def adjust(cls, entity: Union[TypeMessageEntity, List[TypeMessageEntity]], - func: Callable[[TypeMessageEntity], None] - ) -> Union[Optional[TypeMessageEntity], List[TypeMessageEntity]]: - if isinstance(entity, list): - return [Entity.adjust(element, func) for element in entity if entity] - elif not entity: - return None - entity = cls.copy(entity) - func(entity) - if entity.offset < 0: - entity.length += entity.offset - entity.offset = 0 - return entity + USER_MENTION = 1 + ROOM_MENTION = 2 + HEADER = 3 -def offset_diff(amount: int) -> Callable[[TypeMessageEntity], None]: - def func(entity: TypeMessageEntity) -> None: - entity.offset += amount +class TelegramEntity(SemiAbstractEntity): + internal: TypeMessageEntity - return func + def __init__(self, type: Union[TelegramEntityType, Type[TypeMessageEntity]], + offset: int, length: int, extra_info: Dict[str, Any]) -> None: + if isinstance(type, TelegramEntityType): + if isinstance(type.value, int): + raise ValueError(f"Can't create Entity with non-Telegram EntityType {type}") + type = type.value + self.internal = type(offset=offset, length=length, **extra_info) + + def copy(self) -> Optional['TelegramEntity']: + extra_info = {} + if isinstance(self.internal, Pre): + extra_info["language"] = self.internal.language + elif isinstance(self.internal, TextURL): + extra_info["url"] = self.internal.url + elif isinstance(self.internal, (MentionName, InputMentionName)): + extra_info["user_id"] = self.internal.user_id + return TelegramEntity(type(self.internal), offset=self.internal.offset, + length=self.internal.length, extra_info=extra_info) + + def __repr__(self) -> str: + return str(self.internal) + + @property + def offset(self) -> int: + return self.internal.offset + + @offset.setter + def offset(self, value: int) -> None: + self.internal.offset = value + + @property + def length(self) -> int: + return self.internal.length + + @length.setter + def length(self, value: int) -> None: + self.internal.length = value -def offset_length_multiply(amount: int) -> Callable[[TypeMessageEntity], None]: - def func(entity: TypeMessageEntity) -> None: - entity.offset *= amount - entity.length *= amount +class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]): + entity_class = TelegramEntity - return func - - -class TelegramMessage: - def __init__(self, text: str = "", entities: Optional[List[TypeMessageEntity]] = None) -> None: - self.text = text # type: str - self.entities = entities or [] # type: List[TypeMessageEntity] - - def offset_entities(self, offset: int) -> 'TelegramMessage': - def apply_offset(entity: TypeMessageEntity, inner_offset: int - ) -> Optional[TypeMessageEntity]: - entity = Entity.copy(entity) - entity.offset += inner_offset - if entity.offset < 0: - entity.offset = 0 - elif entity.offset > len(self.text): - return None - elif entity.offset + entity.length > len(self.text): - entity.length = len(self.text) - entity.offset - return entity - - self.entities = [apply_offset(entity, offset) for entity in self.entities if entity] - self.entities = [x for x in self.entities if x is not None] - return self - - def append(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage': - for msg in args: - if isinstance(msg, str): - msg = TelegramMessage(text=msg) - self.entities += Entity.adjust(msg.entities, offset_diff(len(self.text))) - self.text += msg.text - return self - - def prepend(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage': - for msg in args: - if isinstance(msg, str): - msg = TelegramMessage(text=msg) - self.entities = msg.entities + Entity.adjust(self.entities, offset_diff(len(msg.text))) - self.text = msg.text + self.text - return self - - def format(self, entity_type: Type[TypeMessageEntity], offset: int = None, length: int = None, - **kwargs) -> 'TelegramMessage': - self.entities.append(entity_type(offset=offset or 0, - length=length if length is not None else len(self.text), - **kwargs)) - return self - - def concat(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage': - return TelegramMessage().append(self, *args) - - def trim(self) -> 'TelegramMessage': - orig_len = len(self.text) - self.text = self.text.lstrip() - diff = orig_len - len(self.text) - self.text = self.text.rstrip() - self.offset_entities(-diff) - return self - - def split(self, separator, max_items: int = 0) -> List['TelegramMessage']: - text_parts = self.text.split(separator, max_items - 1) - output = [] # type: List[TelegramMessage] - - offset = 0 - for part in text_parts: - msg = TelegramMessage(part) - for entity in self.entities: - start_in_range = len(part) > entity.offset - offset >= 0 - end_in_range = len(part) >= entity.offset - offset + entity.length > 0 - if start_in_range and end_in_range: - msg.entities.append(Entity.adjust(entity, offset_diff(-offset))) - output.append(msg) - - offset += len(part) - offset += len(separator) - - return output - - @staticmethod - def join(items: Sequence[Union[str, 'TelegramMessage']], - separator: str = " ") -> 'TelegramMessage': - main = TelegramMessage() - for msg in items: - if isinstance(msg, str): - msg = TelegramMessage(text=msg) - main.entities += Entity.adjust(msg.entities, offset_diff(len(main.text))) - main.text += msg.text + separator - if len(separator) > 0: - main.text = main.text[:-len(separator)] - return main + @property + def telegram_entities(self) -> List[TypeMessageEntity]: + return [entity.internal for entity in self.entities] diff --git a/mautrix_telegram/formatter/from_telegram.py b/mautrix_telegram/formatter/from_telegram.py index 62e9ff8a..72025775 100644 --- a/mautrix_telegram/formatter/from_telegram.py +++ b/mautrix_telegram/formatter/from_telegram.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from html import escape import logging import re @@ -23,48 +22,43 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold, MessageEntityItalic, MessageEntityCode, MessageEntityPre, MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag, - MessageEntityPhone, TypeMessageEntity, Message, PeerChannel, + MessageEntityPhone, TypeMessageEntity, PeerChannel, MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader, MessageEntityUnderline, PeerUser) +from telethon.tl.custom import Message +from telethon.helpers import add_surrogate, del_surrogate -from mautrix_appservice import MatrixRequestError -from mautrix_appservice.intent_api import IntentAPI +from mautrix.errors import MatrixRequestError +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 ..types import TelegramID from ..db import Message as DBMessage -from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html, - trim_reply_fallback_text) if TYPE_CHECKING: from ..abstract_user import AbstractUser -log = logging.getLogger("mau.fmt.tg") # type: logging.Logger +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: space = (evt.to_id.channel_id if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel) else source.tgid) - msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space) + msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space) if msg: - return { - "m.in_reply_to": { - "event_id": msg.mxid, - "room_id": msg.mx_room, - }, - "rel_type": "m.reference", - "event_id": msg.mxid, - "room_id": msg.mx_room, - } - return {} + return RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid) + return None -async def _add_forward_header(source, text: str, html: Optional[str], - fwd_from: MessageFwdHeader) -> Tuple[str, str]: - if not html: - html = escape(text) +async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventContent, + fwd_from: MessageFwdHeader) -> None: + if not content.formatted_body or content.format != Format.HTML: + content.format = Format.HTML + content.formatted_body = escape(content.body) fwd_from_html, fwd_from_text = None, None if fwd_from.from_id: user = u.User.get_by_tgid(TelegramID(fwd_from.from_id)) @@ -81,11 +75,14 @@ async def _add_forward_header(source, text: str, html: Optional[str], f"{escape(fwd_from_text)}") if not fwd_from_text: - user = await source.client.get_entity(PeerUser(fwd_from.from_id)) - if user: - fwd_from_text = pu.Puppet.get_displayname(user, False) - fwd_from_html = f"{escape(fwd_from_text)}" - else: + try: + user = await source.client.get_entity(PeerUser(fwd_from.from_id)) + if user: + fwd_from_text = pu.Puppet.get_displayname(user, False) + fwd_from_html = f"{escape(fwd_from_text)}" + except ValueError: + fwd_from_text = fwd_from_html = "unknown user" + elif fwd_from.channel_id: portal = po.Portal.get_by_tgid(TelegramID(fwd_from.channel_id)) if portal: fwd_from_text = portal.title @@ -93,78 +90,48 @@ async def _add_forward_header(source, text: str, html: Optional[str], fwd_from_html = (f"" f"{escape(fwd_from_text)}") else: - fwd_from_html = f"{escape(fwd_from_text)}" + fwd_from_html = f"channel {escape(fwd_from_text)}" else: - channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id)) - if channel: - fwd_from_text = channel.title - fwd_from_html = f"{fwd_from_text}" + try: + channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id)) + if channel: + fwd_from_text = f"channel {channel.title}" + fwd_from_html = f"channel {escape(channel.title)}" + except ValueError: + fwd_from_text = fwd_from_html = "unknown channel" + elif fwd_from.from_name: + fwd_from_text = fwd_from.from_name + fwd_from_html = f"{escape(fwd_from.from_name)}" + else: + fwd_from_text = "unknown source" + fwd_from_html = f"unknown source" - if not fwd_from_text: - if fwd_from.from_id: - fwd_from_text = "Unknown user" - else: - fwd_from_text = "Unknown source" - fwd_from_html = f"{fwd_from_text}" - - text = "\n".join([f"> {line}" for line in text.split("\n")]) - text = f"Forwarded from {fwd_from_text}:\n{text}" - html = (f"Forwarded message from {fwd_from_html}
" - f"
{html}
") - return text, html + content.body = "\n".join([f"> {line}" for line in content.body.split("\n")]) + content.body = f"Forwarded from {fwd_from_text}:\n{content.body}" + content.formatted_body = ( + f"Forwarded message from {fwd_from_html}
" + f"
{content.formatted_body}
") -async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message, - relates_to: Dict, main_intent: IntentAPI) -> Tuple[str, str]: +async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventContent, evt: Message, + main_intent: IntentAPI): space = (evt.to_id.channel_id if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel) else source.tgid) - msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space) + msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space) if not msg: - return text, html + return - relates_to["rel_type"] = "m.reference" - 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, - } + content.relates_to = RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid) try: - event = await main_intent.get_event(msg.mx_room, msg.mxid) - - content = event["content"] - r_sender = event["sender"] - - r_text_body = trim_reply_fallback_text(content["body"]) - r_html_body = trim_reply_fallback_html(content["formatted_body"] - if "formatted_body" in content - else escape(content["body"])) - - puppet = pu.Puppet.get_by_mxid(r_sender, create=False) - r_displayname = puppet.displayname if puppet else r_sender - r_sender_link = f"{escape(r_displayname)}" - except (ValueError, KeyError, MatrixRequestError): - r_sender_link = "unknown user" - r_displayname = "unknown user" - r_text_body = "Failed to fetch message" - r_html_body = "Failed to fetch message" - - r_msg_link = f"In reply to" - html = ( - f"
{r_msg_link} {r_sender_link}\n{r_html_body}
" - + (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 + event: MessageEvent = await main_intent.get_event(msg.mx_room, msg.mxid) + if isinstance(event.content, TextMessageEventContent): + event.content.trim_reply_fallback() + content.set_reply(event) + except MatrixRequestError: + pass async def telegram_to_matrix(evt: Message, source: "AbstractUser", @@ -172,33 +139,42 @@ async def telegram_to_matrix(evt: Message, source: "AbstractUser", prefix_text: Optional[str] = None, prefix_html: Optional[str] = None, override_text: str = None, override_entities: List[TypeMessageEntity] = None, - no_reply_fallback: bool = False) -> Tuple[str, str, Dict]: - text = add_surrogates(override_text or evt.message) + no_reply_fallback: bool = False) -> TextMessageEventContent: + content = TextMessageEventContent( + msgtype=MessageType.TEXT, + body=add_surrogate(override_text or evt.message), + ) entities = override_entities or evt.entities - html = _telegram_entities_to_matrix_catch(text, entities) if entities else None - relates_to = {} # type: Dict + if entities: + content.format = Format.HTML + content.formatted_body = _telegram_entities_to_matrix_catch(content.body, entities) 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: - text = prefix_text + text + content.body = prefix_text + content.body 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: - 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 not html: - html = escape(text) - text += f"\n- {evt.post_author}" - html += f"
- {evt.post_author}" + if not content.formatted_body: + content.formatted_body = escape(content.body) + content.body += f"\n- {evt.post_author}" + content.formatted_body += f"
- {evt.post_author}" - if html: - html = html.replace("\n", "
") + content.body = del_surrogate(content.body) - return remove_surrogates(text), remove_surrogates(html), relates_to + if content.formatted_body: + content.formatted_body = del_surrogate(content.formatted_body.replace("\n", "
")) + + return content def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str: @@ -313,8 +289,8 @@ def _parse_name_mention(html: List[str], entity_text: str, user_id: TelegramID) return False -message_link_regex = re.compile( - r"https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})") +message_link_regex = re.compile(r"https?://t(?:elegram)?\.(?:me|dog)/" + r"([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})") def _parse_url(html: List[str], entity_text: str, url: str) -> bool: diff --git a/mautrix_telegram/formatter/util.py b/mautrix_telegram/formatter/util.py deleted file mode 100644 index 4ac01284..00000000 --- a/mautrix_telegram/formatter/util.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: future_fstrings -*- -# 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 . -from typing import Optional, Pattern -from html import escape -import struct -import re - - -# add_surrogates and remove_surrogates are unicode surrogate utility functions from Telethon. -# Licensed under the MIT license. -# https://github.com/LonamiWebs/Telethon/blob/7cce7aa3e4c6c7019a55530391b1761d33e5a04e/telethon/helpers.py -def add_surrogates(text: Optional[str]) -> Optional[str]: - if text is None: - return None - return "".join("".join(chr(y) for y in struct.unpack(" Optional[str]: - if text is None: - return None - 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 = re.compile("^" - r"[\s\S]+?" - "") # type: Pattern - - -def trim_reply_fallback_html(html: str) -> str: - return html_reply_fallback_regex.sub("", html) diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 03c10af2..68ba0b03 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,58 +13,56 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, List, Match, Optional, Set, Tuple, TYPE_CHECKING -import logging -import asyncio -import time -import re +from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING -from mautrix_appservice import MatrixRequestError, IntentError +from mautrix.bridge import BaseMatrixHandler +from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType, + ReceiptEventContent, PresenceEvent, PresenceState, TypingEvent, + MessageEvent, StateEvent, RedactionEvent, RoomNameStateEventContent, + RoomAvatarStateEventContent, RoomTopicStateEventContent, + MemberStateEventContent) +from mautrix.errors import MatrixError -from .types import MatrixEvent, MatrixEventID, MatrixRoomID, MatrixUserID from . import user as u, portal as po, puppet as pu, commands as com if TYPE_CHECKING: from .context import Context + from .bot import Bot try: from prometheus_client import Histogram - EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events", - ["event_type"]) + EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events", ["event_type"]) except ImportError: Histogram = None EVENT_TIME = None +RoomMetaStateEventContent = Union[RoomNameStateEventContent, RoomAvatarStateEventContent, + RoomTopicStateEventContent] -class MatrixHandler: - log = logging.getLogger("mau.mx") # type: logging.Logger + +class MatrixHandler(BaseMatrixHandler): + bot: 'Bot' + commands: 'com.CommandProcessor' + previously_typing: Dict[RoomID, Set[UserID]] def __init__(self, context: 'Context') -> None: - self.az, self.config, _, self.tgbot = context.core - self.commands = com.CommandProcessor(context) # type: com.CommandProcessor - self.previously_typing = [] # type: List[MatrixUserID] + super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop, + command_processor=com.CommandProcessor(context)) + self.bot = context.bot + self.previously_typing = {} - self.az.matrix_event_handler(self.handle_event) + async def get_user(self, user_id: UserID) -> 'u.User': + return await u.User.get_by_mxid(user_id).ensure_started() - async def init_as_bot(self) -> None: - displayname = self.config["appservice.bot_displayname"] - if displayname: - try: - await self.az.intent.set_display_name( - displayname if displayname != "remove" else "") - except asyncio.TimeoutError: - self.log.exception("TimeoutError when trying to set displayname") + async def get_portal(self, room_id: RoomID) -> 'po.Portal': + return po.Portal.get_by_mxid(room_id) - avatar = self.config["appservice.bot_avatar"] - if avatar: - try: - await self.az.intent.set_avatar(avatar if avatar != "remove" else "") - except asyncio.TimeoutError: - self.log.exception("TimeoutError when trying to set avatar") + async def get_puppet(self, user_id: UserID) -> 'pu.Puppet': + return pu.Puppet.get_by_mxid(user_id) - async def handle_puppet_invite(self, room_id: MatrixRoomID, puppet: pu.Puppet, inviter: u.User - ) -> None: + async def handle_puppet_invite(self, room_id: RoomID, puppet: pu.Puppet, inviter: u.User, + event_id: EventID) -> None: intent = puppet.default_mxid_intent self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room_id}") if not await inviter.is_logged_in(): @@ -83,7 +80,7 @@ class MatrixHandler: return try: members = await self.az.intent.get_room_members(room_id) - except MatrixRequestError: + except MatrixError: members = [] if self.az.bot_mxid not in members: if len(members) > 1: @@ -95,18 +92,16 @@ class MatrixHandler: await intent.join_room(room_id) portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user") - # TODO: if portal is None: if portal.mxid: try: - await intent.invite(portal.mxid, inviter.mxid) - await intent.send_notice(room_id, text=None, html=( - "You already have a private chat with me: " - f"" - "Link to room" - "")) + await intent.invite_user(portal.mxid, inviter.mxid) + await intent.send_notice( + room_id, text=f"You already have a private chat with me: {portal.mxid}", + html=("You already have a private chat with me: " + f"Link to room")) await intent.leave_room(room_id) return - except MatrixRequestError: + except MatrixError: pass portal.mxid = room_id portal.save() @@ -117,67 +112,25 @@ class MatrixHandler: await intent.send_notice(room_id, "This puppet will remain inactive until a " "Telegram chat is created for this room.") - async def accept_bot_invite(self, room_id: MatrixRoomID, inviter: u.User) -> None: - tries = 0 - while tries < 5: - try: - await self.az.intent.join_room(room_id) - break - except (IntentError, MatrixRequestError): - tries += 1 - wait_for_seconds = (tries + 1) * 10 - if tries < 5: - self.log.exception(f"Failed to join room {room_id} with bridge bot, " - f"retrying in {wait_for_seconds} seconds...") - await asyncio.sleep(wait_for_seconds) - else: - self.log.exception("Failed to join room {room}, giving up.") - return - - if not inviter.whitelisted: - await self.az.intent.send_notice( - room_id, - text="You are not whitelisted to use this bridge.\n\n" - "If you are the owner of this bridge, see the " - "`bridge.permissions` section in your config file.", - html="

You are not whitelisted to use this bridge.

" - "

If you are the owner of this bridge, see the " - "bridge.permissions section in your config file.

") - await self.az.intent.leave_room(room_id) - + async def send_welcome_message(self, room_id: RoomID, inviter: 'u.User') -> None: try: is_management = len(await self.az.intent.get_room_members(room_id)) == 2 - except MatrixRequestError: - is_management = False + except MatrixError: + # The AS bot is not in the room. + return cmd_prefix = self.commands.command_prefix text = html = "Hello, I'm a Telegram bridge bot. " if is_management and inviter.puppet_whitelisted and not await inviter.is_logged_in(): text += f"Use `{cmd_prefix} help` for help or `{cmd_prefix} login` to log in." html += (f"Use {cmd_prefix} help for help" f" or {cmd_prefix} login to log in.") - pass else: text += f"Use `{cmd_prefix} help` for help." html += f"Use {cmd_prefix} help for help." await self.az.intent.send_notice(room_id, text=text, html=html) - async def handle_invite(self, room_id: MatrixRoomID, user_id: MatrixUserID, - inviter_mxid: MatrixUserID) -> None: - self.log.debug(f"{inviter_mxid} invited {user_id} to {room_id}") - inviter = u.User.get_by_mxid(inviter_mxid) - if inviter is None: - self.log.exception("Failed to find user with Matrix ID {inviter_mxid}") - await inviter.ensure_started() - if user_id == self.az.bot_mxid: - return await self.accept_bot_invite(room_id, inviter) - elif not inviter.whitelisted: - return - - puppet = pu.Puppet.get_by_mxid(user_id) - if puppet: - await self.handle_puppet_invite(room_id, puppet, inviter) - return - + async def handle_invite(self, room_id: RoomID, user_id: UserID, inviter: 'u.User', + event_id: EventID) -> None: user = u.User.get_by_mxid(user_id, create=False) if not user: return @@ -187,10 +140,7 @@ class MatrixHandler: await portal.invite_telegram(inviter, user) return - # The rest can probably be ignored - - async def handle_join(self, room_id: MatrixRoomID, user_id: MatrixUserID, - event_id: MatrixEventID) -> None: + async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: user = await u.User.get_by_mxid(user_id).ensure_started() portal = po.Portal.get_by_mxid(room_id) @@ -198,24 +148,24 @@ class MatrixHandler: return if not user.relaybot_whitelisted: - await portal.main_intent.kick(room_id, user.mxid, - "You are not whitelisted on this Telegram bridge.") + await portal.main_intent.kick_user(room_id, user.mxid, + "You are not whitelisted on this Telegram bridge.") return elif not await user.is_logged_in() and not portal.has_bot: - await portal.main_intent.kick(room_id, user.mxid, - "This chat does not have a bot relaying " - "messages for unauthenticated users.") + await portal.main_intent.kick_user(room_id, user.mxid, + "This chat does not have a bot relaying " + "messages for unauthenticated users.") return self.log.debug(f"{user} joined {room_id}") if await user.is_logged_in() or portal.has_bot: await portal.join_matrix(user, event_id) - async def handle_part(self, room_id: MatrixRoomID, user_id: MatrixUserID, - sender_mxid: MatrixUserID, event_id: MatrixEventID) -> None: + async def handle_raw_leave(self, room_id: RoomID, user_id: UserID, sender_id: UserID, + reason: str, event_id: EventID) -> None: self.log.debug(f"{user_id} left {room_id}") - sender = u.User.get_by_mxid(sender_mxid, create=False) + sender = u.User.get_by_mxid(sender_id, create=False) if not sender: return await sender.ensure_started() @@ -226,98 +176,67 @@ class MatrixHandler: puppet = pu.Puppet.get_by_mxid(user_id) if puppet: - if sender: - await portal.kick_matrix(puppet, sender) + await portal.kick_matrix(puppet, sender) return user = u.User.get_by_mxid(user_id, create=False) if not user: return await user.ensure_started() - if await user.is_logged_in() or portal.has_bot: - await portal.leave_matrix(user, sender, event_id) - - def is_command(self, message: Dict) -> Tuple[bool, str]: - text = message.get("body", "") - prefix = self.config["bridge.command_prefix"] - is_command = text.startswith(prefix) - if is_command: - text = text[len(prefix) + 1:].lstrip() - return is_command, text - - async def handle_message(self, room: MatrixRoomID, sender_id: MatrixUserID, message: Dict, - event_id: MatrixEventID) -> None: - is_command, text = self.is_command(message) - sender = await u.User.get_by_mxid(sender_id).ensure_started() - if not sender.relaybot_whitelisted: - self.log.debug(f"Ignoring message \"{message}\" from {sender} to {room}:" - " User is not whitelisted.") - return - self.log.debug(f"Received Matrix event \"{message}\" from {sender} in {room}") - - portal = po.Portal.get_by_mxid(room) - if not is_command and portal and (await sender.is_logged_in() or portal.has_bot): - await portal.handle_matrix_message(sender, message, event_id) - return - - if not sender.whitelisted or message.get("msgtype", "m.unknown") != "m.text": - return - - try: - is_management = len(await self.az.intent.get_room_members(room)) == 2 - except MatrixRequestError: - # The AS bot is not in the room. - return - - if is_command or is_management: - try: - command, arguments = text.split(" ", 1) - args = arguments.split(" ") - except ValueError: - # Not enough values to unpack, i.e. no arguments - command = text - args = [] - await self.commands.handle(room, event_id, sender, command, args, is_management, - is_portal=portal is not None) + if sender_id != user_id: + await portal.kick_matrix(user, sender) + else: + await portal.leave_matrix(user, event_id) @staticmethod - async def handle_redaction(room_id: MatrixRoomID, sender_mxid: MatrixUserID, - event_id: MatrixEventID) -> None: - sender = await u.User.get_by_mxid(sender_mxid).ensure_started() + async def allow_message(user: 'u.User') -> bool: + return user.relaybot_whitelisted + + @staticmethod + async def allow_command(user: 'u.User') -> bool: + return user.whitelisted + + @staticmethod + async def allow_bridging_message(user: 'u.User', portal: 'po.Portal') -> bool: + return await user.is_logged_in() or portal.has_bot + + @staticmethod + async def handle_redaction(evt: RedactionEvent) -> None: + sender = await u.User.get_by_mxid(evt.sender).ensure_started() if not sender.relaybot_whitelisted: return - portal = po.Portal.get_by_mxid(room_id) + portal = po.Portal.get_by_mxid(evt.room_id) if not portal: return - await portal.handle_matrix_deletion(sender, event_id) + await portal.handle_matrix_deletion(sender, evt.redacts) @staticmethod - async def handle_power_levels(room_id: MatrixRoomID, sender_mxid: MatrixUserID, - new: Dict, old: Dict) -> None: - portal = po.Portal.get_by_mxid(room_id) - sender = await u.User.get_by_mxid(sender_mxid).ensure_started() + async def handle_power_levels(evt: StateEvent) -> None: + portal = po.Portal.get_by_mxid(evt.room_id) + sender = await u.User.get_by_mxid(evt.sender).ensure_started() if await sender.has_full_access(allow_bot=True) and portal: - await portal.handle_matrix_power_levels(sender, new["users"], old["users"]) + await portal.handle_matrix_power_levels(sender, evt.content.users, + evt.unsigned.prev_content.users) @staticmethod - async def handle_room_meta(evt_type: str, room_id: MatrixRoomID, sender_mxid: MatrixUserID, - content: dict) -> None: + async def handle_room_meta(evt_type: EventType, room_id: RoomID, sender_mxid: UserID, + content: RoomMetaStateEventContent) -> None: portal = po.Portal.get_by_mxid(room_id) sender = await u.User.get_by_mxid(sender_mxid).ensure_started() if await sender.has_full_access(allow_bot=True) and portal: handler, content_key = { - "m.room.name": (portal.handle_matrix_title, "name"), - "m.room.topic": (portal.handle_matrix_about, "topic"), - "m.room.avatar": (portal.handle_matrix_avatar, "url"), + EventType.ROOM_NAME: (portal.handle_matrix_title, "name"), + EventType.ROOM_TOPIC: (portal.handle_matrix_about, "topic"), + EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, "url"), }[evt_type] if content_key not in content: return await handler(sender, content[content_key]) @staticmethod - async def handle_room_pin(room_id: MatrixRoomID, sender_mxid: MatrixUserID, + async def handle_room_pin(room_id: RoomID, sender_mxid: UserID, new_events: Set[str], old_events: Set[str]) -> None: portal = po.Portal.get_by_mxid(room_id) sender = await u.User.get_by_mxid(sender_mxid).ensure_started() @@ -325,62 +244,69 @@ class MatrixHandler: events = new_events - old_events if len(events) > 0: # New event pinned, set that as pinned in Telegram. - await portal.handle_matrix_pin(sender, MatrixEventID(events.pop())) + await portal.handle_matrix_pin(sender, EventID(events.pop())) elif len(new_events) == 0: # All pinned events removed, remove pinned event in Telegram. await portal.handle_matrix_pin(sender, None) @staticmethod - async def handle_room_upgrade(room_id: MatrixRoomID, new_room_id: MatrixRoomID) -> None: + async def handle_room_upgrade(room_id: RoomID, new_room_id: RoomID) -> None: portal = po.Portal.get_by_mxid(room_id) if portal: await portal.handle_matrix_upgrade(new_room_id) - @staticmethod - async def handle_name_change(room_id: MatrixRoomID, user_id: MatrixUserID, displayname: str, - prev_displayname: str, event_id: MatrixEventID) -> None: + async def handle_member_info_change(self, room_id: RoomID, user_id: UserID, + profile: MemberStateEventContent, + prev_profile: MemberStateEventContent, + event_id: EventID) -> None: + if profile.displayname == prev_profile.displayname: + return + portal = po.Portal.get_by_mxid(room_id) if not portal or not portal.has_bot: return user = await u.User.get_by_mxid(user_id).ensure_started() if await user.needs_relaybot(portal): - await portal.name_change_matrix(user, displayname, prev_displayname, event_id) + await portal.name_change_matrix(user, profile.displayname, prev_profile.displayname, + event_id) @staticmethod - def parse_read_receipts(content: Dict) -> Dict[MatrixUserID, MatrixEventID]: - return {user_id: event_id + def parse_read_receipts(content: ReceiptEventContent) -> Iterable[Tuple[UserID, EventID]]: + return ((user_id, event_id) for event_id, receipts in content.items() - for user_id in receipts.get("m.read", {})} + for user_id in receipts.get(ReceiptType.READ, {})) @staticmethod - async def handle_read_receipts(room_id: MatrixRoomID, - receipts: Dict[MatrixUserID, MatrixEventID]) -> None: + async def handle_read_receipts(room_id: RoomID, receipts: Iterable[Tuple[UserID, EventID]] + ) -> None: portal = po.Portal.get_by_mxid(room_id) if not portal: return - for user_id, event_id in receipts.items(): + for user_id, event_id in receipts: user = await u.User.get_by_mxid(user_id).ensure_started() if not await user.is_logged_in(): continue await portal.mark_read(user, event_id) @staticmethod - async def handle_presence(user_id: MatrixUserID, presence: str) -> None: + async def handle_presence(user_id: UserID, presence: PresenceState) -> None: user = await u.User.get_by_mxid(user_id).ensure_started() if not await user.is_logged_in(): return - await user.set_presence(presence == "online") + await user.set_presence(presence == PresenceState.ONLINE) - async def handle_typing(self, room_id: MatrixRoomID, now_typing: List[MatrixUserID]) -> None: + async def handle_typing(self, room_id: RoomID, now_typing: Set[UserID]) -> None: portal = po.Portal.get_by_mxid(room_id) if not portal: return - for user_id in set(self.previously_typing + now_typing): + previously_typing = self.previously_typing.get(room_id, set()) + + for user_id in set(previously_typing | now_typing): is_typing = user_id in now_typing - was_typing = user_id in self.previously_typing + was_typing = user_id in previously_typing if is_typing and was_typing: continue @@ -390,88 +316,46 @@ class MatrixHandler: await portal.set_typing(user, is_typing) - self.previously_typing = now_typing + self.previously_typing[room_id] = now_typing - def filter_matrix_event(self, event: MatrixEvent) -> bool: - sender = event.get("sender", None) - if not sender: - return False - return (sender == self.az.bot_mxid - or pu.Puppet.get_id_from_mxid(sender) is not None) + def filter_matrix_event(self, evt: Event) -> bool: + if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent)): + return True + return evt.sender and (evt.sender == self.az.bot_mxid + or pu.Puppet.get_id_from_mxid(evt.sender) is not None) - async def try_handle_ephemeral_event(self, evt: MatrixEvent) -> None: - try: - await self.handle_ephemeral_event(evt) - except Exception: - self.log.exception("Error handling manually received Matrix event") + async def handle_ephemeral_event(self, evt: Union[ReceiptEvent, PresenceEvent, TypingEvent] + ) -> None: + if evt.type == EventType.RECEIPT: + await self.handle_read_receipts(evt.room_id, self.parse_read_receipts(evt.content)) + elif evt.type == EventType.PRESENCE: + await self.handle_presence(evt.sender, evt.content.presence) + elif evt.type == EventType.TYPING: + await self.handle_typing(evt.room_id, set(evt.content.user_ids)) - async def handle_ephemeral_event(self, evt: MatrixEvent) -> None: - evt_type = evt.get("type", "m.unknown") # type: str - room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID] - sender = evt.get("sender", None) # type: Optional[MatrixUserID] - content = evt.get("content", {}) # type: Dict - if evt_type == "m.receipt": - await self.handle_read_receipts(room_id, self.parse_read_receipts(content)) - elif evt_type == "m.presence": - await self.handle_presence(sender, content.get("presence", "offline")) - elif evt_type == "m.typing": - await self.handle_typing(room_id, content.get("user_ids", [])) + async def handle_event(self, evt: Event) -> None: + if evt.type == EventType.ROOM_REDACTION: + await self.handle_redaction(evt) - async def handle_event(self, evt: MatrixEvent) -> None: - if self.filter_matrix_event(evt): - return - start_time = time.time() - self.log.debug("Received event: %s", evt) - evt_type = evt.get("type", "m.unknown") # type: str - room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID] - event_id = evt.get("event_id", None) # type: Optional[MatrixEventID] - sender = evt.get("sender", None) # type: Optional[MatrixUserID] - state_key = evt.get("state_key", None) - content = evt.get("content", {}) # type: Dict - if state_key is not None: - if evt_type == "m.room.member": - prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: Dict - membership = content.get("membership", "") # type: str - prev_membership = prev_content.get("membership", "leave") # type: str - if membership == prev_membership: - match = re.compile("@(.+):(.+)").match(state_key) # type: Match - mxid = match.group(0) # type: str - displayname = content.get("displayname", None) or mxid # type: str - prev_displayname = prev_content.get("displayname", None) or mxid # type: str - if displayname != prev_displayname: - await self.handle_name_change(room_id, state_key, displayname, - prev_displayname, event_id) - elif membership == "invite": - await self.handle_invite(room_id, state_key, sender) - elif prev_membership == "join" and membership == "leave": - await self.handle_part(room_id, state_key, sender, event_id) - elif membership == "join": - await self.handle_join(room_id, state_key, event_id) - elif evt_type == "m.room.power_levels": - prev_content = evt.get("unsigned", {}).get("prev_content", {}) - await self.handle_power_levels(room_id, sender, evt["content"], prev_content) - elif evt_type in ("m.room.name", "m.room.avatar", "m.room.topic"): - await self.handle_room_meta(evt_type, room_id, sender, evt["content"]) - elif evt_type == "m.room.pinned_events": - new_events = set(evt["content"]["pinned"]) - try: - old_events = set(evt["unsigned"]["prev_content"]["pinned"]) - except KeyError: - old_events = set() - await self.handle_room_pin(room_id, sender, new_events, old_events) - elif evt_type == "m.room.tombstone": - await self.handle_room_upgrade(room_id, evt["content"]["replacement_room"]) - else: - return - else: - if evt_type in ("m.room.message", "m.sticker"): - if evt_type != "m.room.message": - content["msgtype"] = evt_type - await self.handle_message(room_id, sender, content, event_id) - elif evt_type == "m.room.redaction": - await self.handle_redaction(room_id, sender, evt["redacts"]) - else: - return + async def handle_state_event(self, evt: StateEvent) -> None: + if evt.type == EventType.ROOM_POWER_LEVELS: + await self.handle_power_levels(evt) + elif evt.type in (EventType.ROOM_NAME, EventType.ROOM_AVATAR, EventType.ROOM_TOPIC): + await self.handle_room_meta(evt.type, evt.room_id, evt.sender, evt.content) + elif evt.type == EventType.ROOM_PINNED_EVENTS: + new_events = set(evt.content.pinned) + try: + old_events = set(evt.unsigned.prev_content.pinned) + except (KeyError, ValueError, TypeError, AttributeError): + old_events = set() + await self.handle_room_pin(evt.room_id, evt.sender, new_events, old_events) + elif evt.type == EventType.ROOM_TOMBSTONE: + await self.handle_room_upgrade(evt.room_id, evt.content.replacement_room) - if EVENT_TIME: - EVENT_TIME.labels(event_type=evt_type).observe(time.time() - start_time) + # async def handle_event(self, evt: MatrixEvent) -> None: + # if self.filter_matrix_event(evt): + # return + # start_time = time.time() + # + # if EVENT_TIME: + # EVENT_TIME.labels(event_type=evt_type).observe(time.time() - start_time) diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py deleted file mode 100644 index 2be1c05f..00000000 --- a/mautrix_telegram/portal.py +++ /dev/null @@ -1,2171 +0,0 @@ -# -*- coding: future_fstrings -*- -# 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 . -from typing import Awaitable, Dict, List, Optional, Pattern, Tuple, Union, cast, TYPE_CHECKING, Any -from collections import deque -from datetime import datetime -from string import Template -from html import escape as escape_html -import asyncio -import random -import mimetypes -import codecs -import unicodedata -import base64 -import hashlib -import logging -import json -import re - -import magic -from sqlalchemy.exc import IntegrityError - -from telethon.tl.functions.messages import ( - AddChatUserRequest, CreateChatRequest, DeleteChatUserRequest, EditChatAdminRequest, - EditChatPhotoRequest, EditChatTitleRequest, ExportChatInviteRequest, GetFullChatRequest, - UpdatePinnedMessageRequest, MigrateChatRequest, SetTypingRequest, EditChatAboutRequest) -from telethon.tl.functions.channels import ( - CreateChannelRequest, EditAdminRequest, EditBannedRequest, EditPhotoRequest, EditTitleRequest, - GetParticipantsRequest, InviteToChannelRequest, JoinChannelRequest, LeaveChannelRequest, - UpdateUsernameRequest) -from telethon.tl.functions.messages import ReadHistoryRequest as ReadMessageHistoryRequest -from telethon.tl.functions.channels import ReadHistoryRequest as ReadChannelHistoryRequest -from telethon.errors import (ChatAdminRequiredError, ChatNotModifiedError, PhotoExtInvalidError, - PhotoInvalidDimensionsError, PhotoSaveFileInvalidError) -from telethon.tl.patched import Message, MessageService -from telethon.tl.types import ( - Channel, ChatAdminRights, ChatBannedRights, ChannelFull, ChannelParticipantAdmin, Document, - ChannelParticipantCreator, ChannelParticipantsRecent, ChannelParticipantsSearch, Chat, - ChatFull, ChatInviteEmpty, ChatParticipantAdmin, ChatParticipantCreator, ChatPhoto, Poll, - DocumentAttributeFilename, DocumentAttributeImageSize, DocumentAttributeSticker, PhotoEmpty, - DocumentAttributeVideo, GeoPoint, InputChannel, InputChatUploadedPhoto, InputPhotoFileLocation, - InputPeerChannel, InputPeerChat, InputPeerUser, InputUser, InputUserSelf, MessageMediaPoll, - MessageActionChannelCreate, MessageActionChatAddUser, MessageActionChatCreate, ChatPhotoEmpty, - MessageActionChatDeletePhoto, MessageActionChatDeleteUser, MessageActionChatEditPhoto, - MessageActionChatEditTitle, MessageActionChatJoinedByLink, MessageActionChatMigrateTo, - MessageActionPinMessage, MessageActionGameScore, MessageMediaContact, MessageMediaDocument, - MessageMediaGeo, MessageMediaPhoto, MessageMediaUnsupported, MessageMediaGame, - PeerChannel, PeerChat, PeerUser, Photo, PhotoCachedSize, SendMessageCancelAction, - SendMessageTypingAction, TypeChannelParticipant, TypeChat, TypeChatParticipant, - TypeDocumentAttribute, TypeInputPeer, TypeMessageAction, TypeMessageEntity, TypePeer, - TypePhotoSize, TypeUpdates, TypeUser, PhotoSize, TypeUserFull, UpdateChatUserTyping, - UpdateNewChannelMessage, UpdateNewMessage, UpdateUserTyping, User, UserFull, MessageEntityPre, - InputMediaUploadedDocument, InputPeerPhotoFileLocation) -from mautrix_appservice import MatrixRequestError, IntentError, AppService, IntentAPI - -from .types import MatrixEventID, MatrixRoomID, MatrixUserID, TelegramID -from .context import Context -from .db import Portal as DBPortal, Message as DBMessage, TelegramFile as DBTelegramFile -from .util import ignore_coro, sane_mimetypes -from . import puppet as p, user as u, formatter, util - -if TYPE_CHECKING: - from .bot import Bot - from .abstract_user import AbstractUser - from .config import Config - from .tgclient import MautrixTelegramClient - -config = None # type: Config - -TypeMessage = Union[Message, MessageService] -TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant] -DedupMXID = Tuple[MatrixEventID, TelegramID] -InviteList = Union[MatrixUserID, List[MatrixUserID]] - - -class Portal: - base_log = logging.getLogger("mau.portal") # type: logging.Logger - az = None # type: AppService - bot = None # type: Bot - loop = None # type: asyncio.AbstractEventLoop - - # Config cache - filter_mode = None # type: str - filter_list = None # type: List[str] - - public_portals = False # type: bool - max_initial_member_sync = -1 # type: int - sync_channel_members = True # type: bool - sync_matrix_state = True # type: bool - - dedup_pre_db_check = False # type: bool - dedup_cache_queue_length = 20 # type: int - - alias_template = None # type: str - mx_alias_regex = None # type: Pattern - hs_domain = None # type: str - - # Instance cache - by_mxid = {} # type: Dict[MatrixRoomID, Portal] - by_tgid = {} # type: Dict[Tuple[TelegramID, TelegramID], Portal] - - def __init__(self, tgid: TelegramID, peer_type: str, tg_receiver: Optional[TelegramID] = None, - mxid: Optional[MatrixRoomID] = None, username: Optional[str] = None, - megagroup: Optional[bool] = False, title: Optional[str] = None, - about: Optional[str] = None, photo_id: Optional[str] = None, - local_config: Optional[str] = None, db_instance: DBPortal = None) -> None: - self.mxid = mxid # type: Optional[MatrixRoomID] - self.tgid = tgid # type: TelegramID - self.tg_receiver = tg_receiver or tgid # type: TelegramID - self.peer_type = peer_type # type: str - self.username = username # type: str - self.megagroup = megagroup # type: bool - self.title = title # type: Optional[str] - self.about = about # type: str - self.photo_id = photo_id # type: str - self.local_config = json.loads(local_config or "{}") # type: Dict[str, Any] - self._db_instance = db_instance # type: DBPortal - self.deleted = False # type: bool - self.log = self.base_log.getChild(self.tgid_log) if self.tgid else self.base_log - - self._main_intent = None # type: IntentAPI - self._room_create_lock = asyncio.Lock() # type: asyncio.Lock - self._temp_pinned_message_id = None # type: Optional[int] - self._temp_pinned_message_id_space = None # type: Optional[TelegramID] - self._temp_pinned_message_sender = None # type: Optional[p.Puppet] - - self._dedup = deque() # type: deque - self._dedup_mxid = {} # type: Dict[str, DedupMXID] - self._dedup_action = deque() # type: deque - - self._send_locks = {} # type: Dict[int, asyncio.Lock] - - if tgid: - self.by_tgid[self.tgid_full] = self - if mxid: - self.by_mxid[mxid] = self - - # region Propegrties - - @property - def tgid_full(self) -> Tuple[TelegramID, TelegramID]: - return self.tgid, self.tg_receiver - - @property - def tgid_log(self) -> str: - if self.tgid == self.tg_receiver: - return str(self.tgid) - return f"{self.tg_receiver}<->{self.tgid}" - - @property - def peer(self) -> TypePeer: - if self.peer_type == "user": - return PeerUser(user_id=self.tgid) - elif self.peer_type == "chat": - return PeerChat(chat_id=self.tgid) - elif self.peer_type == "channel": - return PeerChannel(channel_id=self.tgid) - - @property - def has_bot(self) -> bool: - return bool(self.bot and self.bot.is_in_chat(self.tgid)) - - @property - def main_intent(self) -> IntentAPI: - if not self._main_intent: - direct = self.peer_type == "user" - puppet = p.Puppet.get(self.tgid) if direct else None - self._main_intent = puppet.intent if direct else self.az.intent - return self._main_intent - - # endregion - # region Filtering - - def allow_bridging(self, tgid: Optional[TelegramID] = None) -> bool: - tgid = tgid or self.tgid - if self.peer_type == "user": - return True - elif self.filter_mode == "whitelist": - return tgid in self.filter_list - elif self.filter_mode == "blacklist": - return tgid not in self.filter_list - return True - - # endregion - # region Permission checks - - async def can_user_perform(self, user: 'u.User', event: str, default: int = 50) -> bool: - if user.is_admin: - return True - if not self.mxid: - # No room for anybody to perform actions in - return False - try: - await self.main_intent.get_power_levels(self.mxid) - except MatrixRequestError: - return False - return self.main_intent.state_store.has_power_level( - self.mxid, user.mxid, - event=f"net.maunium.telegram.{event}", - default=default) - - # endregion - # region Deduplication - - @staticmethod - def _hash_event(event: TypeMessage) -> str: - # Non-channel messages are unique per-user (wtf telegram), so we have no other choice than - # to deduplicate based on a hash of the message content. - - # The timestamp is only accurate to the second, so we can't rely solely on that either. - if isinstance(event, MessageService): - hash_content = [event.date.timestamp(), event.from_id, event.action] - else: - hash_content = [event.date.timestamp(), event.message] - if event.fwd_from: - hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id] - elif isinstance(event, Message) and event.media: - try: - hash_content += { - MessageMediaContact: lambda media: [media.user_id], - MessageMediaDocument: lambda media: [media.document.id], - MessageMediaPhoto: lambda media: [media.photo.id], - MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat], - }[type(event.media)](event.media) - except KeyError: - pass - return hashlib.md5("-" - .join(str(a) for a in hash_content) - .encode("utf-8") - ).hexdigest() - - def is_duplicate_action(self, event: TypeMessage) -> bool: - evt_hash = self._hash_event(event) if self.peer_type != "channel" else event.id - if evt_hash in self._dedup_action: - return True - - self._dedup_action.append(evt_hash) - - if len(self._dedup_action) > self.dedup_cache_queue_length: - self._dedup_action.popleft() - return False - - def update_duplicate(self, event: TypeMessage, mxid: DedupMXID = None, - expected_mxid: Optional[DedupMXID] = None, force_hash: bool = False - ) -> Optional[DedupMXID]: - evt_hash = self._hash_event( - event) if self.peer_type != "channel" or force_hash else event.id - try: - found_mxid = self._dedup_mxid[evt_hash] - except KeyError: - return MatrixEventID("None"), TelegramID(0) - - if found_mxid != expected_mxid: - return found_mxid - self._dedup_mxid[evt_hash] = mxid - return None - - def is_duplicate(self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False - ) -> Optional[DedupMXID]: - evt_hash = (self._hash_event(event) - if self.peer_type != "channel" or force_hash - else event.id) - if evt_hash in self._dedup: - return self._dedup_mxid[evt_hash] - - self._dedup_mxid[evt_hash] = mxid - self._dedup.append(evt_hash) - - if len(self._dedup) > self.dedup_cache_queue_length: - del self._dedup_mxid[self._dedup.popleft()] - return None - - def get_input_entity(self, user: 'AbstractUser') -> Awaitable[TypeInputPeer]: - return user.client.get_input_entity(self.peer) - - async def get_entity(self, user: 'AbstractUser') -> TypeChat: - try: - return await user.client.get_entity(self.peer) - except ValueError: - if user.is_bot: - self.log.warning(f"Could not find entity with bot {user.tgid}. " - "Failing...") - raise - self.log.warning(f"Could not find entity with user {user.tgid}. " - "falling back to get_dialogs.") - async for dialog in user.client.iter_dialogs(): - if dialog.entity.id == self.tgid: - return dialog.entity - raise - - # endregion - # region Matrix room info updating - - async def invite_to_matrix(self, users: InviteList) -> None: - if isinstance(users, str): - await self.main_intent.invite(self.mxid, users, check_cache=True) - elif isinstance(users, list): - for user in users: - await self.main_intent.invite(self.mxid, user, check_cache=True) - else: - raise ValueError("Invalid invite identifier given to invite_matrix()") - - async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User], - direct: bool, puppet: p.Puppet = None, levels: Dict = None, - users: List[User] = None, - participants: List[TypeParticipant] = None) -> None: - if not direct: - await self.update_info(user, entity) - if not users or not participants: - users, participants = await self._get_users(user, entity) - await self.sync_telegram_users(user, users) - await self.update_telegram_participants(participants, levels) - else: - if not puppet: - puppet = p.Puppet.get(self.tgid) - await puppet.update_info(user, entity) - await puppet.intent.join_room(self.mxid) - if self.sync_matrix_state: - await self.sync_matrix_members() - - async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None, - invites: InviteList = None, update_if_exists: bool = True, - synchronous: bool = False) -> Optional[str]: - if self.mxid: - if update_if_exists: - if not entity: - entity = await self.get_entity(user) - update = self.update_matrix_room(user, entity, self.peer_type == "user") - if synchronous: - await update - else: - ignore_coro(asyncio.ensure_future(update, loop=self.loop)) - await self.invite_to_matrix(invites or []) - return self.mxid - async with self._room_create_lock: - return await self._create_matrix_room(user, entity, invites) - - async def _create_matrix_room(self, user: 'AbstractUser', entity: TypeChat, invites: InviteList - ) -> Optional[MatrixRoomID]: - direct = self.peer_type == "user" - - if self.mxid: - return self.mxid - - if not self.allow_bridging(): - return None - - if not entity: - entity = await self.get_entity(user) - self.log.debug("Fetched data: %s", entity) - - self.log.debug(f"Creating room") - - try: - self.title = entity.title - except AttributeError: - self.title = None - - puppet = p.Puppet.get(self.tgid) if direct else None - self._main_intent = puppet.intent if direct else self.az.intent - - if self.peer_type == "channel": - self.megagroup = entity.megagroup - - if self.peer_type == "channel" and entity.username: - public = Portal.public_portals - alias = self._get_alias_localpart(entity.username) - self.username = entity.username - else: - public = False - # TODO invite link alias? - alias = None - - if alias: - # TODO? properly handle existing room aliases - await self.main_intent.remove_room_alias(alias) - - power_levels = self._get_base_power_levels({}, entity) - users = participants = None - if not direct: - users, participants = await self._get_users(user, entity) - self._participants_to_power_levels(participants, power_levels) - initial_state = [{ - "type": "m.room.power_levels", - "content": power_levels, - }] - if config["appservice.community_id"]: - initial_state.append({ - "type": "m.room.related_groups", - "content": {"groups": [config["appservice.community_id"]]}, - }) - - room_id = await self.main_intent.create_room(alias=alias, is_public=public, - is_direct=direct, invitees=invites or [], - name=self.title, initial_state=initial_state) - if not room_id: - raise Exception(f"Failed to create room") - - self.mxid = MatrixRoomID(room_id) - self.by_mxid[self.mxid] = self - self.save() - self.az.state_store.set_power_levels(self.mxid, power_levels) - user.register_portal(self) - ignore_coro(asyncio.ensure_future(self.update_matrix_room(user, entity, direct, puppet, - levels=power_levels, users=users, - participants=participants), - loop=self.loop)) - - return self.mxid - - def _get_base_power_levels(self, levels: dict = None, entity: TypeChat = None) -> dict: - levels = levels or {} - if self.peer_type == "user": - levels["ban"] = 100 - levels["kick"] = 100 - levels["invite"] = 100 - levels.setdefault("events", {}) - levels["events"]["m.room.name"] = 0 - levels["events"]["m.room.avatar"] = 0 - levels["events"]["m.room.topic"] = 0 - levels["state_default"] = 0 - levels["users_default"] = 0 - levels["events_default"] = 0 - else: - dbr = entity.default_banned_rights - if not dbr: - self.log.debug(f"default_banned_rights is None in {entity}") - dbr = ChatBannedRights(invite_users=True, change_info=True, pin_messages=True, - send_stickers=False, send_messages=False, until_date=0) - levels["ban"] = 99 - levels["kick"] = 50 - levels["invite"] = 50 if dbr.invite_users else 0 - levels.setdefault("events", {}) - levels["events"]["m.room.name"] = 50 if dbr.change_info else 0 - levels["events"]["m.room.avatar"] = 50 if dbr.change_info else 0 - levels["events"]["m.room.topic"] = 50 if dbr.change_info else 0 - levels["events"][ - "m.room.pinned_events"] = 50 if dbr.pin_messages else 0 - levels["events"]["m.room.power_levels"] = 75 - levels["events"]["m.room.history_visibility"] = 75 - levels["state_default"] = 50 - levels["users_default"] = 0 - levels["events_default"] = (50 if (self.peer_type == "channel" and not entity.megagroup - or entity.default_banned_rights.send_messages) - else 0) - levels["events"]["m.sticker"] = 50 if dbr.send_stickers else levels["events_default"] - if "users" not in levels: - levels["users"] = { - self.main_intent.mxid: 100 - } - else: - levels["users"][self.main_intent.mxid] = 100 - return levels - - @property - def alias(self) -> Optional[str]: - if not self.username: - return None - return f"#{self._get_alias_localpart()}:{self.hs_domain}" - - def _get_alias_localpart(self, username: Optional[str] = None) -> Optional[str]: - username = username or self.username - if not username: - return None - return self.alias_template.format(groupname=username) - - def add_bot_chat(self, bot: User) -> None: - if self.bot and bot.id == self.bot.tgid: - self.bot.add_chat(self.tgid, self.peer_type) - return - - user = u.User.get_by_tgid(TelegramID(bot.id)) - if user and user.is_bot: - user.register_portal(self) - - async def sync_telegram_users(self, source: 'AbstractUser', users: List[User]) -> None: - allowed_tgids = set() - skip_deleted = config["bridge.skip_deleted_members"] - for entity in users: - if skip_deleted and entity.deleted: - continue - puppet = p.Puppet.get(TelegramID(entity.id)) - if entity.bot: - self.add_bot_chat(entity) - allowed_tgids.add(entity.id) - await puppet.intent.ensure_joined(self.mxid) - await puppet.update_info(source, entity) - - user = u.User.get_by_tgid(TelegramID(entity.id)) - if user: - await self.invite_to_matrix(user.mxid) - - # We can't trust the member list if any of the following cases is true: - # * There are close to 10 000 users, because Telegram might not be sending all members. - # * The member sync count is limited, because then we might ignore some members. - # * It's a channel, because non-admins don't have access to the member list. - trust_member_list = (len(allowed_tgids) < 9900 - and Portal.max_initial_member_sync == -1 - and (self.megagroup or self.peer_type != "channel")) - if trust_member_list: - joined_mxids = cast(List[MatrixUserID], - await self.main_intent.get_room_members(self.mxid)) - for user_mxid in joined_mxids: - if user_mxid == self.az.bot_mxid: - continue - puppet_id = p.Puppet.get_id_from_mxid(user_mxid) - if puppet_id and puppet_id not in allowed_tgids: - if self.bot and puppet_id == self.bot.tgid: - self.bot.remove_chat(self.tgid) - await self.main_intent.kick(self.mxid, user_mxid, - "User had left this Telegram chat.") - continue - 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: - mx_user.unregister_portal(self) - - if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids: - await self.main_intent.kick(self.mxid, mx_user.mxid, - "You had left this Telegram chat.") - continue - - async def add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None - ) -> None: - puppet = p.Puppet.get(user_id) - if source: - entity = await source.client.get_entity(PeerUser(user_id)) # type: User - await puppet.update_info(source, entity) - await puppet.intent.join_room(self.mxid) - - user = u.User.get_by_tgid(user_id) - if user: - user.register_portal(self) - await self.invite_to_matrix(user.mxid) - - async def delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None: - puppet = p.Puppet.get(user_id) - user = u.User.get_by_tgid(user_id) - kick_message = (f"Kicked by {sender.displayname}" - if sender and sender.tgid != puppet.tgid - else "Left Telegram chat") - if sender and sender.tgid != puppet.tgid: - await self.main_intent.kick(self.mxid, puppet.mxid, kick_message) - else: - await puppet.intent.leave_room(self.mxid) - if user: - user.unregister_portal(self) - await self.main_intent.kick(self.mxid, user.mxid, kick_message) - - async def update_info(self, user: 'AbstractUser', entity: TypeChat = None) -> None: - if self.peer_type == "user": - self.log.warning(f"Called update_info() for direct chat portal") - return - - self.log.debug(f"Updating info") - if not entity: - entity = await self.get_entity(user) - self.log.debug("Fetched data: %s", entity) - changed = False - - if self.peer_type == "channel": - changed = await self.update_username(entity.username) or changed - # TODO update about text - # changed = self.update_about(entity.about) or changed - - changed = await self.update_title(entity.title) or changed - - if isinstance(entity.photo, ChatPhoto): - changed = await self.update_avatar(user, entity.photo) or changed - - if changed: - self.save() - - async def update_username(self, username: str, save: bool = False) -> bool: - if self.username != username: - if self.username: - await self.main_intent.remove_room_alias(self._get_alias_localpart()) - self.username = username or None - if self.username: - await self.main_intent.add_room_alias(self.mxid, self._get_alias_localpart()) - if Portal.public_portals: - await self.main_intent.set_join_rule(self.mxid, "public") - else: - await self.main_intent.set_join_rule(self.mxid, "invite") - - if save: - self.save() - return True - return False - - async def update_about(self, about: str, save: bool = False) -> bool: - if self.about != about: - self.about = about - await self.main_intent.set_room_topic(self.mxid, self.about) - if save: - self.save() - return True - return False - - async def update_title(self, title: str, save: bool = False) -> bool: - if self.title != title: - self.title = title - await self.main_intent.set_room_name(self.mxid, self.title) - if save: - self.save() - return True - return False - - @staticmethod - def _get_largest_photo_size(photo: Union[Photo, Document] - ) -> Tuple[Optional[InputPhotoFileLocation], - Optional[TypePhotoSize]]: - if not photo: - return None, None - if isinstance(photo, Document) and not photo.thumbs: - return None, None - largest = max(photo.sizes if isinstance(photo, Photo) else photo.thumbs, - key=(lambda photo2: (len(photo2.bytes) - if not isinstance(photo2, PhotoSize) - else photo2.size))) - return InputPhotoFileLocation( - id=photo.id, - access_hash=photo.access_hash, - file_reference=photo.file_reference, - thumb_size=largest.type, - ), largest - - async def remove_avatar(self, _: 'AbstractUser', save: bool = False) -> None: - await self.main_intent.set_room_avatar(self.mxid, None) - self.photo_id = None - if save: - self.save() - - async def update_avatar(self, user: 'AbstractUser', - photo: Union[ChatPhoto, ChatPhotoEmpty, Photo, PhotoEmpty], - save: bool = False) -> bool: - if isinstance(photo, ChatPhoto): - loc = InputPeerPhotoFileLocation( - peer=await self.get_input_entity(user), - local_id=photo.photo_big.local_id, - volume_id=photo.photo_big.volume_id, - big=True - ) - photo_id = f"{loc.volume_id}-{loc.local_id}" - elif isinstance(photo, Photo): - loc, largest = self._get_largest_photo_size(photo) - photo_id = f"{largest.location.volume_id}-{largest.location.local_id}" - elif isinstance(photo, (ChatPhotoEmpty, PhotoEmpty)): - photo_id = "" - loc = None - else: - raise ValueError(f"Unknown photo type {type(photo)}") - if self.photo_id != photo_id: - if not photo_id: - await self.main_intent.set_room_avatar(self.mxid, "") - self.photo_id = "" - if save: - self.save() - return True - file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc) - if file: - await self.main_intent.set_room_avatar(self.mxid, file.mxc) - self.photo_id = photo_id - if save: - self.save() - return True - return False - - async def _get_users(self, user: 'AbstractUser', - entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser] - ) -> Tuple[List[TypeUser], List[TypeParticipant]]: - if self.peer_type == "chat": - chat = await user.client(GetFullChatRequest(chat_id=self.tgid)) - return chat.users, chat.full_chat.participants.participants - elif self.peer_type == "channel": - if not self.megagroup and not Portal.sync_channel_members: - return [], [] - - limit = Portal.max_initial_member_sync - if limit == 0: - return [], [] - - try: - if 0 < limit <= 200: - response = await user.client(GetParticipantsRequest( - entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0)) - return response.users, response.participants - elif limit > 200 or limit == -1: - users = [] # type: List[TypeUser] - participants = [] # type: List[TypeParticipant] - offset = 0 - remaining_quota = limit if limit > 0 else 1000000 - query = (ChannelParticipantsSearch("") if limit == -1 - else ChannelParticipantsRecent()) - while True: - if remaining_quota <= 0: - break - response = await user.client(GetParticipantsRequest( - entity, query, offset=offset, limit=min(remaining_quota, 100), hash=0)) - if not response.users: - break - participants += response.participants - users += response.users - offset += len(response.participants) - remaining_quota -= len(response.participants) - return users, participants - except ChatAdminRequiredError: - return [], [] - elif self.peer_type == "user": - return [entity], [] - return [], [] - - async def get_invite_link(self, user: 'u.User') -> str: - if self.peer_type == "user": - raise ValueError("You can't invite users to private chats.") - if self.username: - return f"https://t.me/{self.username}" - link = await user.client(ExportChatInviteRequest(peer=await self.get_input_entity(user))) - if isinstance(link, ChatInviteEmpty): - raise ValueError("Failed to get invite link.") - return link.link - - async def get_authenticated_matrix_users(self) -> List['u.User']: - try: - members = await self.main_intent.get_room_members(self.mxid) - except MatrixRequestError: - return [] - authenticated = [] # type: List[u.User] - has_bot = self.has_bot - for member_str in members: - member = MatrixUserID(member_str) - if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid: - continue - user = await u.User.get_by_mxid(member).ensure_started() # type: u.User - authenticated_through_bot = has_bot and user.relaybot_whitelisted - if authenticated_through_bot or await user.has_full_access(allow_bot=True): - authenticated.append(user) - return authenticated - - @staticmethod - async def cleanup_room(intent: IntentAPI, room_id: str, message: str = "Portal deleted", - puppets_only: bool = False) -> None: - try: - members = await intent.get_room_members(room_id) - except MatrixRequestError: - members = [] - for user in members: - puppet = p.Puppet.get_by_mxid(MatrixUserID(user), create=False) - if user != intent.mxid and (not puppets_only or puppet): - try: - if puppet: - await puppet.intent.leave_room(room_id) - else: - await intent.kick(room_id, user, message) - except (MatrixRequestError, IntentError): - pass - await intent.leave_room(room_id) - - async def unbridge(self) -> None: - await self.cleanup_room(self.main_intent, self.mxid, "Room unbridged", puppets_only=True) - self.delete() - - async def cleanup_and_delete(self) -> None: - await self.cleanup_room(self.main_intent, self.mxid) - self.delete() - - # endregion - # region Matrix event handling - - @staticmethod - def _get_file_meta(body: str, mime: str) -> str: - try: - current_extension = body[body.rindex("."):].lower() - body = body[:body.rindex(".")] - if mimetypes.types_map[current_extension] == mime: - return body + current_extension - except (ValueError, KeyError): - pass - if mime: - return f"matrix_upload{sane_mimetypes.guess_extension(mime)}" - return "" - - def get_config(self, key: str) -> Any: - local = util.recursive_get(self.local_config, key) - if local is not None: - return local - return config[f"bridge.{key}"] - - async def _get_state_change_message(self, event: str, user: 'u.User', - arguments: Optional[Dict] = None) -> Optional[Dict]: - tpl = self.get_config(f"state_event_formats.{event}") - if len(tpl) == 0: - # Empty format means they don't want the message - return None - displayname = await self.get_displayname(user) - - tpl_args = dict(mxid=user.mxid, - username=user.mxid_localpart, - displayname=escape_html(displayname)) - tpl_args = {**tpl_args, **(arguments or {})} - message = Template(tpl).safe_substitute(tpl_args) - return { - "format": "org.matrix.custom.html", - "formatted_body": message, - } - - async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str, - event_id: MatrixEventID) -> None: - async with self.require_send_lock(self.bot.tgid): - message = await self._get_state_change_message( - "name_change", user, - dict(displayname=displayname, prev_displayname=prev_displayname)) - if not message: - return - response = await self.bot.client.send_message( - self.peer, message, - parse_mode=self._matrix_event_to_entities) - space = self.tgid if self.peer_type == "channel" else self.bot.tgid - self.is_duplicate(response, (event_id, space)) - - async def get_displayname(self, user: 'u.User') -> str: - return (await self.main_intent.get_displayname(self.mxid, user.mxid) - or user.mxid) - - async def sync_matrix_members(self) -> None: - resp = await self.main_intent.get_room_joined_memberships(self.mxid) - members = resp["joined"] - for mxid, info in members.items(): - member = { - "membership": "join", - } - if "display_name" in info: - member["displayname"] = info["display_name"] - if "avatar_url" in info: - member["avatar_url"] = info["avatar_url"] - self.az.state_store.set_member(self.mxid, mxid, member) - - def set_typing(self, user: 'u.User', typing: bool = True, - action: type = SendMessageTypingAction) -> Awaitable[bool]: - return user.client(SetTypingRequest( - self.peer, action() if typing else SendMessageCancelAction())) - - async def mark_read(self, user: 'u.User', event_id: MatrixEventID) -> None: - if user.is_bot: - return - space = self.tgid if self.peer_type == "channel" else user.tgid - message = DBMessage.get_by_mxid(event_id, self.mxid, space) - if not message: - return - if self.peer_type == "channel": - await user.client(ReadChannelHistoryRequest( - channel=await self.get_input_entity(user), max_id=message.tgid)) - else: - await user.client(ReadMessageHistoryRequest(peer=self.peer, max_id=message.tgid)) - - async def kick_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User') -> None: - if user.tgid == source.tgid: - return - if await source.needs_relaybot(self): - source = self.bot - if self.peer_type == "chat": - await source.client(DeleteChatUserRequest(chat_id=self.tgid, user_id=user.tgid)) - elif self.peer_type == "channel": - channel = await self.get_input_entity(source) - rights = ChatBannedRights(datetime.fromtimestamp(0), True) - await source.client(EditBannedRequest(channel=channel, - user_id=user.tgid, - banned_rights=rights)) - - async def leave_matrix(self, user: 'u.User', source: 'u.User', - event_id: MatrixEventID) -> None: - if await user.needs_relaybot(self): - async with self.require_send_lock(self.bot.tgid): - message = await self._get_state_change_message("leave", user) - if not message: - return - response = await self.bot.client.send_message( - self.peer, message, - parse_mode=self._matrix_event_to_entities) - space = self.tgid if self.peer_type == "channel" else self.bot.tgid - self.is_duplicate(response, (event_id, space)) - return - - if self.peer_type == "user": - await self.main_intent.leave_room(self.mxid) - self.delete() - try: - del self.by_tgid[self.tgid_full] - del self.by_mxid[self.mxid] - except KeyError: - pass - elif source and source.tgid != user.tgid: - await self.kick_matrix(user, source) - elif self.peer_type == "chat": - await user.client(DeleteChatUserRequest(chat_id=self.tgid, user_id=InputUserSelf())) - elif self.peer_type == "channel": - channel = await self.get_input_entity(user) - await user.client(LeaveChannelRequest(channel=channel)) - - async def join_matrix(self, user: 'u.User', event_id: MatrixEventID) -> None: - if await user.needs_relaybot(self): - async with self.require_send_lock(self.bot.tgid): - message = await self._get_state_change_message("join", user) - if not message: - return - response = await self.bot.client.send_message( - self.peer, message, - parse_mode=self._matrix_event_to_entities) - space = self.tgid if self.peer_type == "channel" else self.bot.tgid - self.is_duplicate(response, (event_id, space)) - return - - if self.peer_type == "channel" and not user.is_bot: - await user.client(JoinChannelRequest(channel=await self.get_input_entity(user))) - else: - # We'll just assume the user is already in the chat. - pass - - async def _apply_msg_format(self, sender: 'u.User', msgtype: str, message: Dict[str, Any] - ) -> None: - if "formatted_body" not in message: - message["format"] = "org.matrix.custom.html" - message["formatted_body"] = escape_html(message.get("body", "")).replace("\n", "
") - body = message["formatted_body"] - - tpl = (self.get_config(f"message_formats.[{msgtype}]") - or "$sender_displayname: $message") - displayname = await self.get_displayname(sender) - tpl_args = dict(sender_mxid=sender.mxid, - sender_username=sender.mxid_localpart, - sender_displayname=escape_html(displayname), - message=body) - message["formatted_body"] = Template(tpl).safe_substitute(tpl_args) - - async def _pre_process_matrix_message(self, sender: 'u.User', use_relaybot: bool, - message: Dict[str, Any]) -> None: - msgtype = message.get("msgtype", "m.text") - if msgtype == "m.emote": - await self._apply_msg_format(sender, msgtype, message) - if "m.new_content" in message: - await self._apply_msg_format(sender, msgtype, message["m.new_content"]) - message["m.new_content"]["msgtype"] = "m.text" - message["msgtype"] = "m.text" - elif use_relaybot: - await self._apply_msg_format(sender, msgtype, message) - if "m.new_content" in message: - await self._apply_msg_format(sender, msgtype, message["m.new_content"]) - - @staticmethod - def _matrix_event_to_entities(event: Dict[str, Any] - ) -> Tuple[str, Optional[List[TypeMessageEntity]]]: - try: - if event.get("format", None) == "org.matrix.custom.html": - message, entities = formatter.matrix_to_telegram(event.get("formatted_body", "")) - else: - message, entities = formatter.matrix_text_to_telegram(event.get("body", "")) - except KeyError: - message, entities = None, None - return message, entities - - def require_send_lock(self, user_id: TelegramID) -> asyncio.Lock: - if user_id is None: - raise ValueError("Required send lock for none id") - try: - return self._send_locks[user_id] - except KeyError: - self._send_locks[user_id] = asyncio.Lock() - return self._send_locks[user_id] - - def optional_send_lock(self, user_id: TelegramID) -> Optional[asyncio.Lock]: - if user_id is None: - return None - try: - return self._send_locks[user_id] - except KeyError: - return None - - async def _handle_matrix_text(self, sender_id: TelegramID, event_id: MatrixEventID, - space: TelegramID, client: 'MautrixTelegramClient', - message: Dict, reply_to: TelegramID) -> None: - lock = self.require_send_lock(sender_id) - async with lock: - lp = self.get_config("telegram_link_preview") - relates_to = message.get("m.relates_to", None) or {} - if relates_to.get("rel_type", None) == "m.replace": - orig_msg = DBMessage.get_by_mxid(relates_to.get("event_id", ""), self.mxid, space) - if orig_msg and "m.new_content" in message: - message = message["m.new_content"] - response = await client.edit_message(self.peer, orig_msg.tgid, message, - parse_mode=self._matrix_event_to_entities, - link_preview=lp) - self._add_telegram_message_to_db(event_id, space, -1, response) - return - response = await client.send_message(self.peer, message, reply_to=reply_to, - parse_mode=self._matrix_event_to_entities, - link_preview=lp) - self._add_telegram_message_to_db(event_id, space, 0, response) - - async def _handle_matrix_file(self, msgtype: str, sender_id: TelegramID, - event_id: MatrixEventID, space: TelegramID, - client: 'MautrixTelegramClient', message: dict, - reply_to: TelegramID) -> None: - file = await self.main_intent.download_file(message["url"]) - - info = message.get("info", {}) - mime = info.get("mimetype", None) - - w, h = None, None - - if msgtype == "m.sticker": - if mime != "image/gif": - mime, file, w, h = util.convert_image(file, source_mime=mime, target_type="webp") - else: - # Remove sticker description - message["mxtg_filename"] = "sticker.gif" - message["body"] = "" - elif "w" in info and "h" in info: - w, h = info["w"], info["h"] - - file_name = self._get_file_meta(message["mxtg_filename"], mime) - attributes = [DocumentAttributeFilename(file_name=file_name)] - if w and h: - attributes.append(DocumentAttributeImageSize(w, h)) - - caption = message["body"] if message["body"].lower() != file_name.lower() else None - - media = await client.upload_file_direct( - file, mime, attributes, file_name, - max_image_size=config["bridge.image_as_file_size"] * 1000 ** 2) - lock = self.require_send_lock(sender_id) - async with lock: - relates_to = message.get("m.relates_to", None) or {} - if relates_to.get("rel_type", None) == "m.replace": - orig_msg = DBMessage.get_by_mxid(relates_to.get("event_id", ""), self.mxid, space) - if orig_msg: - response = await client.edit_message(self.peer, orig_msg.tgid, - caption, file=media) - self._add_telegram_message_to_db(event_id, space, -1, response) - return - try: - response = await client.send_media(self.peer, media, reply_to=reply_to, - caption=caption) - except (PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, PhotoExtInvalidError): - media = InputMediaUploadedDocument(file=media.file, mime_type=mime, - attributes=attributes) - response = await client.send_media(self.peer, media, reply_to=reply_to, - caption=caption) - self._add_telegram_message_to_db(event_id, space, 0, response) - - async def _handle_matrix_location(self, sender_id: TelegramID, event_id: MatrixEventID, - space: TelegramID, client: 'MautrixTelegramClient', - message: Dict[str, Any], reply_to: TelegramID) -> None: - try: - lat, long = message["geo_uri"][len("geo:"):].split(",") - lat, long = float(lat), float(long) - except (KeyError, ValueError): - self.log.exception("Failed to parse location") - return None - caption, entities = self._matrix_event_to_entities(message) - media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0)) - - lock = self.require_send_lock(sender_id) - async with lock: - relates_to = message.get("m.relates_to", None) or {} - if relates_to.get("rel_type", None) == "m.replace": - orig_msg = DBMessage.get_by_mxid(relates_to.get("event_id", ""), self.mxid, space) - if orig_msg: - response = await client.edit_message(self.peer, orig_msg.tgid, - caption, file=media) - self._add_telegram_message_to_db(event_id, space, -1, response) - return - response = await client.send_media(self.peer, media, reply_to=reply_to, - caption=caption, entities=entities) - self._add_telegram_message_to_db(event_id, space, 0, response) - - def _add_telegram_message_to_db(self, event_id: MatrixEventID, space: TelegramID, - edit_index: int, response: TypeMessage) -> None: - self.log.debug("Handled Matrix message: %s", response) - self.is_duplicate(response, (event_id, space), force_hash=edit_index != 0) - if edit_index < 0: - prev_edit = DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1) - edit_index = prev_edit.edit_index + 1 - DBMessage( - tgid=TelegramID(response.id), - tg_space=space, - mx_room=self.mxid, - mxid=event_id, - edit_index=edit_index).insert() - - async def handle_matrix_message(self, sender: 'u.User', message: Dict[str, Any], - event_id: MatrixEventID) -> None: - if "body" not in message or "msgtype" not in message: - self.log.debug(f"Ignoring message {event_id} in {self.mxid} without body or msgtype") - return - - puppet = p.Puppet.get_by_custom_mxid(sender.mxid) - if puppet and message.get("net.maunium.telegram.puppet", False): - self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid) - return - - logged_in = not await sender.needs_relaybot(self) - client = sender.client if logged_in else self.bot.client - sender_id = sender.tgid if logged_in else self.bot.tgid - space = (self.tgid if self.peer_type == "channel" # Channels have their own ID space - else (sender.tgid if logged_in else self.bot.tgid)) - reply_to = formatter.matrix_reply_to_telegram(message, space, room_id=self.mxid) - - message["mxtg_filename"] = message["body"] - await self._pre_process_matrix_message(sender, not logged_in, message) - msgtype = message["msgtype"] - - if msgtype == "m.notice": - bridge_notices = self.get_config("bridge_notices.default") - excepted = sender.mxid in self.get_config("bridge_notices.exceptions") - if not bridge_notices and not excepted: - return - - if msgtype == "m.text" or msgtype == "m.notice": - await self._handle_matrix_text(sender_id, event_id, space, client, message, reply_to) - elif msgtype == "m.location": - await self._handle_matrix_location(sender_id, event_id, space, client, message, - reply_to) - elif msgtype in ("m.sticker", "m.image", "m.file", "m.audio", "m.video"): - await self._handle_matrix_file(msgtype, sender_id, event_id, space, client, message, - reply_to) - else: - self.log.debug(f"Unhandled Matrix event: {message}") - - async def handle_matrix_pin(self, sender: 'u.User', - pinned_message: Optional[MatrixEventID]) -> None: - if self.peer_type != "chat" and self.peer_type != "channel": - return - try: - if not pinned_message: - await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=0)) - else: - tg_space = self.tgid if self.peer_type == "channel" else sender.tgid - message = DBMessage.get_by_mxid(pinned_message, self.mxid, tg_space) - if message is None: - self.log.warning(f"Could not find pinned {pinned_message} in {self.mxid}") - return - await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=message.tgid)) - except ChatNotModifiedError: - pass - - async def handle_matrix_deletion(self, deleter: 'u.User', event_id: MatrixEventID) -> None: - real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot - space = self.tgid if self.peer_type == "channel" else real_deleter.tgid - message = DBMessage.get_by_mxid(event_id, self.mxid, space) - if not message: - return - if message.edit_index == 0: - await real_deleter.client.delete_messages(self.peer, [message.tgid]) - else: - self.log.debug(f"Ignoring deletion of edit event {message.mxid} in {message.mx_room}") - - async def _update_telegram_power_level(self, sender: 'u.User', user_id: TelegramID, - level: int) -> None: - if self.peer_type == "chat": - await sender.client(EditChatAdminRequest( - chat_id=self.tgid, user_id=user_id, is_admin=level >= 50)) - elif self.peer_type == "channel": - moderator = level >= 50 - admin = level >= 75 - rights = ChatAdminRights(change_info=moderator, post_messages=moderator, - edit_messages=moderator, delete_messages=moderator, - ban_users=moderator, invite_users=moderator, - pin_messages=moderator, add_admins=admin) - await sender.client( - EditAdminRequest(channel=await self.get_input_entity(sender), - user_id=user_id, admin_rights=rights)) - - async def handle_matrix_power_levels(self, sender: 'u.User', - new_users: Dict[MatrixUserID, int], - old_users: Dict[str, int]) -> None: - # TODO handle all power level changes and bridge exact admin rights to supergroups/channels - for user, level in new_users.items(): - if not user or user == self.main_intent.mxid or user == sender.mxid: - continue - user_id = p.Puppet.get_id_from_mxid(user) - if not user_id: - mx_user = u.User.get_by_mxid(user, create=False) - if not mx_user or not mx_user.tgid: - continue - user_id = mx_user.tgid - if not user_id or user_id == sender.tgid: - continue - if user not in old_users or level != old_users[user]: - await self._update_telegram_power_level(sender, user_id, level) - - async def handle_matrix_about(self, sender: 'u.User', about: str) -> None: - if self.peer_type not in ("chat", "channel"): - return - peer = await self.get_input_entity(sender) - await sender.client(EditChatAboutRequest(peer=peer, about=about)) - self.about = about - self.save() - - async def handle_matrix_title(self, sender: 'u.User', title: str) -> None: - if self.peer_type not in ("chat", "channel"): - return - - if self.peer_type == "chat": - response = await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title)) - else: - channel = await self.get_input_entity(sender) - response = await sender.client(EditTitleRequest(channel=channel, title=title)) - self._register_outgoing_actions_for_dedup(response) - self.title = title - self.save() - - async def handle_matrix_avatar(self, sender: 'u.User', url: str) -> None: - if self.peer_type not in ("chat", "channel"): - # Invalid peer type - return - - file = await self.main_intent.download_file(url) - mime = magic.from_buffer(file, mime=True) - ext = sane_mimetypes.guess_extension(mime) - uploaded = await sender.client.upload_file(file, file_name=f"avatar{ext}", use_cache=False) - photo = InputChatUploadedPhoto(file=uploaded) - - if self.peer_type == "chat": - response = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo)) - else: - channel = await self.get_input_entity(sender) - response = await sender.client(EditPhotoRequest(channel=channel, photo=photo)) - self._register_outgoing_actions_for_dedup(response) - for update in response.updates: - is_photo_update = (isinstance(update, UpdateNewMessage) - and isinstance(update.message, MessageService) - and isinstance(update.message.action, MessageActionChatEditPhoto)) - if is_photo_update: - loc, size = self._get_largest_photo_size(update.message.action.photo) - self.photo_id = f"{size.location.volume_id}-{size.location.local_id}" - self.save() - break - - async def handle_matrix_upgrade(self, new_room: MatrixRoomID) -> None: - old_room = self.mxid - self.migrate_and_save_matrix(new_room) - await self.main_intent.join_room(new_room) - entity = None # type: TypeInputPeer - user = None # type: AbstractUser - if self.bot and self.has_bot: - user = self.bot - entity = await self.get_input_entity(self.bot) - if not entity: - user_mxids = await self.main_intent.get_room_members(self.mxid) - for user_str in user_mxids: - user_id = MatrixUserID(user_str) - if user_id == self.az.bot_mxid: - continue - user = u.User.get_by_mxid(user_id, create=False) - if user and user.tgid: - entity = await self.get_input_entity(user) - if entity: - break - if not entity: - self.log.error( - "Failed to fully migrate to upgraded Matrix room: no Telegram user found.") - return - users, participants = await self._get_users(self.bot, entity) - await self.sync_telegram_users(user, users) - levels = await self.main_intent.get_power_levels(self.mxid) - await self.update_telegram_participants(participants, levels) - self.log.info(f"Upgraded room from {old_room} to {self.mxid}") - - def _register_outgoing_actions_for_dedup(self, response: TypeUpdates) -> None: - for update in response.updates: - check_dedup = (isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)) - and isinstance(update.message, MessageService)) - if check_dedup: - self.is_duplicate_action(update.message) - - # endregion - # region Telegram chat info updating - - async def _get_telegram_users_in_matrix_room(self) -> List[TelegramID]: - user_tgids = set() - user_mxids = await self.main_intent.get_room_members(self.mxid, ("join", "invite")) - for user_str in user_mxids: - user = MatrixUserID(user_str) - if user == self.az.bot_mxid: - continue - mx_user = u.User.get_by_mxid(user, create=False) - if mx_user and mx_user.tgid: - user_tgids.add(mx_user.tgid) - puppet_id = p.Puppet.get_id_from_mxid(user) - if puppet_id: - user_tgids.add(puppet_id) - return list(user_tgids) - - async def upgrade_telegram_chat(self, source: 'u.User') -> None: - if self.peer_type != "chat": - raise ValueError("Only normal group chats are upgradable to supergroups.") - - response = await source.client(MigrateChatRequest(chat_id=self.tgid)) - entity = None - for chat in response.chats: - if isinstance(chat, Channel): - entity = chat - break - if not entity: - raise ValueError("Upgrade may have failed: output channel not found.") - self.peer_type = "channel" - self.migrate_and_save_telegram(TelegramID(entity.id)) - await self.update_info(source, entity) - - async def set_telegram_username(self, source: 'u.User', username: str) -> None: - if self.peer_type != "channel": - raise ValueError("Only channels and supergroups have usernames.") - await source.client( - UpdateUsernameRequest(await self.get_input_entity(source), username)) - if await self.update_username(username): - self.save() - - async def create_telegram_chat(self, source: 'u.User', supergroup: bool = False) -> None: - if not self.mxid: - raise ValueError("Can't create Telegram chat for portal without Matrix room.") - elif self.tgid: - raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.") - - invites = await self._get_telegram_users_in_matrix_room() - if len(invites) < 2: - if self.bot is not None: - info, mxid = await self.bot.get_me() - raise ValueError("Not enough Telegram users to create a chat. " - "Invite more Telegram ghost users to the room, such as the " - f"relaybot ([{info.first_name}](https://matrix.to/#/{mxid})).") - raise ValueError("Not enough Telegram users to create a chat. " - "Invite more Telegram ghost users to the room.") - if self.peer_type == "chat": - response = await source.client(CreateChatRequest(title=self.title, users=invites)) - entity = response.chats[0] - elif self.peer_type == "channel": - response = await source.client(CreateChannelRequest(title=self.title, - about=self.about or "", - megagroup=supergroup)) - entity = response.chats[0] - await source.client(InviteToChannelRequest( - channel=await source.client.get_input_entity(entity), - users=invites)) - else: - raise ValueError("Invalid peer type for Telegram chat creation") - - self.tgid = entity.id - self.tg_receiver = self.tgid - self.by_tgid[self.tgid_full] = self - await self.update_info(source, entity) - self.db_instance.insert() - self.log = self.base_log.getChild(str(self.tgid)) - - if self.bot and self.bot.tgid in invites: - self.bot.add_chat(self.tgid, self.peer_type) - - levels = await self.main_intent.get_power_levels(self.mxid) - bot_level = self._get_bot_level(levels) - if bot_level == 100: - levels = self._get_base_power_levels(levels, entity) - await self.main_intent.set_power_levels(self.mxid, levels) - await self.handle_matrix_power_levels(source, levels["users"], {}) - - async def invite_telegram(self, source: 'u.User', - puppet: Union[p.Puppet, 'AbstractUser']) -> None: - if self.peer_type == "chat": - await source.client( - AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0)) - elif self.peer_type == "channel": - await source.client(InviteToChannelRequest(channel=self.peer, users=[puppet.tgid])) - else: - raise ValueError("Invalid peer type for Telegram user invite") - - # endregion - # region Telegram event handling - - async def handle_telegram_typing(self, user: p.Puppet, - _: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None: - await user.intent.set_typing(self.mxid, is_typing=True) - - def get_external_url(self, evt: Message) -> Optional[str]: - if self.peer_type == "channel" and self.username is not None: - return f"https://t.me/{self.username}/{evt.id}" - elif self.peer_type != "user": - return f"https://t.me/c/{self.tgid}/{evt.id}" - return None - - async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, - relates_to: Dict = None) -> Optional[Dict]: - loc, largest_size = self._get_largest_photo_size(evt.media.photo) - file = await util.transfer_file_to_matrix(source.client, intent, loc) - if not file: - return None - if self.get_config("inline_images") and (evt.message - or evt.fwd_from or evt.reply_to_msg_id): - text, html, relates_to = await formatter.telegram_to_matrix( - evt, source, self.main_intent, - prefix_html=f"Inline Telegram photo
", - prefix_text="Inline image: ") - await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to, - timestamp=evt.date, - external_url=self.get_external_url(evt)) - info = { - "h": largest_size.h, - "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)}" - await intent.set_typing(self.mxid, is_typing=False) - result = await intent.send_image(self.mxid, file.mxc, info=info, text=name, - relates_to=relates_to, timestamp=evt.date, - external_url=self.get_external_url(evt)) - if evt.message: - text, html, _ = await formatter.telegram_to_matrix(evt, source, self.main_intent, - no_reply_fallback=True) - result = await intent.send_text(self.mxid, text, html=html, timestamp=evt.date, - external_url=self.get_external_url(evt)) - return result - - @staticmethod - def _parse_telegram_document_attributes(attributes: List[TypeDocumentAttribute]) -> Dict: - attrs = { - "name": None, - "mime_type": None, - "is_sticker": False, - "sticker_alt": None, - "width": None, - "height": None, - } # type: Dict - for attr in attributes: - if isinstance(attr, DocumentAttributeFilename): - attrs["name"] = attrs["name"] or attr.file_name - attrs["mime_type"], _ = mimetypes.guess_type(attr.file_name) - elif isinstance(attr, DocumentAttributeSticker): - attrs["is_sticker"] = True - attrs["sticker_alt"] = attr.alt - elif isinstance(attr, DocumentAttributeVideo): - attrs["width"], attrs["height"] = attr.w, attr.h - return attrs - - @staticmethod - def _parse_telegram_document_meta(evt: Message, file: DBTelegramFile, attrs: Dict, - thumb_size: TypePhotoSize) -> Tuple[Dict, str]: - document = evt.media.document - name = evt.message or attrs["name"] - if attrs["is_sticker"]: - alt = attrs["sticker_alt"] - if len(alt) > 0: - try: - name = f"{alt} ({unicodedata.name(alt[0]).lower()})" - except ValueError: - name = alt - - generic_types = ("text/plain", "application/octet-stream") - if file.mime_type in generic_types and document.mime_type not in generic_types: - mime_type = document.mime_type or file.mime_type - else: - mime_type = file.mime_type or document.mime_type - info = { - "size": file.size, - "mimetype": mime_type, - } - - if attrs["mime_type"] and not file.was_converted: - file.mime_type = attrs["mime_type"] or file.mime_type - if file.width and file.height: - info["w"], info["h"] = file.width, file.height - elif attrs["width"] and attrs["height"]: - info["w"], info["h"] = attrs["width"], attrs["height"] - - if file.thumbnail: - info["thumbnail_url"] = file.thumbnail.mxc - info["thumbnail_info"] = { - "mimetype": file.thumbnail.mime_type, - "h": file.thumbnail.height or thumb_size.h, - "w": file.thumbnail.width or thumb_size.w, - "size": file.thumbnail.size, - } - - return info, name - - async def handle_telegram_document(self, source: 'AbstractUser', intent: IntentAPI, - evt: Message, relates_to: dict = None) -> Optional[Dict]: - document = evt.media.document - - attrs = self._parse_telegram_document_attributes(document.attributes) - - if document.size > config["bridge.max_document_size"] * 1000 ** 2: - name = attrs["name"] or "" - caption = f"\n{evt.message}" if evt.message else "" - return await intent.send_notice(self.mxid, f"Too large file {name}{caption}") - - thumb_loc, thumb_size = self._get_largest_photo_size(document) - if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)): - self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}") - thumb_loc = None - thumb_size = None - file = await util.transfer_file_to_matrix(source.client, intent, document, thumb_loc, - is_sticker=attrs["is_sticker"]) - if not file: - return None - - info, name = self._parse_telegram_document_meta(evt, file, attrs, thumb_size) - - await intent.set_typing(self.mxid, is_typing=False) - - kwargs = { - "room_id": self.mxid, - "url": file.mxc, - "info": info, - "text": name, - "relates_to": relates_to, - "timestamp": evt.date, - "external_url": self.get_external_url(evt) - } - - if attrs["is_sticker"]: - return await intent.send_sticker(**kwargs) - - mime_type = info["mimetype"] - if mime_type.startswith("video/"): - kwargs["file_type"] = "m.video" - elif mime_type.startswith("audio/"): - kwargs["file_type"] = "m.audio" - elif mime_type.startswith("image/"): - kwargs["file_type"] = "m.image" - else: - kwargs["file_type"] = "m.file" - return await intent.send_file(**kwargs) - - def handle_telegram_location(self, _: 'AbstractUser', intent: IntentAPI, evt: Message, - relates_to: dict = None) -> Awaitable[dict]: - location = evt.media.geo - long = location.long - lat = location.lat - long_char = "E" if long > 0 else "W" - lat_char = "N" if lat > 0 else "S" - rounded_long = round(long, 5) - rounded_lat = round(lat, 5) - - body = f"{rounded_lat}° {lat_char}, {rounded_long}° {long_char}" - - url = f"https://maps.google.com/?q={lat},{long}" - - formatted_body = f"Location: {body}" - # At least riot-web ignores formatting in m.location messages, - # so we'll add a plaintext link. - body = f"Location: {body}\n{url}" - - return intent.send_message(self.mxid, { - "msgtype": "m.location", - "geo_uri": f"geo:{lat},{long}", - "body": body, - "format": "org.matrix.custom.html", - "formatted_body": formatted_body, - "m.relates_to": relates_to or None, - }, timestamp=evt.date, external_url=self.get_external_url(evt)) - - async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool, - evt: Message) -> dict: - self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}") - text, html, relates_to = await formatter.telegram_to_matrix(evt, source, self.main_intent) - await intent.set_typing(self.mxid, is_typing=False) - msgtype = "m.notice" if is_bot and self.get_config("bot_messages_as_notices") else "m.text" - return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to, - msgtype=msgtype, timestamp=evt.date, - external_url=self.get_external_url(evt)) - - async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI, - evt: Message, relates_to: dict = None) -> dict: - override_text = ("This message is not supported on your version of Mautrix-Telegram. " - "Please check https://github.com/tulir/mautrix-telegram or ask your " - "bridge administrator about possible updates.") - text, html, relates_to = await formatter.telegram_to_matrix( - evt, source, self.main_intent, override_text=override_text) - await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_message(self.mxid, { - "body": text, - "msgtype": "m.notice", - "format": "org.matrix.custom.html", - "formatted_body": html, - "m.relates_to": relates_to, - "net.maunium.telegram.unsupported": True, - }, timestamp=evt.date, external_url=self.get_external_url(evt)) - - async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, - relates_to: dict) -> dict: - poll = evt.media.poll # type: Poll - poll_id = self._encode_msgid(source, evt) - - _n = 0 - - def n() -> int: - nonlocal _n - _n += 1 - return _n - - text = (f"Poll: {poll.question}\n" - + "\n".join(f"{n()}. {answer.text}" for answer in poll.answers) + - "\n" - f"Vote with !tg vote {poll_id} ") - - html = (f"Poll: {poll.question}
\n" - f"
    " - + "\n".join(f"
  1. {answer.text}
  2. " - for answer in poll.answers) + - "
\n" - f"Vote with !tg vote {poll_id} <choice number>") - await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to, - msgtype="m.text", timestamp=evt.date, - external_url=self.get_external_url(evt)) - - @staticmethod - def _int_to_bytes(i: int) -> bytes: - hex_value = "{0:010x}".format(i) - return codecs.decode(hex_value, "hex_codec") - - def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str: - if self.peer_type == "channel": - play_id = (b"c" - + self._int_to_bytes(self.tgid) - + self._int_to_bytes(evt.id)) - elif self.peer_type == "chat": - play_id = (b"g" - + self._int_to_bytes(self.tgid) - + self._int_to_bytes(evt.id) - + self._int_to_bytes(source.tgid)) - elif self.peer_type == "user": - play_id = (b"u" - + self._int_to_bytes(self.tgid) - + self._int_to_bytes(evt.id)) - else: - raise ValueError("Portal has invalid peer type") - return base64.b64encode(play_id).decode("utf-8").rstrip("=") - - async def handle_telegram_game(self, source: 'AbstractUser', intent: IntentAPI, - evt: Message, relates_to: dict = None): - game = evt.media.game - play_id = self._encode_msgid(source, evt) - command = f"!tg play {play_id}" - override_text = f"Run {command} in your bridge management room to play {game.title}" - override_entities = [ - MessageEntityPre(offset=len("Run "), length=len(command), language="")] - text, html, relates_to = await formatter.telegram_to_matrix( - evt, source, self.main_intent, - override_text=override_text, override_entities=override_entities) - await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_message(self.mxid, { - "body": text, - "msgtype": "m.notice", - "format": "org.matrix.custom.html", - "formatted_body": html, - "m.relates_to": relates_to, - "net.maunium.telegram.game": play_id, - }, timestamp=evt.date, external_url=self.get_external_url(evt)) - - async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, - evt: Message) -> None: - if not self.mxid: - return - elif hasattr(evt, "media") and isinstance(evt.media, (MessageMediaGame,)): - self.log.debug("Ignoring game message edit event") - return - - lock = self.optional_send_lock(sender.tgid if sender else None) - if lock: - async with lock: - pass - - tg_space = self.tgid if self.peer_type == "channel" else source.tgid - - temporary_identifier = MatrixEventID( - f"${random.randint(1000000000000, 9999999999999)}TGBRIDGEDITEMP") - duplicate_found = self.is_duplicate(evt, (temporary_identifier, tg_space), force_hash=True) - if duplicate_found: - mxid, other_tg_space = duplicate_found - if tg_space != other_tg_space: - prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) - if not prev_edit_msg: - return - DBMessage(mxid=mxid, mx_room=self.mxid, tg_space=tg_space, tgid=evt.id, - edit_index=prev_edit_msg.edit_index + 1).insert() - return - - text, html, _ = await formatter.telegram_to_matrix(evt, source, self.main_intent) - editing_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space) - if not editing_msg: - self.log.info(f"Didn't find edited message {evt.id}@{tg_space} (src {source.tgid}) " - "in database.") - return - - msgtype = ("m.notice" - if sender and sender.is_bot and self.get_config("bot_messages_as_notices") - else "m.text") - content = { - "body": f"Edit: {text}", - "msgtype": msgtype, - "format": "org.matrix.custom.html", - "formatted_body": (f"Edit: " - f"{html or escape_html(text)}"), - "external_url": self.get_external_url(evt), - "m.new_content": { - "body": text, - "msgtype": "m.text", - **({"format": "org.matrix.custom.html", - "formatted_body": html} if html else {}), - }, - "m.relates_to": { - "rel_type": "m.replace", - "event_id": editing_msg.mxid, - }, - } - - intent = sender.intent if sender else self.main_intent - await intent.set_typing(self.mxid, is_typing=False) - response = await intent.send_message(self.mxid, content) - mxid = response["event_id"] - - prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) or editing_msg - DBMessage(mxid=mxid, mx_room=self.mxid, tg_space=tg_space, tgid=evt.id, - edit_index=prev_edit_msg.edit_index + 1).insert() - DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=mxid) - - async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet, - evt: Message) -> None: - if not self.mxid: - await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False) - - lock = self.optional_send_lock(sender.tgid if sender else None) - if lock: - async with lock: - pass - - tg_space = self.tgid if self.peer_type == "channel" else source.tgid - - temporary_identifier = MatrixEventID( - f"${random.randint(1000000000000, 9999999999999)}TGBRIDGETEMP") - duplicate_found = self.is_duplicate(evt, (temporary_identifier, tg_space)) - if duplicate_found: - mxid, other_tg_space = duplicate_found - self.log.debug(f"Ignoring message {evt.id}@{tg_space} (src {source.tgid}) " - f"as it was already handled (in space {other_tg_space})") - if tg_space != other_tg_space: - DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=mxid, - tg_space=tg_space, edit_index=0).insert() - return - - if self.dedup_pre_db_check and self.peer_type == "channel": - msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space) - if msg: - self.log.debug(f"Ignoring message {evt.id} (src {source.tgid}) as it was already" - f"handled into {msg.mxid}. This duplicate was catched in the db " - "check. If you get this message often, consider increasing" - "bridge.deduplication.cache_queue_length in the config.") - return - - if sender and not sender.displayname: - self.log.debug(f"Telegram user {sender.tgid} sent a message, but doesn't have a " - "displayname, updating info...") - entity = await source.client.get_entity(PeerUser(sender.tgid)) - await sender.update_info(source, entity) - - allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo, - MessageMediaGame, MessageMediaPoll, MessageMediaUnsupported) - media = evt.media if hasattr(evt, "media") and isinstance(evt.media, - allowed_media) else None - intent = sender.intent if sender else self.main_intent - if not media and evt.message: - is_bot = sender.is_bot if sender else False - response = await self.handle_telegram_text(source, intent, is_bot, evt) - elif media: - response = await { - MessageMediaPhoto: self.handle_telegram_photo, - MessageMediaDocument: self.handle_telegram_document, - MessageMediaGeo: self.handle_telegram_location, - MessageMediaPoll: self.handle_telegram_poll, - MessageMediaUnsupported: self.handle_telegram_unsupported, - MessageMediaGame: self.handle_telegram_game, - }[type(media)](source, intent, evt, - relates_to=formatter.telegram_reply_to_matrix(evt, source)) - else: - self.log.debug("Unhandled Telegram message: %s", evt) - return - - if not response: - return - - mxid = response["event_id"] - - prev_id = self.update_duplicate(evt, (mxid, tg_space), (temporary_identifier, tg_space)) - if prev_id: - self.log.debug(f"Sent message {evt.id}@{tg_space} to Matrix as {mxid}. " - f"Temporary dedup identifier was {temporary_identifier}, " - f"but dedup map contained {prev_id[1]} instead! -- " - "This was probably a race condition caused by Telegram sending updates" - "to other clients before responding to the sender. I'll just redact " - "the likely duplicate message now.") - await intent.redact(self.mxid, mxid) - return - - self.log.debug("Handled Telegram message: %s", evt) - try: - DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=mxid, - tg_space=tg_space, edit_index=0).insert() - DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=mxid) - except IntegrityError as e: - self.log.exception(f"{e.__class__.__name__} while saving message mapping. " - "This might mean that an update was handled after it left the " - "dedup cache queue. You can try enabling bridge.deduplication." - "pre_db_check in the config.") - await intent.redact(self.mxid, mxid) - - async def _create_room_on_action(self, source: 'AbstractUser', - action: TypeMessageAction) -> bool: - if source.is_relaybot: - return False - create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate) - create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink) - if isinstance(action, create_and_exit) or isinstance(action, create_and_continue): - await self.create_matrix_room(source, invites=[source.mxid], - update_if_exists=isinstance(action, create_and_exit)) - if not isinstance(action, create_and_continue): - return False - return True - - async def handle_telegram_action(self, source: 'AbstractUser', sender: p.Puppet, - update: MessageService) -> None: - action = update.action - should_ignore = ((not self.mxid and not await self._create_room_on_action(source, action)) - or self.is_duplicate_action(update)) - if should_ignore or not self.mxid: - return - # TODO figure out how to see changes to about text / channel username - if isinstance(action, MessageActionChatEditTitle): - await self.update_title(action.title, save=True) - elif isinstance(action, MessageActionChatEditPhoto): - await self.update_avatar(source, action.photo, save=True) - elif isinstance(action, MessageActionChatDeletePhoto): - await self.remove_avatar(source, save=True) - elif isinstance(action, MessageActionChatAddUser): - for user_id in action.users: - await self.add_telegram_user(TelegramID(user_id), source) - elif isinstance(action, MessageActionChatJoinedByLink): - await self.add_telegram_user(sender.id, source) - elif isinstance(action, MessageActionChatDeleteUser): - await self.delete_telegram_user(TelegramID(action.user_id), sender) - elif isinstance(action, MessageActionChatMigrateTo): - self.peer_type = "channel" - self.migrate_and_save_telegram(TelegramID(action.channel_id)) - await sender.intent.send_emote(self.mxid, "upgraded this group to a supergroup.") - elif isinstance(action, MessageActionPinMessage): - await self.receive_telegram_pin_sender(sender) - elif isinstance(action, MessageActionGameScore): - # TODO handle game score - pass - else: - self.log.debug("Unhandled Telegram action in %s: %s", self.title, action) - - async def set_telegram_admin(self, user_id: TelegramID) -> None: - puppet = p.Puppet.get(user_id) - user = u.User.get_by_tgid(user_id) - - levels = await self.main_intent.get_power_levels(self.mxid) - if user: - levels["users"][user.mxid] = 50 - if puppet: - levels["users"][puppet.mxid] = 50 - await self.main_intent.set_power_levels(self.mxid, levels) - - async def receive_telegram_pin_sender(self, sender: p.Puppet) -> None: - self._temp_pinned_message_sender = sender - if self._temp_pinned_message_id: - await self.update_telegram_pin() - - async def update_telegram_pin(self) -> None: - intent = (self._temp_pinned_message_sender.intent - if self._temp_pinned_message_sender else self.main_intent) - msg_id = self._temp_pinned_message_id - self._temp_pinned_message_id = None - self._temp_pinned_message_sender = None - - message = DBMessage.get_one_by_tgid(msg_id, self._temp_pinned_message_id_space) - if message: - await intent.set_pinned_messages(self.mxid, [message.mxid]) - else: - await intent.set_pinned_messages(self.mxid, []) - - async def receive_telegram_pin_id(self, msg_id: int, receiver: TelegramID) -> None: - if msg_id == 0: - return await self.update_telegram_pin() - self._temp_pinned_message_id = msg_id - self._temp_pinned_message_id_space = receiver if self.peer_type != "channel" else self.tgid - if self._temp_pinned_message_sender: - await self.update_telegram_pin() - - @staticmethod - def _get_level_from_participant(participant: TypeParticipant, _: Dict) -> int: - # TODO use the power level requirements to get better precision in channels - if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)): - return 50 - elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)): - return 95 - return 0 - - @staticmethod - def _participant_to_power_levels(levels: dict, user: Union['u.User', p.Puppet], new_level: int, - bot_level: int) -> bool: - new_level = min(new_level, bot_level) - default_level = levels["users_default"] if "users_default" in levels else 0 - 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: - levels["users"][user.mxid] = new_level - return True - 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 - def _get_powerlevel_level(levels: dict) -> int: - try: - return levels["events"]["m.room.power_levels"] - except KeyError: - try: - return levels["state_default"] - except KeyError: - return 50 - - def _participants_to_power_levels(self, participants: List[TypeParticipant], levels: Dict - ) -> bool: - bot_level = self._get_bot_level(levels) - if bot_level < self._get_powerlevel_level(levels): - return False - changed = False - admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level) - if levels["events"]["m.room.power_levels"] != admin_power_level: - changed = True - levels["events"]["m.room.power_levels"] = admin_power_level - - for participant in participants: - puppet = p.Puppet.get(TelegramID(participant.user_id)) - user = u.User.get_by_tgid(TelegramID(participant.user_id)) - new_level = self._get_level_from_participant(participant, levels) - - if user: - user.register_portal(self) - changed = self._participant_to_power_levels(levels, user, new_level, - bot_level) or changed - - if puppet: - changed = self._participant_to_power_levels(levels, puppet, new_level, - bot_level) or changed - return changed - - async def update_telegram_participants(self, participants: List[TypeParticipant], - levels: dict = None) -> None: - if not levels: - levels = await self.main_intent.get_power_levels(self.mxid) - if self._participants_to_power_levels(participants, levels): - await self.main_intent.set_power_levels(self.mxid, levels) - - async def set_telegram_admins_enabled(self, enabled: bool) -> None: - level = 50 if enabled else 10 - levels = await self.main_intent.get_power_levels(self.mxid) - levels["invite"] = level - levels["events"]["m.room.name"] = level - levels["events"]["m.room.avatar"] = level - await self.main_intent.set_power_levels(self.mxid, levels) - - # endregion - # region Database conversion - - @property - def db_instance(self) -> DBPortal: - if not self._db_instance: - self._db_instance = self.new_db_instance() - return self._db_instance - - def new_db_instance(self) -> DBPortal: - return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type, - mxid=self.mxid, username=self.username, megagroup=self.megagroup, - title=self.title, about=self.about, photo_id=self.photo_id, - config=json.dumps(self.local_config)) - - def migrate_and_save_telegram(self, new_id: TelegramID) -> None: - try: - del self.by_tgid[self.tgid_full] - except KeyError: - pass - try: - existing = self.by_tgid[(new_id, new_id)] - existing.delete() - except KeyError: - pass - self.db_instance.update(tgid=new_id, tg_receiver=new_id, peer_type=self.peer_type) - old_id = self.tgid - self.tgid = new_id - self.tg_receiver = new_id - self.by_tgid[self.tgid_full] = self - self.log = self.base_log.getChild(str(self.tgid)) - self.log.info(f"Telegram chat upgraded from {old_id}") - - def migrate_and_save_matrix(self, new_id: MatrixRoomID) -> None: - try: - del self.by_mxid[self.mxid] - except KeyError: - pass - self.mxid = new_id - self.db_instance.update(mxid=self.mxid) - self.by_mxid[self.mxid] = self - - def save(self) -> None: - self.db_instance.update(mxid=self.mxid, username=self.username, title=self.title, - about=self.about, photo_id=self.photo_id, - config=json.dumps(self.local_config)) - - def delete(self) -> None: - try: - del self.by_tgid[self.tgid_full] - except KeyError: - pass - try: - del self.by_mxid[self.mxid] - except KeyError: - pass - if self._db_instance: - self._db_instance.delete() - self.deleted = True - - @classmethod - def from_db(cls, db_portal: DBPortal) -> 'Portal': - return Portal(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver, - peer_type=db_portal.peer_type, mxid=db_portal.mxid, - username=db_portal.username, megagroup=db_portal.megagroup, - title=db_portal.title, about=db_portal.about, photo_id=db_portal.photo_id, - local_config=db_portal.config, db_instance=db_portal) - - # endregion - # region Class instance lookup - - @classmethod - def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']: - try: - return cls.by_mxid[mxid] - except KeyError: - pass - - portal = DBPortal.get_by_mxid(mxid) - if portal: - return cls.from_db(portal) - - return None - - @classmethod - def get_username_from_mx_alias(cls, alias: str) -> Optional[str]: - match = cls.mx_alias_regex.match(alias) - if match: - return match.group(1) - return None - - @classmethod - def find_by_username(cls, username: str) -> Optional['Portal']: - if not username: - return None - - for _, portal in cls.by_tgid.items(): - if portal.username and portal.username.lower() == username.lower(): - return portal - - dbportal = DBPortal.get_by_username(username) - if dbportal: - return cls.from_db(dbportal) - - return None - - @classmethod - def get_by_tgid(cls, tgid: TelegramID, tg_receiver: Optional[TelegramID] = None, - peer_type: str = None) -> Optional['Portal']: - tg_receiver = tg_receiver or tgid - tgid_full = (tgid, tg_receiver) - try: - return cls.by_tgid[tgid_full] - except KeyError: - pass - - db_portal = DBPortal.get_by_tgid(tgid, tg_receiver) - if db_portal: - return cls.from_db(db_portal) - - if peer_type: - portal = Portal(tgid, peer_type=peer_type, tg_receiver=tg_receiver) - portal.db_instance.insert() - return portal - - return None - - @classmethod - def get_by_entity(cls, entity: Union[TypeChat, TypePeer, TypeUser, TypeUserFull, - TypeInputPeer], - receiver_id: Optional[TelegramID] = None, create: bool = True - ) -> Optional['Portal']: - entity_type = type(entity) - if entity_type in {Chat, ChatFull}: - type_name = "chat" - entity_id = entity.id - elif entity_type in {PeerChat, InputPeerChat}: - type_name = "chat" - entity_id = entity.chat_id - elif entity_type in {Channel, ChannelFull}: - type_name = "channel" - entity_id = entity.id - elif entity_type in {PeerChannel, InputPeerChannel, InputChannel}: - type_name = "channel" - entity_id = entity.channel_id - elif entity_type in {User, UserFull}: - type_name = "user" - entity_id = entity.id - elif entity_type in {PeerUser, InputPeerUser, InputUser}: - type_name = "user" - entity_id = entity.user_id - else: - raise ValueError(f"Unknown entity type {entity_type.__name__}") - return cls.get_by_tgid(TelegramID(entity_id), - receiver_id if type_name == "user" else entity_id, - type_name if create else None) - - # endregion - - -def init(context: Context) -> None: - global config - Portal.az, config, Portal.loop, Portal.bot = context.core - Portal.max_initial_member_sync = config["bridge.max_initial_member_sync"] - Portal.sync_channel_members = config["bridge.sync_channel_members"] - Portal.sync_matrix_state = config["bridge.sync_matrix_state"] - Portal.public_portals = config["bridge.public_portals"] - Portal.filter_mode = config["bridge.filter.mode"] - Portal.filter_list = config["bridge.filter.list"] - Portal.dedup_pre_db_check = config["bridge.deduplication.pre_db_check"] - Portal.dedup_cache_queue_length = config["bridge.deduplication.cache_queue_length"] - Portal.alias_template = config.get("bridge.alias_template", "telegram_{groupname}") - Portal.hs_domain = config["homeserver.domain"] - Portal.mx_alias_regex = re.compile( - f"#{Portal.alias_template.format(groupname='(.+)')}:{Portal.hs_domain}") diff --git a/mautrix_telegram/portal/__init__.py b/mautrix_telegram/portal/__init__.py new file mode 100644 index 00000000..800f93d2 --- /dev/null +++ b/mautrix_telegram/portal/__init__.py @@ -0,0 +1,21 @@ +from .base import BasePortal, init as init_base +from .matrix import PortalMatrix, init as init_matrix +from .metadata import PortalMetadata, init as init_metadata +from .telegram import PortalTelegram, init as init_telegram +from .deduplication import init as init_dedup +from ..context import Context + + +class Portal(PortalMatrix, PortalTelegram, PortalMetadata): + pass + + +def init(context: Context) -> None: + init_base(context) + init_dedup(context) + init_metadata(context) + init_telegram(context) + init_matrix(context) + + +__all__ = ["Portal", "init"] diff --git a/mautrix_telegram/portal/__init__.pyi b/mautrix_telegram/portal/__init__.pyi new file mode 100644 index 00000000..f705bfc7 --- /dev/null +++ b/mautrix_telegram/portal/__init__.pyi @@ -0,0 +1,15 @@ +from typing import Union +from .base import BasePortal +from .portal_matrix import PortalMatrix +from .portal_metadata import PortalMetadata +from .portal_telegram import PortalTelegram +from ..context import Context + +Portal = Union[BasePortal, PortalMatrix, PortalMetadata, PortalTelegram] + + +def init(context: Context) -> None: + pass + + +__all__ = ["Portal", "init"] diff --git a/mautrix_telegram/portal/base.py b/mautrix_telegram/portal/base.py new file mode 100644 index 00000000..fd4d3374 --- /dev/null +++ b/mautrix_telegram/portal/base.py @@ -0,0 +1,474 @@ +# 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 . +from typing import Awaitable, Dict, List, Optional, Tuple, Union, Any, TYPE_CHECKING +from abc import ABC, abstractmethod +import asyncio +import logging +import json + +from telethon.tl.functions.messages import ExportChatInviteRequest +from telethon.tl.types import (Channel, ChannelFull, Chat, ChatFull, ChatInviteEmpty, InputChannel, + InputPeerChannel, InputPeerChat, InputPeerUser, InputUser, + PeerChannel, PeerChat, PeerUser, TypeChat, TypeInputPeer, TypePeer, + TypeUser, TypeUserFull, User, UserFull, TypeInputChannel, Photo, + Document, TypePhotoSize, PhotoSize, InputPhotoFileLocation, + TypeChatParticipant, TypeChannelParticipant, PhotoEmpty, ChatPhoto, + ChatPhotoEmpty) + +from mautrix.errors import MatrixRequestError, IntentError +from mautrix.appservice import AppService, IntentAPI +from mautrix.types import RoomID, RoomAlias, UserID, EventType, PowerLevelStateEventContent +from mautrix.util.simple_template import SimpleTemplate + +from ..types import TelegramID +from ..context import Context +from ..db import Portal as DBPortal +from .. import puppet as p, user as u, util +from .deduplication import PortalDedup +from .send_lock import PortalSendLock + +if TYPE_CHECKING: + from ..bot import Bot + from ..abstract_user import AbstractUser + from ..config import Config + from . import Portal + +TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant] +TypeChatPhoto = Union[ChatPhoto, ChatPhotoEmpty, Photo, PhotoEmpty] +InviteList = Union[UserID, List[UserID]] + +config: Optional['Config'] = None + + +class BasePortal(ABC): + base_log: logging.Logger = logging.getLogger("mau.portal") + az: AppService = None + bot: 'Bot' = None + loop: asyncio.AbstractEventLoop = None + + # Config cache + filter_mode: str = None + filter_list: List[str] = None + + max_initial_member_sync: int = -1 + sync_channel_members: bool = True + sync_matrix_state: bool = True + public_portals: bool = False + + alias_template: SimpleTemplate[str] + hs_domain: str + + # Instance cache + by_mxid: Dict[RoomID, 'Portal'] = {} + by_tgid: Dict[Tuple[TelegramID, TelegramID], 'Portal'] = {} + + mxid: Optional[RoomID] + tgid: TelegramID + tg_receiver: TelegramID + peer_type: str + username: str + megagroup: bool + title: Optional[str] + about: Optional[str] + photo_id: Optional[str] + local_config: Dict[str, Any] + deleted: bool + log: logging.Logger + + alias: Optional[RoomAlias] + + dedup: PortalDedup + send_lock: PortalSendLock + + _db_instance: DBPortal + _main_intent: Optional[IntentAPI] + + def __init__(self, tgid: TelegramID, peer_type: str, tg_receiver: Optional[TelegramID] = None, + mxid: Optional[RoomID] = None, username: Optional[str] = None, + megagroup: Optional[bool] = False, title: Optional[str] = None, + about: Optional[str] = None, photo_id: Optional[str] = None, + local_config: Optional[str] = None, db_instance: DBPortal = None) -> None: + self.mxid = mxid + self.tgid = tgid + self.tg_receiver = tg_receiver or tgid + self.peer_type = peer_type + self.username = username + self.megagroup = megagroup + self.title = title + self.about = about + self.photo_id = photo_id + self.local_config = json.loads(local_config or "{}") + self._db_instance = db_instance + self._main_intent = None + self.deleted = False + self.log = self.base_log.getChild(self.tgid_log if self.tgid else self.mxid) + + self.dedup = PortalDedup(self) + self.send_lock = PortalSendLock() + + if tgid: + self.by_tgid[self.tgid_full] = self + if mxid: + self.by_mxid[mxid] = self + + # region Propegrties + + @property + def tgid_full(self) -> Tuple[TelegramID, TelegramID]: + return self.tgid, self.tg_receiver + + @property + def tgid_log(self) -> str: + if self.tgid == self.tg_receiver: + return str(self.tgid) + return f"{self.tg_receiver}<->{self.tgid}" + + @property + def peer(self) -> Union[TypePeer, TypeInputPeer]: + if self.peer_type == "user": + return PeerUser(user_id=self.tgid) + elif self.peer_type == "chat": + return PeerChat(chat_id=self.tgid) + elif self.peer_type == "channel": + return PeerChannel(channel_id=self.tgid) + + @property + def has_bot(self) -> bool: + return bool(self.bot and self.bot.is_in_chat(self.tgid)) + + @property + def main_intent(self) -> IntentAPI: + if not self._main_intent: + direct = self.peer_type == "user" + puppet = p.Puppet.get(self.tgid) if direct else None + self._main_intent = puppet.intent_for(self) if direct else self.az.intent + return self._main_intent + + @property + def allow_bridging(self) -> bool: + if self.peer_type == "user": + return True + elif self.filter_mode == "whitelist": + return self.tgid in self.filter_list + elif self.filter_mode == "blacklist": + return self.tgid not in self.filter_list + return True + + # endregion + # region Miscellaneous getters + + def get_config(self, key: str) -> Any: + local = util.recursive_get(self.local_config, key) + if local is not None: + return local + return config[f"bridge.{key}"] + + @staticmethod + def _get_largest_photo_size(photo: Union[Photo, Document] + ) -> Tuple[Optional[InputPhotoFileLocation], + Optional[TypePhotoSize]]: + if not photo: + return None, None + if isinstance(photo, Document) and not photo.thumbs: + return None, None + + largest = max(photo.thumbs if isinstance(photo, Document) else photo.sizes, + key=(lambda photo2: (len(photo2.bytes) + if not isinstance(photo2, PhotoSize) + else photo2.size))) + return InputPhotoFileLocation( + id=photo.id, + access_hash=photo.access_hash, + file_reference=photo.file_reference, + thumb_size=largest.type, + ), largest + + async def can_user_perform(self, user: 'u.User', event: str) -> bool: + if user.is_admin: + return True + if not self.mxid: + # No room for anybody to perform actions in + return False + try: + await self.main_intent.get_power_levels(self.mxid) + except MatrixRequestError: + return False + evt_type = EventType.find(f"net.maunium.telegram.{event}") + evt_type.t_class = EventType.Class.STATE + return self.main_intent.state_store.has_power_level(self.mxid, user.mxid, event=evt_type) + + def get_input_entity(self, user: 'AbstractUser' + ) -> Awaitable[Union[TypeInputPeer, TypeInputChannel]]: + return user.client.get_input_entity(self.peer) + + async def get_entity(self, user: 'AbstractUser') -> TypeChat: + try: + return await user.client.get_entity(self.peer) + except ValueError: + if user.is_bot: + self.log.warning(f"Could not find entity with bot {user.tgid}. " + "Failing...") + raise + self.log.warning(f"Could not find entity with user {user.tgid}. " + "falling back to get_dialogs.") + async for dialog in user.client.iter_dialogs(): + if dialog.entity.id == self.tgid: + return dialog.entity + raise + + async def get_invite_link(self, user: 'u.User') -> str: + if self.peer_type == "user": + raise ValueError("You can't invite users to private chats.") + if self.username: + return f"https://t.me/{self.username}" + link = await user.client(ExportChatInviteRequest(peer=await self.get_input_entity(user))) + if isinstance(link, ChatInviteEmpty): + raise ValueError("Failed to get invite link.") + return link.link + + # endregion + # region Matrix room cleanup + + async def get_authenticated_matrix_users(self) -> List['u.User']: + try: + members = await self.main_intent.get_room_members(self.mxid) + except MatrixRequestError: + return [] + authenticated: List[u.User] = [] + has_bot = self.has_bot + for member_str in members: + member = UserID(member_str) + if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid: + continue + user = await u.User.get_by_mxid(member).ensure_started() + authenticated_through_bot = has_bot and user.relaybot_whitelisted + if authenticated_through_bot or await user.has_full_access(allow_bot=True): + authenticated.append(user) + return authenticated + + @staticmethod + async def cleanup_room(intent: IntentAPI, room_id: RoomID, message: str = "Portal deleted", + puppets_only: bool = False) -> None: + try: + members = await intent.get_room_members(room_id) + except MatrixRequestError: + members = [] + for user in members: + puppet = p.Puppet.get_by_mxid(UserID(user), create=False) + if user != intent.mxid and (not puppets_only or puppet): + try: + if puppet: + await puppet.default_mxid_intent.leave_room(room_id) + else: + await intent.kick_user(room_id, user, message) + except (MatrixRequestError, IntentError): + pass + await intent.leave_room(room_id) + + async def unbridge(self) -> None: + await self.cleanup_room(self.main_intent, self.mxid, "Room unbridged", puppets_only=True) + self.delete() + + async def cleanup_and_delete(self) -> None: + await self.cleanup_room(self.main_intent, self.mxid) + self.delete() + + # endregion + # region Database conversion + + @property + def db_instance(self) -> DBPortal: + if not self._db_instance: + self._db_instance = self.new_db_instance() + return self._db_instance + + def new_db_instance(self) -> DBPortal: + return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type, + mxid=self.mxid, username=self.username, megagroup=self.megagroup, + title=self.title, about=self.about, photo_id=self.photo_id, + config=json.dumps(self.local_config)) + + def save(self) -> None: + self.db_instance.edit(mxid=self.mxid, username=self.username, title=self.title, + about=self.about, photo_id=self.photo_id, + config=json.dumps(self.local_config)) + + def delete(self) -> None: + try: + del self.by_tgid[self.tgid_full] + except KeyError: + pass + try: + del self.by_mxid[self.mxid] + except KeyError: + pass + if self._db_instance: + self._db_instance.delete() + self.deleted = True + + @classmethod + def from_db(cls, db_portal: DBPortal) -> 'Portal': + return cls(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver, + peer_type=db_portal.peer_type, mxid=db_portal.mxid, + username=db_portal.username, megagroup=db_portal.megagroup, + title=db_portal.title, about=db_portal.about, photo_id=db_portal.photo_id, + local_config=db_portal.config, db_instance=db_portal) + + # endregion + # region Class instance lookup + + @classmethod + def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']: + try: + return cls.by_mxid[mxid] + except KeyError: + pass + + portal = DBPortal.get_by_mxid(mxid) + if portal: + return cls.from_db(portal) + + return None + + @classmethod + def get_username_from_mx_alias(cls, alias: str) -> Optional[str]: + return cls.alias_template.parse(alias) + + @classmethod + def find_by_username(cls, username: str) -> Optional['Portal']: + if not username: + return None + + for _, portal in cls.by_tgid.items(): + if portal.username and portal.username.lower() == username.lower(): + return portal + + dbportal = DBPortal.get_by_username(username) + if dbportal: + return cls.from_db(dbportal) + + return None + + @classmethod + def get_by_tgid(cls, tgid: TelegramID, tg_receiver: Optional[TelegramID] = None, + peer_type: str = None) -> Optional['Portal']: + tg_receiver = tg_receiver or tgid + tgid_full = (tgid, tg_receiver) + try: + return cls.by_tgid[tgid_full] + except KeyError: + pass + + db_portal = DBPortal.get_by_tgid(tgid, tg_receiver) + if db_portal: + return cls.from_db(db_portal) + + if peer_type: + portal = cls(tgid, peer_type=peer_type, tg_receiver=tg_receiver) + portal.db_instance.insert() + return portal + + return None + + @classmethod + def get_by_entity(cls, entity: Union[TypeChat, TypePeer, TypeUser, TypeUserFull, + TypeInputPeer], + receiver_id: Optional[TelegramID] = None, create: bool = True + ) -> Optional['Portal']: + entity_type = type(entity) + if entity_type in (Chat, ChatFull): + type_name = "chat" + entity_id = entity.id + elif entity_type in (PeerChat, InputPeerChat): + type_name = "chat" + entity_id = entity.chat_id + elif entity_type in (Channel, ChannelFull): + type_name = "channel" + entity_id = entity.id + elif entity_type in (PeerChannel, InputPeerChannel, InputChannel): + type_name = "channel" + entity_id = entity.channel_id + elif entity_type in (User, UserFull): + type_name = "user" + entity_id = entity.id + elif entity_type in (PeerUser, InputPeerUser, InputUser): + type_name = "user" + entity_id = entity.user_id + else: + raise ValueError(f"Unknown entity type {entity_type.__name__}") + return cls.get_by_tgid(TelegramID(entity_id), + receiver_id if type_name == "user" else entity_id, + type_name if create else None) + + # endregion + # region Abstract methods (cross-called in matrix/metadata/telegram classes) + + @abstractmethod + async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User], + direct: bool, puppet: p.Puppet = None, + levels: PowerLevelStateEventContent = None, + users: List[User] = None, + participants: List[TypeParticipant] = None) -> None: + pass + + @abstractmethod + async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None, + invites: InviteList = None, update_if_exists: bool = True, + synchronous: bool = False) -> Optional[str]: + pass + + @abstractmethod + async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None + ) -> None: + pass + + @abstractmethod + async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None: + pass + + @abstractmethod + async def _update_title(self, title: str, save: bool = False) -> bool: + pass + + @abstractmethod + async def _update_avatar(self, user: 'AbstractUser', photo: Union[TypeChatPhoto], + save: bool = False) -> bool: + pass + + @abstractmethod + def _migrate_and_save_telegram(self, new_id: TelegramID) -> None: + pass + + @abstractmethod + def handle_matrix_power_levels(self, sender: 'u.User', new_levels: Dict[UserID, int], + old_levels: Dict[UserID, int]) -> Awaitable[None]: + pass + + # endregion + + +def init(context: Context) -> None: + global config + BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core + BasePortal.max_initial_member_sync = config["bridge.max_initial_member_sync"] + BasePortal.sync_channel_members = config["bridge.sync_channel_members"] + BasePortal.sync_matrix_state = config["bridge.sync_matrix_state"] + BasePortal.public_portals = config["bridge.public_portals"] + BasePortal.filter_mode = config["bridge.filter.mode"] + BasePortal.filter_list = config["bridge.filter.list"] + BasePortal.hs_domain = config["homeserver.domain"] + BasePortal.alias_template = SimpleTemplate(config["bridge.alias_template"], "groupname", + prefix="#", suffix=f":{BasePortal.hs_domain}") diff --git a/mautrix_telegram/portal/deduplication.py b/mautrix_telegram/portal/deduplication.py new file mode 100644 index 00000000..d22b5927 --- /dev/null +++ b/mautrix_telegram/portal/deduplication.py @@ -0,0 +1,133 @@ +# 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 . +from typing import Optional, Deque, Dict, Tuple, TYPE_CHECKING +from collections import deque +import hashlib + +from telethon.tl.patched import Message, MessageService +from telethon.tl.types import (MessageMediaContact, MessageMediaDocument, MessageMediaGeo, + MessageMediaPhoto, TypeMessage, TypeUpdates, UpdateNewMessage, + UpdateNewChannelMessage) + +from mautrix.types import EventID + +from ..context import Context +from ..types import TelegramID + +if TYPE_CHECKING: + from .base import BasePortal + +DedupMXID = Tuple[EventID, TelegramID] + + +class PortalDedup: + pre_db_check: bool = False + cache_queue_length: int = 20 + + _dedup: Deque[str] + _dedup_mxid: Dict[str, DedupMXID] + _dedup_action: Deque[str] + _portal: 'BasePortal' + + def __init__(self, portal: 'BasePortal') -> None: + self._dedup = deque() + self._dedup_mxid = {} + self._dedup_action = deque() + self._portal = portal + + @property + def _always_force_hash(self) -> bool: + return self._portal.peer_type != 'channel' + + @staticmethod + def _hash_event(event: TypeMessage) -> str: + # Non-channel messages are unique per-user (wtf telegram), so we have no other choice than + # to deduplicate based on a hash of the message content. + + # The timestamp is only accurate to the second, so we can't rely solely on that either. + if isinstance(event, MessageService): + hash_content = [event.date.timestamp(), event.from_id, event.action] + else: + hash_content = [event.date.timestamp(), event.message] + if event.fwd_from: + hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id] + elif isinstance(event, Message) and event.media: + try: + hash_content += { + MessageMediaContact: lambda media: [media.user_id], + MessageMediaDocument: lambda media: [media.document.id], + MessageMediaPhoto: lambda media: [media.photo.id], + MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat], + }[type(event.media)](event.media) + except KeyError: + pass + return hashlib.md5("-" + .join(str(a) for a in hash_content) + .encode("utf-8") + ).hexdigest() + + def check_action(self, event: TypeMessage) -> bool: + evt_hash = self._hash_event(event) if self._always_force_hash else event.id + if evt_hash in self._dedup_action: + return True + + self._dedup_action.append(evt_hash) + + if len(self._dedup_action) > self.cache_queue_length: + self._dedup_action.popleft() + return False + + def update(self, event: TypeMessage, mxid: DedupMXID = None, + expected_mxid: Optional[DedupMXID] = None, force_hash: bool = False + ) -> Optional[DedupMXID]: + evt_hash = self._hash_event(event) if self._always_force_hash or force_hash else event.id + try: + found_mxid = self._dedup_mxid[evt_hash] + except KeyError: + return EventID("None"), TelegramID(0) + + if found_mxid != expected_mxid: + return found_mxid + self._dedup_mxid[evt_hash] = mxid + return None + + def check(self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False + ) -> Optional[DedupMXID]: + evt_hash = (self._hash_event(event) + if self._always_force_hash or force_hash + else event.id) + if evt_hash in self._dedup: + return self._dedup_mxid[evt_hash] + + self._dedup_mxid[evt_hash] = mxid + self._dedup.append(evt_hash) + + if len(self._dedup) > self.cache_queue_length: + del self._dedup_mxid[self._dedup.popleft()] + return None + + def register_outgoing_actions(self, response: TypeUpdates) -> None: + for update in response.updates: + check_dedup = (isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)) + and isinstance(update.message, MessageService)) + if check_dedup: + self.check(update.message) + + +def init(context: Context) -> None: + cfg = context.config + PortalDedup.dedup_pre_db_check = cfg["bridge.deduplication.pre_db_check"] + PortalDedup.dedup_cache_queue_length = cfg["bridge.deduplication.cache_queue_length"] diff --git a/mautrix_telegram/portal/matrix.py b/mautrix_telegram/portal/matrix.py new file mode 100644 index 00000000..aee53ed8 --- /dev/null +++ b/mautrix_telegram/portal/matrix.py @@ -0,0 +1,503 @@ +# 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 . +from typing import Awaitable, Dict, List, Optional, Tuple, Union, Any, TYPE_CHECKING +from html import escape as escape_html +from string import Template +from abc import ABC +import mimetypes + +import magic + +from telethon.tl.functions.messages import (EditChatPhotoRequest, EditChatTitleRequest, + UpdatePinnedMessageRequest, SetTypingRequest, + EditChatAboutRequest) +from telethon.tl.functions.channels import EditPhotoRequest, EditTitleRequest, JoinChannelRequest +from telethon.errors import (ChatNotModifiedError, PhotoExtInvalidError, + PhotoInvalidDimensionsError, PhotoSaveFileInvalidError) +from telethon.tl.patched import Message, MessageService +from telethon.tl.types import ( + DocumentAttributeFilename, DocumentAttributeImageSize, GeoPoint, + InputChatUploadedPhoto, MessageActionChatEditPhoto, MessageMediaGeo, + SendMessageCancelAction, SendMessageTypingAction, TypeInputPeer, TypeMessageEntity, + UpdateNewMessage, InputMediaUploadedDocument) + +from mautrix.types import (EventID, RoomID, UserID, ContentURI, MessageType, MessageEventContent, + TextMessageEventContent, MediaMessageEventContent, Format, + LocationMessageEventContent) +from mautrix.bridge import BasePortal as MautrixBasePortal + +from ..types import TelegramID +from ..db import Message as DBMessage +from ..util import sane_mimetypes +from ..context import Context +from .. import puppet as p, user as u, formatter, util +from .base import BasePortal + +if TYPE_CHECKING: + from ..abstract_user import AbstractUser + from ..tgclient import MautrixTelegramClient + from ..config import Config + +TypeMessage = Union[Message, MessageService] + +config: Optional['Config'] = None + + +class PortalMatrix(BasePortal, MautrixBasePortal, ABC): + @staticmethod + def _get_file_meta(body: str, mime: str) -> str: + try: + current_extension = body[body.rindex("."):].lower() + body = body[:body.rindex(".")] + if mimetypes.types_map[current_extension] == mime: + return body + current_extension + except (ValueError, KeyError): + pass + if mime: + return f"matrix_upload{sane_mimetypes.guess_extension(mime)}" + return "" + + async def _get_state_change_message(self, event: str, user: 'u.User', **kwargs: Any + ) -> Optional[str]: + tpl = self.get_config(f"state_event_formats.{event}") + if len(tpl) == 0: + # Empty format means they don't want the message + return None + displayname = await self.get_displayname(user) + + tpl_args = { + "mxid": user.mxid, + "username": user.mxid_localpart, + "displayname": escape_html(displayname), + **kwargs, + } + return Template(tpl).safe_substitute(tpl_args) + + async def _send_state_change_message(self, event: str, user: 'u.User', event_id: EventID, + **kwargs: Any) -> None: + if not self.has_bot: + return + async with self.send_lock(self.bot.tgid): + message = await self._get_state_change_message(event, user, **kwargs) + if not message: + return + response = await self.bot.client.send_message( + self.peer, message, + parse_mode=self._matrix_event_to_entities) + space = self.tgid if self.peer_type == "channel" else self.bot.tgid + self.dedup.check(response, (event_id, space)) + + async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str, + event_id: EventID) -> None: + await self._send_state_change_message("name_change", user, event_id, + displayname=displayname, + prev_displayname=prev_displayname) + + async def get_displayname(self, user: 'u.User') -> str: + return await self.main_intent.get_room_displayname(self.mxid, user.mxid) or user.mxid + + def set_typing(self, user: 'u.User', typing: bool = True, + action: type = SendMessageTypingAction) -> Awaitable[bool]: + return user.client(SetTypingRequest( + self.peer, action() if typing else SendMessageCancelAction())) + + async def mark_read(self, user: 'u.User', event_id: EventID) -> None: + if user.is_bot: + return + space = self.tgid if self.peer_type == "channel" else user.tgid + message = DBMessage.get_by_mxid(event_id, self.mxid, space) + if not message: + return + await user.client.send_read_acknowledge(self.peer, max_id=message.tgid, + clear_mentions=True) + + async def kick_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User') -> None: + if user.tgid == source.tgid: + return + if self.peer_type == "user" and user.tgid == self.tgid: + self.delete() + try: + del self.by_tgid[self.tgid_full] + del self.by_mxid[self.mxid] + except KeyError: + pass + return + if isinstance(user, u.User) and await user.needs_relaybot(self): + if not self.bot: + return + # TODO kick and ban message + return + if await source.needs_relaybot(self): + if not self.has_bot: + return + source = self.bot + await source.client.kick_participant(self.peer, user.peer) + + async def leave_matrix(self, user: 'u.User', event_id: EventID) -> None: + if await user.needs_relaybot(self): + await self._send_state_change_message("leave", user, event_id) + return + + if self.peer_type == "user": + await self.main_intent.leave_room(self.mxid) + self.delete() + try: + del self.by_tgid[self.tgid_full] + del self.by_mxid[self.mxid] + except KeyError: + pass + else: + await user.client.delete_dialog(self.peer) + + async def join_matrix(self, user: 'u.User', event_id: EventID) -> None: + if await user.needs_relaybot(self): + await self._send_state_change_message("join", user, event_id) + return + + if self.peer_type == "channel" and not user.is_bot: + await user.client(JoinChannelRequest(channel=await self.get_input_entity(user))) + else: + # We'll just assume the user is already in the chat. + pass + + async def _apply_msg_format(self, sender: 'u.User', content: MessageEventContent + ) -> None: + if isinstance(content, TextMessageEventContent) and content.format != Format.HTML: + content.format = Format.HTML + content.formatted_body = escape_html(content.body).replace("\n", "
") + + tpl = (self.get_config(f"message_formats.[{content.msgtype.value}]") + or "$sender_displayname: $message") + displayname = await self.get_displayname(sender) + tpl_args = dict(sender_mxid=sender.mxid, + sender_username=sender.mxid_localpart, + sender_displayname=escape_html(displayname), + body=content.body) + if isinstance(content, TextMessageEventContent): + tpl_args["formatted_body"] = content.formatted_body + tpl_args["message"] = content.formatted_body + content.formatted_body = Template(tpl).safe_substitute(tpl_args) + else: + tpl_args["message"] = content.body + content.body = Template(tpl).safe_substitute(tpl_args) + + async def _pre_process_matrix_message(self, sender: 'u.User', use_relaybot: bool, + content: MessageEventContent) -> None: + if content.msgtype == MessageType.EMOTE: + await self._apply_msg_format(sender, content) + content.msgtype = MessageType.TEXT + elif use_relaybot: + await self._apply_msg_format(sender, content) + + @staticmethod + def _matrix_event_to_entities(event: Union[str, MessageEventContent] + ) -> Tuple[str, Optional[List[TypeMessageEntity]]]: + try: + if isinstance(event, str): + 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: + message, entities = formatter.matrix_text_to_telegram(event.body) + except KeyError: + message, entities = None, None + return message, entities + + async def _handle_matrix_text(self, sender_id: TelegramID, event_id: EventID, + space: TelegramID, client: 'MautrixTelegramClient', + content: TextMessageEventContent, reply_to: TelegramID) -> None: + async with self.send_lock(sender_id): + lp = self.get_config("telegram_link_preview") + if content.get_edit(): + orig_msg = DBMessage.get_by_mxid(content.get_edit(), self.mxid, space) + if orig_msg: + response = await client.edit_message(self.peer, orig_msg.tgid, content, + parse_mode=self._matrix_event_to_entities, + link_preview=lp) + self._add_telegram_message_to_db(event_id, space, -1, response) + return + response = await client.send_message(self.peer, content, reply_to=reply_to, + parse_mode=self._matrix_event_to_entities, + link_preview=lp) + self._add_telegram_message_to_db(event_id, space, 0, response) + + async def _handle_matrix_file(self, sender_id: TelegramID, event_id: EventID, + space: TelegramID, client: 'MautrixTelegramClient', + content: MediaMessageEventContent, reply_to: TelegramID) -> None: + file = await self.main_intent.download_media(content.url) + + mime = content.info.mimetype + + w, h = content.info.width, content.info.height + + if content.msgtype == MessageType.STICKER: + if mime != "image/gif": + mime, file, w, h = util.convert_image(file, source_mime=mime, target_type="webp") + else: + # Remove sticker description + content["net.maunium.telegram.internal.filename"] = "sticker.gif" + content.body = "" + + file_name = self._get_file_meta(content["net.maunium.telegram.internal.filename"], mime) + attributes = [DocumentAttributeFilename(file_name=file_name)] + if w and h: + attributes.append(DocumentAttributeImageSize(w, h)) + + caption = content.body if content.body.lower() != file_name.lower() else None + + media = await client.upload_file_direct( + file, mime, attributes, file_name, + max_image_size=config["bridge.image_as_file_size"] * 1000 ** 2) + async with self.send_lock(sender_id): + if await self._matrix_document_edit(client, content, space, caption, media, event_id): + return + try: + response = await client.send_media(self.peer, media, reply_to=reply_to, + caption=caption) + except (PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, PhotoExtInvalidError): + media = InputMediaUploadedDocument(file=media.file, mime_type=mime, + attributes=attributes) + response = await client.send_media(self.peer, media, reply_to=reply_to, + caption=caption) + self._add_telegram_message_to_db(event_id, space, 0, response) + + async def _matrix_document_edit(self, client: 'MautrixTelegramClient', + content: MessageEventContent, space: TelegramID, + caption: str, media: Any, event_id: EventID) -> bool: + if content.get_edit(): + orig_msg = DBMessage.get_by_mxid(content.get_edit(), self.mxid, space) + if orig_msg: + response = await client.edit_message(self.peer, orig_msg.tgid, + caption, file=media) + self._add_telegram_message_to_db(event_id, space, -1, response) + return True + return False + + async def _handle_matrix_location(self, sender_id: TelegramID, event_id: EventID, + space: TelegramID, client: 'MautrixTelegramClient', + content: LocationMessageEventContent, reply_to: TelegramID + ) -> None: + try: + lat, long = content.geo_uri[len("geo:"):].split(",") + lat, long = float(lat), float(long) + except (KeyError, ValueError): + self.log.exception("Failed to parse location") + return None + caption, entities = self._matrix_event_to_entities(content) + media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0)) + + async with self.send_lock(sender_id): + if await self._matrix_document_edit(client, content, space, caption, media, event_id): + return + response = await client.send_media(self.peer, media, reply_to=reply_to, + caption=caption, entities=entities) + self._add_telegram_message_to_db(event_id, space, 0, response) + + def _add_telegram_message_to_db(self, event_id: EventID, space: TelegramID, + edit_index: int, response: TypeMessage) -> None: + self.log.debug("Handled Matrix message: %s", response) + self.dedup.check(response, (event_id, space), force_hash=edit_index != 0) + if edit_index < 0: + prev_edit = DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1) + edit_index = prev_edit.edit_index + 1 + DBMessage( + tgid=TelegramID(response.id), + tg_space=space, + mx_room=self.mxid, + mxid=event_id, + edit_index=edit_index).insert() + + async def handle_matrix_message(self, sender: 'u.User', content: MessageEventContent, + event_id: EventID) -> None: + if not content.body or not content.msgtype: + self.log.debug(f"Ignoring message {event_id} in {self.mxid} without body or msgtype") + return + + puppet = p.Puppet.get_by_custom_mxid(sender.mxid) + if puppet and content.get("net.maunium.telegram.puppet", False): + self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid) + return + + logged_in = not await sender.needs_relaybot(self) + client = sender.client if logged_in else self.bot.client + sender_id = sender.tgid if logged_in else self.bot.tgid + space = (self.tgid if self.peer_type == "channel" # Channels have their own ID space + else (sender.tgid if logged_in else self.bot.tgid)) + reply_to = formatter.matrix_reply_to_telegram(content, space, room_id=self.mxid) + + content["net.maunium.telegram.internal.filename"] = content.body + await self._pre_process_matrix_message(sender, not logged_in, content) + + if content.msgtype == MessageType.NOTICE: + bridge_notices = self.get_config("bridge_notices.default") + excepted = sender.mxid in self.get_config("bridge_notices.exceptions") + if not bridge_notices and not excepted: + return + + if content.msgtype in (MessageType.TEXT, MessageType.NOTICE): + await self._handle_matrix_text(sender_id, event_id, space, client, content, reply_to) + elif content.msgtype == MessageType.LOCATION: + await self._handle_matrix_location(sender_id, event_id, space, client, content, + reply_to) + elif content.msgtype in (MessageType.STICKER, MessageType.IMAGE, MessageType.FILE, + MessageType.AUDIO, MessageType.VIDEO): + await self._handle_matrix_file(sender_id, event_id, space, client, content, reply_to) + else: + self.log.debug(f"Unhandled Matrix event: {content}") + + async def handle_matrix_pin(self, sender: 'u.User', + pinned_message: Optional[EventID]) -> None: + if self.peer_type != "chat" and self.peer_type != "channel": + return + try: + if not pinned_message: + await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=0)) + else: + tg_space = self.tgid if self.peer_type == "channel" else sender.tgid + message = DBMessage.get_by_mxid(pinned_message, self.mxid, tg_space) + if message is None: + self.log.warning(f"Could not find pinned {pinned_message} in {self.mxid}") + return + await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=message.tgid)) + except ChatNotModifiedError: + pass + + async def handle_matrix_deletion(self, deleter: 'u.User', event_id: EventID) -> None: + real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot + space = self.tgid if self.peer_type == "channel" else real_deleter.tgid + message = DBMessage.get_by_mxid(event_id, self.mxid, space) + if not message: + return + if message.edit_index == 0: + await real_deleter.client.delete_messages(self.peer, [message.tgid]) + else: + self.log.debug(f"Ignoring deletion of edit event {message.mxid} in {message.mx_room}") + + async def _update_telegram_power_level(self, sender: 'u.User', user_id: TelegramID, + level: int) -> None: + moderator = level >= 50 + admin = level >= 75 + await sender.client.edit_admin(self.peer, user_id, + change_info=moderator, post_messages=moderator, + edit_messages=moderator, delete_messages=moderator, + ban_users=moderator, invite_users=moderator, + pin_messages=moderator, add_admins=admin) + + async def handle_matrix_power_levels(self, sender: 'u.User', new_users: Dict[UserID, int], + old_users: Dict[UserID, int]) -> None: + # TODO handle all power level changes and bridge exact admin rights to supergroups/channels + for user, level in new_users.items(): + if not user or user == self.main_intent.mxid or user == sender.mxid: + continue + user_id = p.Puppet.get_id_from_mxid(user) + if not user_id: + mx_user = u.User.get_by_mxid(user, create=False) + if not mx_user or not mx_user.tgid: + continue + user_id = mx_user.tgid + if not user_id or user_id == sender.tgid: + continue + if user not in old_users or level != old_users[user]: + await self._update_telegram_power_level(sender, user_id, level) + + async def handle_matrix_about(self, sender: 'u.User', about: str) -> None: + if self.peer_type not in ("chat", "channel"): + return + peer = await self.get_input_entity(sender) + await sender.client(EditChatAboutRequest(peer=peer, about=about)) + self.about = about + self.save() + + async def handle_matrix_title(self, sender: 'u.User', title: str) -> None: + if self.peer_type not in ("chat", "channel"): + return + + if self.peer_type == "chat": + response = await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title)) + else: + channel = await self.get_input_entity(sender) + response = await sender.client(EditTitleRequest(channel=channel, title=title)) + self.dedup.register_outgoing_actions(response) + self.title = title + self.save() + + async def handle_matrix_avatar(self, sender: 'u.User', url: ContentURI) -> None: + if self.peer_type not in ("chat", "channel"): + # Invalid peer type + return + + file = await self.main_intent.download_media(url) + mime = magic.from_buffer(file, mime=True) + ext = sane_mimetypes.guess_extension(mime) + uploaded = await sender.client.upload_file(file, file_name=f"avatar{ext}") + photo = InputChatUploadedPhoto(file=uploaded) + + if self.peer_type == "chat": + response = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo)) + else: + channel = await self.get_input_entity(sender) + response = await sender.client(EditPhotoRequest(channel=channel, photo=photo)) + self.dedup.register_outgoing_actions(response) + for update in response.updates: + is_photo_update = (isinstance(update, UpdateNewMessage) + and isinstance(update.message, MessageService) + and isinstance(update.message.action, MessageActionChatEditPhoto)) + if is_photo_update: + loc, size = self._get_largest_photo_size(update.message.action.photo) + self.photo_id = f"{size.location.volume_id}-{size.location.local_id}" + self.save() + break + + async def handle_matrix_upgrade(self, new_room: RoomID) -> None: + old_room = self.mxid + self.migrate_and_save_matrix(new_room) + await self.main_intent.join_room(new_room) + entity: Optional[TypeInputPeer] = None + user: Optional[AbstractUser] = None + if self.bot and self.has_bot: + user = self.bot + entity = await self.get_input_entity(self.bot) + if not entity: + user_mxids = await self.main_intent.get_room_members(self.mxid) + for user_str in user_mxids: + user_id = UserID(user_str) + if user_id == self.az.bot_mxid: + continue + user = u.User.get_by_mxid(user_id, create=False) + if user and user.tgid: + entity = await self.get_input_entity(user) + if entity: + break + if not entity: + self.log.error("Failed to fully migrate to upgraded Matrix room: " + "no Telegram user found.") + return + await self.update_matrix_room(user, entity, direct=self.peer_type == "user") + self.log.info(f"Upgraded room from {old_room} to {self.mxid}") + + def migrate_and_save_matrix(self, new_id: RoomID) -> None: + try: + del self.by_mxid[self.mxid] + except KeyError: + pass + self.mxid = new_id + self.db_instance.edit(mxid=self.mxid) + self.by_mxid[self.mxid] = self + + +def init(context: Context) -> None: + global config + config = context.config diff --git a/mautrix_telegram/portal/metadata.py b/mautrix_telegram/portal/metadata.py new file mode 100644 index 00000000..e5f3ccea --- /dev/null +++ b/mautrix_telegram/portal/metadata.py @@ -0,0 +1,666 @@ +# 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 . +from typing import List, Optional, Tuple, Union, TYPE_CHECKING +from abc import ABC +import asyncio + +from telethon.tl.functions.messages import (AddChatUserRequest, CreateChatRequest, + GetFullChatRequest, MigrateChatRequest) +from telethon.tl.functions.channels import (CreateChannelRequest, GetParticipantsRequest, + InviteToChannelRequest, UpdateUsernameRequest) +from telethon.errors import ChatAdminRequiredError +from telethon.tl.types import ( + Channel, ChatBannedRights, ChannelParticipantsRecent, ChannelParticipantsSearch, ChatPhoto, + PhotoEmpty, InputChannel, InputUser, ChatPhotoEmpty, PeerUser, Photo, TypeChat, TypeInputPeer, + TypeUser, User, InputPeerPhotoFileLocation, ChatParticipantAdmin, ChannelParticipantAdmin, + ChatParticipantCreator, ChannelParticipantCreator) + +from mautrix.errors import MForbidden +from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership, Member, + PowerLevelStateEventContent, RoomAlias) + +from ..types import TelegramID +from ..context import Context +from .. import puppet as p, user as u, util +from .base import BasePortal, InviteList, TypeParticipant, TypeChatPhoto + +if TYPE_CHECKING: + from ..abstract_user import AbstractUser + from ..config import Config + +config: Optional['Config'] = None + + +class PortalMetadata(BasePortal, ABC): + _room_create_lock: asyncio.Lock + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._room_create_lock = asyncio.Lock() + + # region Matrix -> Telegram + + async def _get_telegram_users_in_matrix_room(self) -> List[Union[InputUser, PeerUser]]: + user_tgids = set() + user_mxids = await self.main_intent.get_room_members(self.mxid, (Membership.JOIN, + Membership.INVITE)) + for user_str in user_mxids: + user = UserID(user_str) + if user == self.az.bot_mxid: + continue + mx_user = u.User.get_by_mxid(user, create=False) + if mx_user and mx_user.tgid: + user_tgids.add(mx_user.tgid) + puppet_id = p.Puppet.get_id_from_mxid(user) + if puppet_id: + user_tgids.add(puppet_id) + return [PeerUser(user_id) for user_id in user_tgids] + + async def upgrade_telegram_chat(self, source: 'u.User') -> None: + if self.peer_type != "chat": + raise ValueError("Only normal group chats are upgradable to supergroups.") + + response = await source.client(MigrateChatRequest(chat_id=self.tgid)) + entity = None + for chat in response.chats: + if isinstance(chat, Channel): + entity = chat + break + if not entity: + raise ValueError("Upgrade may have failed: output channel not found.") + self.peer_type = "channel" + self._migrate_and_save_telegram(TelegramID(entity.id)) + await self.update_info(source, entity) + + def _migrate_and_save_telegram(self, new_id: TelegramID) -> None: + try: + del self.by_tgid[self.tgid_full] + except KeyError: + pass + try: + existing = self.by_tgid[(new_id, new_id)] + existing.delete() + except KeyError: + pass + self.db_instance.edit(tgid=new_id, tg_receiver=new_id, peer_type=self.peer_type) + old_id = self.tgid + self.tgid = new_id + self.tg_receiver = new_id + self.by_tgid[self.tgid_full] = self + self.log = self.base_log.getChild(self.tgid_log) + self.log.info(f"Telegram chat upgraded from {old_id}") + + async def set_telegram_username(self, source: 'u.User', username: str) -> None: + if self.peer_type != "channel": + raise ValueError("Only channels and supergroups have usernames.") + await source.client( + UpdateUsernameRequest(await self.get_input_entity(source), username)) + if await self._update_username(username): + self.save() + + async def create_telegram_chat(self, source: 'u.User', supergroup: bool = False) -> None: + if not self.mxid: + raise ValueError("Can't create Telegram chat for portal without Matrix room.") + elif self.tgid: + raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.") + + invites = await self._get_telegram_users_in_matrix_room() + if len(invites) < 2: + if self.bot is not None: + info, mxid = await self.bot.get_me() + raise ValueError("Not enough Telegram users to create a chat. " + "Invite more Telegram ghost users to the room, such as the " + f"relaybot ([{info.first_name}](https://matrix.to/#/{mxid})).") + raise ValueError("Not enough Telegram users to create a chat. " + "Invite more Telegram ghost users to the room.") + if self.peer_type == "chat": + response = await source.client(CreateChatRequest(title=self.title, users=invites)) + entity = response.chats[0] + elif self.peer_type == "channel": + response = await source.client(CreateChannelRequest(title=self.title, + about=self.about or "", + megagroup=supergroup)) + entity = response.chats[0] + await source.client(InviteToChannelRequest( + channel=await source.client.get_input_entity(entity), + users=invites)) + else: + raise ValueError("Invalid peer type for Telegram chat creation") + + self.tgid = entity.id + self.tg_receiver = self.tgid + self.by_tgid[self.tgid_full] = self + await self.update_info(source, entity) + self.db_instance.insert() + self.log = self.base_log.getChild(self.tgid_log) + + if self.bot and self.bot.tgid in invites: + self.bot.add_chat(self.tgid, self.peer_type) + + levels = await self.main_intent.get_power_levels(self.mxid) + if levels.get_user_level(self.main_intent.mxid) == 100: + levels = self._get_base_power_levels(levels, entity) + await self.main_intent.set_power_levels(self.mxid, levels) + await self.handle_matrix_power_levels(source, levels.users, {}) + + async def invite_telegram(self, source: 'u.User', + puppet: Union[p.Puppet, 'AbstractUser']) -> None: + if self.peer_type == "chat": + await source.client( + AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0)) + elif self.peer_type == "channel": + await source.client(InviteToChannelRequest(channel=self.peer, users=[puppet.tgid])) + else: + raise ValueError("Invalid peer type for Telegram user invite") + + async def sync_matrix_members(self) -> None: + resp = await self.main_intent.get_room_joined_memberships(self.mxid) + members = resp["joined"] + for mxid, info in members.items(): + member = Member(membership=Membership.JOIN) + if "display_name" in info: + member.displayname = info["display_name"] + if "avatar_url" in info: + member.avatar_url = info["avatar_url"] + self.az.state_store.set_member(self.mxid, mxid, member) + + # endregion + # region Telegram -> Matrix + + async def invite_to_matrix(self, users: InviteList) -> None: + if isinstance(users, list): + for user in users: + await self.main_intent.invite_user(self.mxid, user, check_cache=True) + else: + await self.main_intent.invite_user(self.mxid, users, check_cache=True) + + async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User], + direct: bool = None, puppet: p.Puppet = None, + levels: PowerLevelStateEventContent = None, + users: List[User] = None, + participants: List[TypeParticipant] = None) -> None: + if direct is None: + direct = self.peer_type == "user" + try: + await self._update_matrix_room(user, entity, direct, puppet, levels, users, + participants) + except Exception: + self.log.exception("Fatal error updating Matrix room") + + async def _update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User], + direct: bool, puppet: p.Puppet = None, + levels: PowerLevelStateEventContent = None, + users: List[User] = None, + participants: List[TypeParticipant] = None) -> None: + if not direct: + await self.update_info(user, entity) + if not users or not participants: + users, participants = await self._get_users(user, entity) + await self._sync_telegram_users(user, users) + await self.update_telegram_participants(participants, levels) + else: + if not puppet: + puppet = p.Puppet.get(self.tgid) + await puppet.update_info(user, entity) + await puppet.intent_for(self).join_room(self.mxid) + if self.sync_matrix_state: + await self.sync_matrix_members() + + async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None, + invites: InviteList = None, update_if_exists: bool = True, + synchronous: bool = False) -> Optional[str]: + if self.mxid: + if update_if_exists: + if not entity: + entity = await self.get_entity(user) + update = self.update_matrix_room(user, entity, self.peer_type == "user") + if synchronous: + await update + else: + asyncio.ensure_future(update, loop=self.loop) + await self.invite_to_matrix(invites or []) + return self.mxid + async with self._room_create_lock: + try: + return await self._create_matrix_room(user, entity, invites) + except Exception: + self.log.exception("Fatal error creating Matrix room") + + async def _create_matrix_room(self, user: 'AbstractUser', entity: TypeChat, invites: InviteList + ) -> Optional[RoomID]: + direct = self.peer_type == "user" + + if self.mxid: + return self.mxid + + if not self.allow_bridging: + return None + + if not entity: + entity = await self.get_entity(user) + self.log.debug(f"Fetched data: {entity}") + + self.log.debug("Creating room") + + try: + self.title = entity.title + except AttributeError: + self.title = None + + if direct and self.tgid == user.tgid: + self.title = "Telegram Saved Messages" + self.about = "Your Telegram cloud storage chat" + + puppet = p.Puppet.get(self.tgid) if direct else None + self._main_intent = puppet.intent_for(self) if direct else self.az.intent + + if self.peer_type == "channel": + self.megagroup = entity.megagroup + + if self.peer_type == "channel" and entity.username: + preset = RoomCreatePreset.PUBLIC + alias = self._get_alias_localpart(entity.username) + self.username = entity.username + else: + preset = RoomCreatePreset.PRIVATE + # TODO invite link alias? + alias = None + + if alias: + # TODO? properly handle existing room aliases + await self.main_intent.remove_room_alias(alias) + + power_levels = self._get_base_power_levels(entity=entity) + users = participants = None + if not direct: + users, participants = await self._get_users(user, entity) + self._participants_to_power_levels(participants, power_levels) + initial_state = [{ + "type": EventType.ROOM_POWER_LEVELS.serialize(), + "content": power_levels.serialize(), + }] + if config["appservice.community_id"]: + initial_state.append({ + "type": "m.room.related_groups", + "content": {"groups": [config["appservice.community_id"]]}, + }) + + room_id = await self.main_intent.create_room(alias_localpart=alias, preset=preset, + is_direct=direct, invitees=invites or [], + name=self.title, topic=self.about, + initial_state=initial_state) + if not room_id: + raise Exception(f"Failed to create room") + + self.mxid = RoomID(room_id) + self.by_mxid[self.mxid] = self + self.save() + self.az.state_store.set_power_levels(self.mxid, power_levels) + user.register_portal(self) + asyncio.ensure_future(self.update_matrix_room(user, entity, direct, puppet, + levels=power_levels, users=users, + participants=participants), loop=self.loop) + + return self.mxid + + def _get_base_power_levels(self, levels: PowerLevelStateEventContent = None, + entity: TypeChat = None) -> PowerLevelStateEventContent: + levels = levels or PowerLevelStateEventContent() + if self.peer_type == "user": + levels.ban = 100 + levels.kick = 100 + levels.invite = 100 + levels.redact = 0 + levels.events[EventType.ROOM_NAME] = 0 + levels.events[EventType.ROOM_AVATAR] = 0 + levels.events[EventType.ROOM_TOPIC] = 0 + levels.state_default = 0 + levels.users_default = 0 + levels.events_default = 0 + else: + dbr = entity.default_banned_rights + if not dbr: + self.log.debug(f"default_banned_rights is None in {entity}") + dbr = ChatBannedRights(invite_users=True, change_info=True, pin_messages=True, + send_stickers=False, send_messages=False, until_date=None) + levels.ban = 99 + levels.kick = 50 + levels.redact = 50 + levels.invite = 50 if dbr.invite_users else 0 + levels.events[EventType.ROOM_ENCRYPTED] = 99 + levels.events[EventType.ROOM_TOMBSTONE] = 99 + levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0 + levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0 + levels.events[EventType.ROOM_TOPIC] = 50 if dbr.change_info else 0 + levels.events[EventType.ROOM_PINNED_EVENTS] = 50 if dbr.pin_messages else 0 + levels.events[EventType.ROOM_POWER_LEVELS] = 75 + levels.events[EventType.ROOM_HISTORY_VISIBILITY] = 75 + levels.state_default = 50 + levels.users_default = 0 + levels.events_default = (50 if (self.peer_type == "channel" and not entity.megagroup + or entity.default_banned_rights.send_messages) + else 0) + levels.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default + levels.users[self.main_intent.mxid] = 100 + return levels + + @staticmethod + def _get_level_from_participant(participant: TypeParticipant) -> int: + # TODO use the power level requirements to get better precision in channels + if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)): + return 50 + elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)): + return 95 + return 0 + + @staticmethod + def _participant_to_power_levels(levels: PowerLevelStateEventContent, + user: Union['u.User', p.Puppet], new_level: int, + bot_level: int) -> bool: + new_level = min(new_level, bot_level) + user_level = levels.get_user_level(user.mxid) + if user_level != new_level and user_level < bot_level: + levels.users[user.mxid] = new_level + return True + return False + + def _participants_to_power_levels(self, participants: List[TypeParticipant], + levels: PowerLevelStateEventContent) -> bool: + bot_level = levels.get_user_level(self.main_intent.mxid) + if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS): + return False + changed = False + admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level) + if levels.events[EventType.ROOM_POWER_LEVELS] != admin_power_level: + changed = True + levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level + + for participant in participants: + puppet = p.Puppet.get(TelegramID(participant.user_id)) + user = u.User.get_by_tgid(TelegramID(participant.user_id)) + new_level = self._get_level_from_participant(participant) + + if user: + user.register_portal(self) + changed = self._participant_to_power_levels(levels, user, new_level, + bot_level) or changed + + if puppet: + changed = self._participant_to_power_levels(levels, puppet, new_level, + bot_level) or changed + return changed + + async def update_telegram_participants(self, participants: List[TypeParticipant], + levels: PowerLevelStateEventContent = None) -> None: + if not levels: + levels = await self.main_intent.get_power_levels(self.mxid) + if self._participants_to_power_levels(participants, levels): + await self.main_intent.set_power_levels(self.mxid, levels) + + @property + def alias(self) -> Optional[RoomAlias]: + if not self.username: + return None + return RoomAlias(f"#{self._get_alias_localpart()}:{self.hs_domain}") + + def _get_alias_localpart(self, username: Optional[str] = None) -> Optional[str]: + username = username or self.username + if not username: + return None + return self.alias_template.format(username) + + def _add_bot_chat(self, bot: User) -> None: + if self.bot and bot.id == self.bot.tgid: + self.bot.add_chat(self.tgid, self.peer_type) + return + + user = u.User.get_by_tgid(TelegramID(bot.id)) + if user and user.is_bot: + user.register_portal(self) + + async def _sync_telegram_users(self, source: 'AbstractUser', users: List[User]) -> None: + allowed_tgids = set() + skip_deleted = config["bridge.skip_deleted_members"] + for entity in users: + if skip_deleted and entity.deleted: + continue + puppet = p.Puppet.get(TelegramID(entity.id)) + if entity.bot: + self._add_bot_chat(entity) + allowed_tgids.add(entity.id) + await puppet.intent_for(self).ensure_joined(self.mxid) + await puppet.update_info(source, entity) + + user = u.User.get_by_tgid(TelegramID(entity.id)) + if user: + await self.invite_to_matrix(user.mxid) + + # We can't trust the member list if any of the following cases is true: + # * There are close to 10 000 users, because Telegram might not be sending all members. + # * The member sync count is limited, because then we might ignore some members. + # * It's a channel, because non-admins don't have access to the member list. + trust_member_list = (len(allowed_tgids) < 9900 + and self.max_initial_member_sync == -1 + and (self.megagroup or self.peer_type != "channel")) + if trust_member_list: + joined_mxids = await self.main_intent.get_room_members(self.mxid) + for user_mxid in joined_mxids: + if user_mxid == self.az.bot_mxid: + continue + puppet_id = p.Puppet.get_id_from_mxid(user_mxid) + if puppet_id and puppet_id not in allowed_tgids: + if self.bot and puppet_id == self.bot.tgid: + self.bot.remove_chat(self.tgid) + await self.main_intent.kick_user(self.mxid, user_mxid, + "User had left this Telegram chat.") + continue + 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: + mx_user.unregister_portal(self) + + 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, + "You had left this Telegram chat.") + continue + + async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None + ) -> None: + puppet = p.Puppet.get(user_id) + if source: + entity: User = await source.client.get_entity(PeerUser(user_id)) + await puppet.update_info(source, entity) + await puppet.intent_for(self).ensure_joined(self.mxid) + + user = u.User.get_by_tgid(user_id) + if user: + user.register_portal(self) + await self.invite_to_matrix(user.mxid) + + async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None: + puppet = p.Puppet.get(user_id) + user = u.User.get_by_tgid(user_id) + kick_message = (f"Kicked by {sender.displayname}" + if sender and sender.tgid != puppet.tgid + else "Left Telegram chat") + if sender.tgid != puppet.tgid: + try: + await sender.intent_for(self).kick_user(self.mxid, puppet.mxid) + except MForbidden: + await self.main_intent.kick_user(self.mxid, puppet.mxid, kick_message) + else: + await puppet.intent_for(self).leave_room(self.mxid) + if user: + user.unregister_portal(self) + if sender.tgid != puppet.tgid: + try: + await sender.intent_for(self).kick_user(self.mxid, puppet.mxid) + return + except MForbidden: + pass + try: + await self.main_intent.kick_user(self.mxid, user.mxid, kick_message) + except MForbidden as e: + self.log.warning(f"Failed to kick {user.mxid}: {e}") + + async def update_info(self, user: 'AbstractUser', entity: TypeChat = None) -> None: + if self.peer_type == "user": + self.log.warning("Called update_info() for direct chat portal") + return + + self.log.debug("Updating info") + if not entity: + entity = await self.get_entity(user) + self.log.debug(f"Fetched data: {entity}") + changed = False + + if self.peer_type == "channel": + changed = await self._update_username(entity.username) or changed + + if hasattr(entity, "about"): + changed = self._update_about(entity.about) or changed + + changed = await self._update_title(entity.title) or changed + + if isinstance(entity.photo, ChatPhoto): + changed = await self._update_avatar(user, entity.photo) or changed + + if changed: + self.save() + + async def _update_username(self, username: str, save: bool = False) -> bool: + if self.username == username: + return False + + if self.username: + await self.main_intent.remove_room_alias(self._get_alias_localpart()) + self.username = username or None + if self.username: + await self.main_intent.add_room_alias(self.mxid, self._get_alias_localpart(), + override=True) + if self.public_portals: + await self.main_intent.set_join_rule(self.mxid, "public") + else: + await self.main_intent.set_join_rule(self.mxid, "invite") + + if save: + self.save() + return True + + async def _update_about(self, about: str, save: bool = False) -> bool: + if self.about == about: + return False + + self.about = about + await self.main_intent.set_room_topic(self.mxid, self.about) + if save: + self.save() + return True + + async def _update_title(self, title: str, save: bool = False) -> bool: + if self.title == title: + return False + + self.title = title + await self.main_intent.set_room_name(self.mxid, self.title) + if save: + self.save() + return True + + async def _update_avatar(self, user: 'AbstractUser', photo: TypeChatPhoto, save: bool = False + ) -> bool: + if isinstance(photo, ChatPhoto): + loc = InputPeerPhotoFileLocation( + peer=await self.get_input_entity(user), + local_id=photo.photo_big.local_id, + volume_id=photo.photo_big.volume_id, + big=True + ) + photo_id = f"{loc.volume_id}-{loc.local_id}" + elif isinstance(photo, Photo): + loc, largest = self._get_largest_photo_size(photo) + photo_id = f"{largest.location.volume_id}-{largest.location.local_id}" + elif isinstance(photo, (ChatPhotoEmpty, PhotoEmpty)): + photo_id = "" + loc = None + else: + raise ValueError(f"Unknown photo type {type(photo)}") + if self.photo_id != photo_id: + if not photo_id: + await self.main_intent.set_room_avatar(self.mxid, None) + self.photo_id = "" + if save: + self.save() + return True + file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc) + if file: + await self.main_intent.set_room_avatar(self.mxid, file.mxc) + self.photo_id = photo_id + if save: + self.save() + return True + return False + + async def _get_users(self, user: 'AbstractUser', + entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser, InputChannel] + ) -> Tuple[List[TypeUser], List[TypeParticipant]]: + # TODO replace with client.get_participants + if self.peer_type == "chat": + chat = await user.client(GetFullChatRequest(chat_id=self.tgid)) + return chat.users, chat.full_chat.participants.participants + elif self.peer_type == "channel": + if not self.megagroup and not self.sync_channel_members: + return [], [] + + limit = self.max_initial_member_sync + if limit == 0: + return [], [] + + try: + if 0 < limit <= 200: + response = await user.client(GetParticipantsRequest( + entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0)) + return response.users, response.participants + elif limit > 200 or limit == -1: + users: List[TypeUser] = [] + participants: List[TypeParticipant] = [] + offset = 0 + remaining_quota = limit if limit > 0 else 1000000 + query = (ChannelParticipantsSearch("") if limit == -1 + else ChannelParticipantsRecent()) + while True: + if remaining_quota <= 0: + break + response = await user.client(GetParticipantsRequest( + entity, query, offset=offset, limit=min(remaining_quota, 100), hash=0)) + if not response.users: + break + participants += response.participants + users += response.users + offset += len(response.participants) + remaining_quota -= len(response.participants) + return users, participants + except ChatAdminRequiredError: + return [], [] + elif self.peer_type == "user": + return [entity], [] + return [], [] + + # endregion + + +def init(context: Context) -> None: + global config + config = context.config diff --git a/mautrix_telegram/portal/send_lock.py b/mautrix_telegram/portal/send_lock.py new file mode 100644 index 00000000..c760f44b --- /dev/null +++ b/mautrix_telegram/portal/send_lock.py @@ -0,0 +1,44 @@ +# 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 . +from typing import Dict +from asyncio import Lock + +from ..types import TelegramID + + +class FakeLock: + async def __aenter__(self) -> None: + pass + + async def __aexit__(self, exc_type, exc, tb) -> None: + pass + + +class PortalSendLock: + _send_locks: Dict[int, Lock] + _noop_lock: Lock = FakeLock() + + def __init__(self) -> None: + self._send_locks = {} + + def __call__(self, user_id: TelegramID, required: bool = True) -> Lock: + if user_id is None and required: + raise ValueError("Required send lock for none id") + try: + return self._send_locks[user_id] + except KeyError: + return (self._send_locks.setdefault(user_id, Lock()) + if required else self._noop_lock) diff --git a/mautrix_telegram/portal/telegram.py b/mautrix_telegram/portal/telegram.py new file mode 100644 index 00000000..1aee58b8 --- /dev/null +++ b/mautrix_telegram/portal/telegram.py @@ -0,0 +1,556 @@ +# 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 . +from typing import Awaitable, Dict, List, Optional, Tuple, Union, NamedTuple, TYPE_CHECKING +from html import escape as escape_html +from abc import ABC +import random +import mimetypes +import codecs +import unicodedata +import base64 + +from sqlalchemy.exc import IntegrityError + +from telethon.tl.patched import Message, MessageService +from telethon.tl.types import ( + Poll, DocumentAttributeFilename, DocumentAttributeSticker, DocumentAttributeVideo, + MessageMediaPoll, MessageActionChannelCreate, MessageActionChatAddUser, + MessageActionChatCreate, MessageActionChatDeletePhoto, MessageActionChatDeleteUser, + MessageActionChatEditPhoto, MessageActionChatEditTitle, MessageActionChatJoinedByLink, + MessageActionChatMigrateTo, MessageActionPinMessage, MessageActionGameScore, + MessageMediaDocument, MessageMediaGeo, MessageMediaPhoto, MessageMediaUnsupported, + MessageMediaGame, PeerUser, PhotoCachedSize, TypeChannelParticipant, TypeChatParticipant, + TypeDocumentAttribute, TypeMessageAction, TypePhotoSize, PhotoSize, UpdateChatUserTyping, + UpdateUserTyping, MessageEntityPre, ChatPhotoEmpty) + +from mautrix.appservice import IntentAPI +from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType, + EventType, MediaMessageEventContent, TextMessageEventContent, + LocationMessageEventContent, Format) + +from ..types import TelegramID +from ..db import Message as DBMessage, TelegramFile as DBTelegramFile +from ..util import sane_mimetypes +from ..context import Context +from .. import puppet as p, user as u, formatter, util +from .base import BasePortal + +if TYPE_CHECKING: + from ..abstract_user import AbstractUser + from ..config import Config + +InviteList = Union[UserID, List[UserID]] +TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant] +DocAttrs = NamedTuple("DocAttrs", name=Optional[str], mime_type=Optional[str], is_sticker=bool, + sticker_alt=Optional[str], width=int, height=int) + +config: Optional['Config'] = None + + +class PortalTelegram(BasePortal, ABC): + _temp_pinned_message_id: Optional[TelegramID] + _temp_pinned_message_id_space: Optional[TelegramID] + _temp_pinned_message_sender: Optional['p.Puppet'] + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._temp_pinned_message_id = None + self._temp_pinned_message_id_space = None + self._temp_pinned_message_sender = None + + async def handle_telegram_typing(self, user: p.Puppet, + _: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None: + await user.intent_for(self).set_typing(self.mxid, is_typing=True) + + def _get_external_url(self, evt: Message) -> Optional[str]: + if self.peer_type == "channel" and self.username is not None: + return f"https://t.me/{self.username}/{evt.id}" + elif self.peer_type != "user": + return f"https://t.me/c/{self.tgid}/{evt.id}" + return None + + async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, + relates_to: Dict = None) -> Optional[EventID]: + loc, largest_size = self._get_largest_photo_size(evt.media.photo) + file = await util.transfer_file_to_matrix(source.client, intent, loc) + if not file: + return None + if self.get_config("inline_images") and (evt.message + or evt.fwd_from or evt.reply_to_msg_id): + content = await formatter.telegram_to_matrix( + evt, source, self.main_intent, + prefix_html=f"Inline Telegram photo
", + prefix_text="Inline image: ") + content.external_url = self._get_external_url(evt) + await intent.set_typing(self.mxid, is_typing=False) + return await intent.send_message(self.mxid, content, timestamp=evt.date) + info = ImageInfo( + height=largest_size.h, width=largest_size.w, orientation=0, mimetype=file.mime_type, + size=(len(largest_size.bytes) if (isinstance(largest_size, PhotoCachedSize)) + else largest_size.size)) + name = f"image{sane_mimetypes.guess_extension(file.mime_type)}" + await intent.set_typing(self.mxid, is_typing=False) + content = MediaMessageEventContent(url=file.mxc, msgtype=MessageType.IMAGE, info=info, + body=name, relates_to=relates_to, + external_url=self._get_external_url(evt)) + result = await intent.send_message(self.mxid, content, timestamp=evt.date) + if evt.message: + caption_content = await formatter.telegram_to_matrix(evt, source, self.main_intent, + no_reply_fallback=True) + caption_content.external_url = content.external_url + result = await intent.send_message(self.mxid, caption_content, timestamp=evt.date) + return result + + @staticmethod + def _parse_telegram_document_attributes(attributes: List[TypeDocumentAttribute]) -> DocAttrs: + name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0 + for attr in attributes: + if isinstance(attr, DocumentAttributeFilename): + name = name or attr.file_name + mime_type, _ = mimetypes.guess_type(attr.file_name) + elif isinstance(attr, DocumentAttributeSticker): + is_sticker = True + sticker_alt = attr.alt + elif isinstance(attr, DocumentAttributeVideo): + width, height = attr.w, attr.h + return DocAttrs(name, mime_type, is_sticker, sticker_alt, width, height) + + @staticmethod + def _parse_telegram_document_meta(evt: Message, file: DBTelegramFile, attrs: DocAttrs, + thumb_size: TypePhotoSize) -> Tuple[ImageInfo, str]: + document = evt.media.document + name = evt.message or attrs.name + if attrs.is_sticker: + alt = attrs.sticker_alt + if len(alt) > 0: + try: + name = f"{alt} ({unicodedata.name(alt[0]).lower()})" + except ValueError: + name = alt + + generic_types = ("text/plain", "application/octet-stream") + if file.mime_type in generic_types and document.mime_type not in generic_types: + mime_type = document.mime_type or file.mime_type + else: + mime_type = file.mime_type or document.mime_type + info = ImageInfo(size=file.size, mimetype=mime_type) + + if attrs.mime_type and not file.was_converted: + file.mime_type = attrs.mime_type or file.mime_type + if file.width and file.height: + info.width, info.height = file.width, file.height + elif attrs.width and attrs.height: + info.width, info.height = attrs.width, attrs.height + + if file.thumbnail: + info.thumbnail_url = file.thumbnail.mxc + info.thumbnail_info = ThumbnailInfo(mimetype=file.thumbnail.mime_type, + height=file.thumbnail.height or thumb_size.h, + width=file.thumbnail.width or thumb_size.w, + size=file.thumbnail.size) + + return info, name + + async def handle_telegram_document(self, source: 'AbstractUser', intent: IntentAPI, + evt: Message, relates_to: RelatesTo = None + ) -> Optional[EventID]: + document = evt.media.document + + attrs = self._parse_telegram_document_attributes(document.attributes) + + if document.size > config["bridge.max_document_size"] * 1000 ** 2: + name = attrs.name or "" + caption = f"\n{evt.message}" if evt.message else "" + return await intent.send_notice(self.mxid, f"Too large file {name}{caption}") + + thumb_loc, thumb_size = self._get_largest_photo_size(document) + if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)): + self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}") + thumb_loc = None + thumb_size = None + file = await util.transfer_file_to_matrix(source.client, intent, document, thumb_loc, + is_sticker=attrs.is_sticker) + if not file: + return None + + info, name = self._parse_telegram_document_meta(evt, file, attrs, thumb_size) + + await intent.set_typing(self.mxid, is_typing=False) + + event_type = EventType.STICKER if attrs.is_sticker else EventType.ROOM_MESSAGE + content = MediaMessageEventContent( + body=name or "unnamed file", info=info, url=file.mxc, relates_to=relates_to, + external_url=self._get_external_url(evt), + msgtype={ + "video/": MessageType.VIDEO, + "audio/": MessageType.AUDIO, + "image/": MessageType.IMAGE, + }.get(info.mimetype[:6], MessageType.FILE)) + return await intent.send_message_event(self.mxid, event_type, content, timestamp=evt.date) + + def handle_telegram_location(self, _: 'AbstractUser', intent: IntentAPI, evt: Message, + relates_to: dict = None) -> Awaitable[EventID]: + long = evt.media.geo.long + lat = evt.media.geo.lat + long_char = "E" if long > 0 else "W" + lat_char = "N" if lat > 0 else "S" + + body = f"{round(lat, 5)}° {lat_char}, {round(long, 5)}° {long_char}" + url = f"https://maps.google.com/?q={lat},{long}" + + content = LocationMessageEventContent( + msgtype=MessageType.LOCATION, geo_uri=f"geo:{lat},{long}", + body=f"Location: {body}\n{url}", + relates_to=relates_to, external_url=self._get_external_url(evt)) + content["format"] = Format.HTML + content["formatted_body"] = f"Location: {body}" + + return intent.send_message(self.mxid, content, timestamp=evt.date) + + async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool, + evt: Message) -> EventID: + self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}") + content = await formatter.telegram_to_matrix(evt, source, self.main_intent) + content.external_url = self._get_external_url(evt) + if is_bot and self.get_config("bot_messages_as_notices"): + content.msgtype = MessageType.NOTICE + await intent.set_typing(self.mxid, is_typing=False) + return await intent.send_message(self.mxid, content, timestamp=evt.date) + + async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI, + evt: Message, relates_to: dict = None) -> EventID: + override_text = ("This message is not supported on your version of Mautrix-Telegram. " + "Please check https://github.com/tulir/mautrix-telegram or ask your " + "bridge administrator about possible updates.") + content = await formatter.telegram_to_matrix( + evt, source, self.main_intent, override_text=override_text) + content.msgtype = MessageType.NOTICE + content.external_url = self._get_external_url(evt) + content["net.maunium.telegram.unsupported"] = True + await intent.set_typing(self.mxid, is_typing=False) + return await intent.send_message(self.mxid, content, timestamp=evt.date) + + async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, + relates_to: RelatesTo) -> EventID: + poll: Poll = evt.media.poll + poll_id = self._encode_msgid(source, evt) + + _n = 0 + + def n() -> int: + nonlocal _n + _n += 1 + return _n + + text_answers = "\n".join(f"{n()}. {answer.text}" for answer in poll.answers) + html_answers = "\n".join(f"
  • {answer.text}
  • " for answer in poll.answers) + content = TextMessageEventContent( + msgtype=MessageType.TEXT, format=Format.HTML, + body=f"Poll: {poll.question}\n{text_answers}\n" + f"Vote with !tg vote {poll_id} ", + formatted_body=f"Poll: {poll.question}
    \n" + f"
      {html_answers}
    \n" + f"Vote with !tg vote {poll_id} <choice number>", + relates_to=relates_to, external_url=self._get_external_url(evt)) + + await intent.set_typing(self.mxid, is_typing=False) + return await intent.send_message(self.mxid, content, timestamp=evt.date) + + @staticmethod + def _int_to_bytes(i: int) -> bytes: + hex_value = "{0:010x}".format(i) + return codecs.decode(hex_value, "hex_codec") + + def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str: + if self.peer_type == "channel": + play_id = (b"c" + + self._int_to_bytes(self.tgid) + + self._int_to_bytes(evt.id)) + elif self.peer_type == "chat": + play_id = (b"g" + + self._int_to_bytes(self.tgid) + + self._int_to_bytes(evt.id) + + self._int_to_bytes(source.tgid)) + elif self.peer_type == "user": + play_id = (b"u" + + self._int_to_bytes(self.tgid) + + self._int_to_bytes(evt.id)) + else: + raise ValueError("Portal has invalid peer type") + return base64.b64encode(play_id).decode("utf-8").rstrip("=") + + async def handle_telegram_game(self, source: 'AbstractUser', intent: IntentAPI, + evt: Message, relates_to: RelatesTo = None) -> EventID: + game = evt.media.game + play_id = self._encode_msgid(source, evt) + command = f"!tg play {play_id}" + override_text = f"Run {command} in your bridge management room to play {game.title}" + override_entities = [ + MessageEntityPre(offset=len("Run "), length=len(command), language="")] + + content = await formatter.telegram_to_matrix( + evt, source, self.main_intent, + override_text=override_text, override_entities=override_entities) + content.msgtype = MessageType.NOTICE + content.external_url = self._get_external_url(evt) + content["net.maunium.telegram.game"] = play_id + + await intent.set_typing(self.mxid, is_typing=False) + return await intent.send_message(self.mxid, content, timestamp=evt.date) + + async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, evt: Message + ) -> None: + if not self.mxid: + return + elif hasattr(evt, "media") and isinstance(evt.media, MessageMediaGame): + self.log.debug("Ignoring game message edit event") + return + + async with self.send_lock(sender.tgid if sender else None, required=False): + tg_space = self.tgid if self.peer_type == "channel" else source.tgid + + temporary_identifier = EventID( + f"${random.randint(1000000000000, 9999999999999)}TGBRIDGEDITEMP") + duplicate_found = self.dedup.check(evt, (temporary_identifier, tg_space), + force_hash=True) + if duplicate_found: + mxid, other_tg_space = duplicate_found + if tg_space != other_tg_space: + prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) + if not prev_edit_msg: + return + DBMessage(mxid=mxid, mx_room=self.mxid, tg_space=tg_space, + tgid=TelegramID(evt.id), edit_index=prev_edit_msg.edit_index + 1 + ).insert() + return + + content = await formatter.telegram_to_matrix(evt, source, self.main_intent, + no_reply_fallback=True) + editing_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space) + if not editing_msg: + self.log.info(f"Didn't find edited message {evt.id}@{tg_space} (src {source.tgid}) " + "in database.") + return + + content.msgtype = (MessageType.NOTICE if (sender and sender.is_bot + and self.get_config("bot_messages_as_notices")) + else MessageType.TEXT) + content.external_url = self._get_external_url(evt) + content.set_edit(editing_msg.mxid) + + # TODO remove this stuff once mautrix-python generates m.new_content + new_content = content.serialize() + del new_content["m.relates_to"] + content["m.new_content"] = new_content + content.body = f"Edit: {content.body}" + content.format = Format.HTML + content.formatted_body = (f"Edit: " + f"{content.formatted_body or escape_html(content.body)}") + + intent = sender.intent_for(self) if sender else self.main_intent + await intent.set_typing(self.mxid, is_typing=False) + event_id = await intent.send_message(self.mxid, content) + + prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) or editing_msg + DBMessage(mxid=event_id, mx_room=self.mxid, tg_space=tg_space, tgid=TelegramID(evt.id), + edit_index=prev_edit_msg.edit_index + 1).insert() + DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id) + + async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet, + evt: Message) -> None: + if not self.mxid: + await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False) + + if (self.peer_type == "user" and sender.tgid == self.tg_receiver + and not sender.is_real_user and not self.az.state_store.is_joined(self.mxid, + sender.mxid)): + self.log.debug(f"Ignoring private chat message {evt.id}@{source.tgid} as receiver does" + " not have matrix puppeting and their default puppet isn't in the room") + + async with self.send_lock(sender.tgid if sender else None, required=False): + tg_space = self.tgid if self.peer_type == "channel" else source.tgid + + temporary_identifier = EventID( + f"${random.randint(1000000000000, 9999999999999)}TGBRIDGETEMP") + duplicate_found = self.dedup.check(evt, (temporary_identifier, tg_space)) + if duplicate_found: + mxid, other_tg_space = duplicate_found + self.log.debug(f"Ignoring message {evt.id}@{tg_space} (src {source.tgid}) " + f"as it was already handled (in space {other_tg_space})") + if tg_space != other_tg_space: + DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=mxid, + tg_space=tg_space, edit_index=0).insert() + return + + if self.dedup.pre_db_check and self.peer_type == "channel": + msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space) + if msg: + self.log.debug(f"Ignoring message {evt.id} (src {source.tgid}) as it was already" + f"handled into {msg.mxid}. This duplicate was catched in the db " + "check. If you get this message often, consider increasing" + "bridge.deduplication.cache_queue_length in the config.") + return + + if sender and not sender.displayname: + self.log.debug(f"Telegram user {sender.tgid} sent a message, but doesn't have a " + "displayname, updating info...") + entity = await source.client.get_entity(PeerUser(sender.tgid)) + await sender.update_info(source, entity) + + allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo, + MessageMediaGame, MessageMediaPoll, MessageMediaUnsupported) + media = evt.media if hasattr(evt, "media") and isinstance(evt.media, + allowed_media) else None + intent = sender.intent_for(self) if sender else self.main_intent + if not media and evt.message: + is_bot = sender.is_bot if sender else False + event_id = await self.handle_telegram_text(source, intent, is_bot, evt) + elif media: + event_id = await { + MessageMediaPhoto: self.handle_telegram_photo, + MessageMediaDocument: self.handle_telegram_document, + MessageMediaGeo: self.handle_telegram_location, + MessageMediaPoll: self.handle_telegram_poll, + MessageMediaUnsupported: self.handle_telegram_unsupported, + MessageMediaGame: self.handle_telegram_game, + }[type(media)](source, intent, evt, + relates_to=formatter.telegram_reply_to_matrix(evt, source)) + else: + self.log.debug("Unhandled Telegram message: %s", evt) + return + + if not event_id: + return + + prev_id = self.dedup.update(evt, (event_id, tg_space), (temporary_identifier, tg_space)) + if prev_id: + self.log.debug(f"Sent message {evt.id}@{tg_space} to Matrix as {event_id}. " + f"Temporary dedup identifier was {temporary_identifier}, " + f"but dedup map contained {prev_id[1]} instead! -- " + "This was probably a race condition caused by Telegram sending updates" + "to other clients before responding to the sender. I'll just redact " + "the likely duplicate message now.") + await intent.redact(self.mxid, event_id) + return + + self.log.debug("Handled Telegram message: %s", evt) + try: + DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=event_id, + tg_space=tg_space, edit_index=0).insert() + DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id) + except IntegrityError as e: + self.log.exception(f"{e.__class__.__name__} while saving message mapping. " + "This might mean that an update was handled after it left the " + "dedup cache queue. You can try enabling bridge.deduplication." + "pre_db_check in the config.") + await intent.redact(self.mxid, event_id) + + async def _create_room_on_action(self, source: 'AbstractUser', + action: TypeMessageAction) -> bool: + if source.is_relaybot: + return False + create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate) + create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink) + if isinstance(action, create_and_exit) or isinstance(action, create_and_continue): + await self.create_matrix_room(source, invites=[source.mxid], + update_if_exists=isinstance(action, create_and_exit)) + if not isinstance(action, create_and_continue): + return False + return True + + async def handle_telegram_action(self, source: 'AbstractUser', sender: p.Puppet, + update: MessageService) -> None: + action = update.action + should_ignore = ((not self.mxid and not await self._create_room_on_action(source, action)) + or self.dedup.check_action(update)) + if should_ignore or not self.mxid: + return + if isinstance(action, MessageActionChatEditTitle): + await self._update_title(action.title, save=True) + elif isinstance(action, MessageActionChatEditPhoto): + await self._update_avatar(source, action.photo, save=True) + elif isinstance(action, MessageActionChatDeletePhoto): + await self._update_avatar(source, ChatPhotoEmpty(), save=True) + elif isinstance(action, MessageActionChatAddUser): + for user_id in action.users: + await self._add_telegram_user(TelegramID(user_id), source) + elif isinstance(action, MessageActionChatJoinedByLink): + await self._add_telegram_user(sender.id, source) + elif isinstance(action, MessageActionChatDeleteUser): + await self._delete_telegram_user(TelegramID(action.user_id), sender) + elif isinstance(action, MessageActionChatMigrateTo): + self.peer_type = "channel" + self._migrate_and_save_telegram(TelegramID(action.channel_id)) + await sender.intent_for(self).send_emote(self.mxid, + "upgraded this group to a supergroup.") + elif isinstance(action, MessageActionPinMessage): + await self.receive_telegram_pin_sender(sender) + elif isinstance(action, MessageActionGameScore): + # TODO handle game score + pass + else: + self.log.debug("Unhandled Telegram action in %s: %s", self.title, action) + + async def set_telegram_admin(self, user_id: TelegramID) -> None: + puppet = p.Puppet.get(user_id) + user = u.User.get_by_tgid(user_id) + + levels = await self.main_intent.get_power_levels(self.mxid) + if user: + levels.users[user.mxid] = 50 + if puppet: + levels.users[puppet.mxid] = 50 + await self.main_intent.set_power_levels(self.mxid, levels) + + async def receive_telegram_pin_sender(self, sender: p.Puppet) -> None: + self._temp_pinned_message_sender = sender + if self._temp_pinned_message_id: + await self.update_telegram_pin() + + async def update_telegram_pin(self) -> None: + intent = (self._temp_pinned_message_sender.intent_for(self) + if self._temp_pinned_message_sender else self.main_intent) + msg_id = self._temp_pinned_message_id + self._temp_pinned_message_id = None + self._temp_pinned_message_sender = None + + message = DBMessage.get_one_by_tgid(msg_id, self._temp_pinned_message_id_space) + if message: + await intent.set_pinned_messages(self.mxid, [message.mxid]) + else: + await intent.set_pinned_messages(self.mxid, []) + + async def receive_telegram_pin_id(self, msg_id: TelegramID, receiver: TelegramID) -> None: + if msg_id == 0: + return await self.update_telegram_pin() + self._temp_pinned_message_id = msg_id + self._temp_pinned_message_id_space = receiver if self.peer_type != "channel" else self.tgid + if self._temp_pinned_message_sender: + await self.update_telegram_pin() + + async def set_telegram_admins_enabled(self, enabled: bool) -> None: + level = 50 if enabled else 10 + levels = await self.main_intent.get_power_levels(self.mxid) + levels.invite = level + levels.events[EventType.ROOM_NAME] = level + levels.events[EventType.ROOM_AVATAR] = level + await self.main_intent.set_power_levels(self.mxid, levels) + + +def init(context: Context) -> None: + global config + config = context.config diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index ede800b0..cb3c9d9f 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,21 +13,24 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Awaitable, Any, Dict, List, Iterable, Optional, Pattern, Union, TYPE_CHECKING +from typing import Awaitable, Any, Dict, Iterable, Optional, Union, TYPE_CHECKING from difflib import SequenceMatcher -from enum import Enum -from aiohttp import ServerDisconnectedError +import unicodedata import asyncio import logging -import re from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer, - InputPeerPhotoFileLocation, UserProfilePhotoEmpty) -from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError + InputPeerPhotoFileLocation, UserProfilePhotoEmpty, TypeInputUser) -from .types import MatrixUserID, TelegramID +from mautrix.appservice import AppService, IntentAPI +from mautrix.errors import MatrixRequestError +from mautrix.bridge import CustomPuppetMixin +from mautrix.types import UserID, SyncToken +from mautrix.util.simple_template import SimpleTemplate + +from .types import TelegramID from .db import Puppet as DBPuppet -from . import util +from . import util, portal as p if TYPE_CHECKING: from .matrix import MatrixHandler @@ -36,26 +38,47 @@ if TYPE_CHECKING: from .context import Context from .abstract_user import AbstractUser -PuppetError = Enum('PuppetError', 'Success OnlyLoginSelf InvalidAccessToken') - -config = None # type: Config +config: Optional['Config'] = None -class Puppet: - log = logging.getLogger("mau.puppet") # type: logging.Logger - az = None # type: AppService - mx = None # type: MatrixHandler - loop = None # type: asyncio.AbstractEventLoop - mxid_regex = None # type: Pattern - username_template = None # type: str - hs_domain = None # type: str - cache = {} # type: Dict[TelegramID, Puppet] - by_custom_mxid = {} # type: Dict[str, Puppet] +class Puppet(CustomPuppetMixin): + log: logging.Logger = logging.getLogger("mau.puppet") + az: AppService + mx: 'MatrixHandler' + loop: asyncio.AbstractEventLoop + hs_domain: str + mxid_template: SimpleTemplate[TelegramID] + displayname_template: SimpleTemplate[str] + + cache: Dict[TelegramID, 'Puppet'] = {} + by_custom_mxid: Dict[UserID, 'Puppet'] = {} + + id: TelegramID + access_token: Optional[str] + custom_mxid: Optional[UserID] + _next_batch: Optional[SyncToken] + default_mxid: UserID + + username: Optional[str] + displayname: Optional[str] + displayname_source: Optional[TelegramID] + photo_id: Optional[str] + is_bot: bool + is_registered: bool + disable_updates: bool + + default_mxid_intent: IntentAPI + intent: IntentAPI + + sync_task: Optional[asyncio.Future] + + _db_instance: Optional[DBPuppet] def __init__(self, id: TelegramID, access_token: Optional[str] = None, - custom_mxid: Optional[MatrixUserID] = None, + custom_mxid: Optional[UserID] = None, + next_batch: Optional[SyncToken] = None, username: Optional[str] = None, displayname: Optional[str] = None, displayname_source: Optional[TelegramID] = None, @@ -64,40 +87,47 @@ class Puppet: is_registered: bool = False, disable_updates: bool = False, db_instance: Optional[DBPuppet] = None) -> None: - self.id = id # type: TelegramID - self.access_token = access_token # type: Optional[str] - self.custom_mxid = custom_mxid # type: Optional[MatrixUserID] - self.default_mxid = self.get_mxid_from_id(self.id) # type: MatrixUserID + self.id = id + self.access_token = access_token + self.custom_mxid = custom_mxid + self._next_batch = next_batch + self.default_mxid = self.get_mxid_from_id(self.id) - self.username = username # type: Optional[str] - self.displayname = displayname # type: Optional[str] - self.displayname_source = displayname_source # type: Optional[TelegramID] - self.photo_id = photo_id # type: Optional[str] - self.is_bot = is_bot # type: bool - self.is_registered = is_registered # type: bool - self.disable_updates = disable_updates # type: bool - self._db_instance = db_instance # type: Optional[DBPuppet] + self.username = username + self.displayname = displayname + self.displayname_source = displayname_source + self.photo_id = photo_id + self.is_bot = is_bot + self.is_registered = is_registered + self.disable_updates = disable_updates + self._db_instance = db_instance self.default_mxid_intent = self.az.intent.user(self.default_mxid) - self.intent = self._fresh_intent() # type: IntentAPI - self.sync_task = None # type: Optional[asyncio.Future] + self.intent = self._fresh_intent() + self.sync_task = None self.cache[id] = self if self.custom_mxid: self.by_custom_mxid[self.custom_mxid] = self - @property - def mxid(self) -> MatrixUserID: - return self.custom_mxid or self.default_mxid + self.log = self.log.getChild(str(self.id)) @property def tgid(self) -> TelegramID: return self.id @property - def is_real_user(self) -> bool: - """ Is True when the puppet is a real Matrix user. """ - return bool(self.custom_mxid and self.access_token) + def peer(self) -> PeerUser: + return PeerUser(user_id=self.tgid) + + @property + def next_batch(self) -> SyncToken: + return self._next_batch + + @next_batch.setter + def next_batch(self, value: SyncToken) -> None: + self._next_batch = value + self.db_instance.edit(next_batch=self._next_batch) @staticmethod async def is_logged_in() -> bool: @@ -106,175 +136,17 @@ class Puppet: @property def plain_displayname(self) -> str: - tpl = config["bridge.displayname_template"] - if tpl == "{displayname}": - # Template has no extra stuff, no need to parse. - return self.displayname - regex = re.compile("^" + re.escape(tpl).replace(re.escape("{displayname}"), "(.+?)") + "$") - match = regex.match(self.displayname) - return match.group(1) or self.displayname + return self.displayname_template.parse(self.displayname) or self.displayname - def get_input_entity(self, user: 'AbstractUser') -> Awaitable[TypeInputPeer]: - return user.client.get_input_entity(PeerUser(user_id=self.tgid)) + def get_input_entity(self, user: 'AbstractUser' + ) -> Awaitable[Union[TypeInputPeer, TypeInputUser]]: + return user.client.get_input_entity(self.peer) - # region Custom puppet management - def _fresh_intent(self) -> IntentAPI: - return (self.az.intent.user(self.custom_mxid, self.access_token) - if self.is_real_user else self.default_mxid_intent) + def intent_for(self, portal: 'p.Portal') -> IntentAPI: + if portal.tgid == self.tgid: + return self.default_mxid_intent + return self.intent - async def switch_mxid(self, access_token: Optional[str], - mxid: Optional[MatrixUserID]) -> PuppetError: - prev_mxid = self.custom_mxid - self.custom_mxid = mxid - self.access_token = access_token - self.intent = self._fresh_intent() - - err = await self.init_custom_mxid() - if err != PuppetError.Success: - return err - - try: - del self.by_custom_mxid[prev_mxid] # type: ignore - except KeyError: - pass - if self.mxid != self.default_mxid: - self.by_custom_mxid[self.mxid] = self - await self.leave_rooms_with_default_user() - self.save() - return PuppetError.Success - - async def init_custom_mxid(self) -> PuppetError: - if not self.is_real_user: - return PuppetError.Success - - mxid = await self.intent.whoami() - if not mxid or mxid != self.custom_mxid: - self.custom_mxid = None - self.access_token = None - self.intent = self._fresh_intent() - if mxid != self.custom_mxid: - return PuppetError.OnlyLoginSelf - return PuppetError.InvalidAccessToken - if config["bridge.sync_with_custom_puppets"]: - self.sync_task = asyncio.ensure_future(self.sync(), loop=self.loop) - return PuppetError.Success - - async def leave_rooms_with_default_user(self) -> None: - for room_id in await self.default_mxid_intent.get_joined_rooms(): - try: - await self.default_mxid_intent.leave_room(room_id) - await self.intent.ensure_joined(room_id) - except (IntentError, MatrixRequestError): - pass - - def create_sync_filter(self) -> Awaitable[str]: - return self.intent.client.create_filter(self.custom_mxid, { - "room": { - "include_leave": False, - "state": { - "types": [] - }, - "timeline": { - "types": [], - }, - "ephemeral": { - "types": ["m.typing", "m.receipt"], - }, - "account_data": { - "types": [] - } - }, - "account_data": { - "types": [], - }, - "presence": { - "types": ["m.presence"], - "senders": [self.custom_mxid], - }, - }) - - def filter_events(self, events: List[Dict]) -> List: - new_events = [] - for event in events: - evt_type = event.get("type", None) - event.setdefault("content", {}) - if evt_type == "m.typing": - is_typing = self.custom_mxid in event["content"].get("user_ids", []) - event["content"]["user_ids"] = [self.custom_mxid] if is_typing else [] - elif evt_type == "m.receipt": - val = None - evt = None - for event_id in event["content"]: - try: - val = event["content"][event_id]["m.read"][self.custom_mxid] - evt = event_id - break - except KeyError: - pass - if val and evt: - event["content"] = {evt: {"m.read": { - self.custom_mxid: val - }}} - else: - continue - new_events.append(event) - return new_events - - def handle_sync(self, presence: List, ephemeral: Dict) -> None: - presence_events = [self.mx.try_handle_ephemeral_event(event) for event in presence] - - for room_id, events in ephemeral.items(): - for event in events: - event["room_id"] = room_id - - ephemeral_events = [self.mx.try_handle_ephemeral_event(event) - for events in ephemeral.values() - for event in self.filter_events(events)] - - events = ephemeral_events + presence_events # List[Callable[[int], Awaitable[None]]] - coro = asyncio.gather(*events, loop=self.loop) - asyncio.ensure_future(coro, loop=self.loop) - - async def sync(self) -> None: - try: - await self._sync() - except asyncio.CancelledError: - self.log.info("Syncing cancelled") - except Exception: - self.log.exception("Fatal error syncing") - - async def _sync(self) -> None: - if not self.is_real_user: - self.log.warning("Called sync() for non-custom puppet.") - return - custom_mxid = self.custom_mxid - access_token_at_start = self.access_token - errors = 0 - next_batch = None - filter_id = await self.create_sync_filter() - self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.") - while access_token_at_start == self.access_token: - try: - sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch, - set_presence="offline") # type: Dict - errors = 0 - if next_batch is not None: - presence = sync_resp.get("presence", {}).get("events", []) # type: List - ephemeral = {room: data.get("ephemeral", {}).get("events", []) - for room, data - in sync_resp.get("rooms", {}).get("join", {}).items() - } # type: Dict - self.handle_sync(presence, ephemeral) - next_batch = sync_resp.get("next_batch", None) - except (MatrixRequestError, ServerDisconnectedError) as e: - wait = min(errors, 11) ** 2 - self.log.warning(f"Syncer for {custom_mxid} errored: {e}. " - f"Waiting for {wait} seconds...") - errors += 1 - await asyncio.sleep(wait) - self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.") - - # endregion # region DB conversion @property @@ -283,26 +155,27 @@ class Puppet: self._db_instance = self.new_db_instance() return self._db_instance + @property + def _fields(self) -> Dict[str, Any]: + return dict(access_token=self.access_token, next_batch=self._next_batch, + custom_mxid=self.custom_mxid, username=self.username, is_bot=self.is_bot, + displayname=self.displayname, displayname_source=self.displayname_source, + photo_id=self.photo_id, matrix_registered=self.is_registered, + disable_updates=self.disable_updates) + def new_db_instance(self) -> DBPuppet: - return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid, - username=self.username, displayname=self.displayname, - displayname_source=self.displayname_source, photo_id=self.photo_id, - is_bot=self.is_bot, matrix_registered=self.is_registered, - disable_updates=self.disable_updates) + return DBPuppet(id=self.id, **self._fields) + + def save(self) -> None: + self.db_instance.edit(**self._fields) @classmethod def from_db(cls, db_puppet: DBPuppet) -> 'Puppet': return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid, - db_puppet.username, db_puppet.displayname, db_puppet.displayname_source, - db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered, - db_puppet.disable_updates, db_instance=db_puppet) - - def save(self) -> None: - self.db_instance.update(access_token=self.access_token, custom_mxid=self.custom_mxid, - username=self.username, displayname=self.displayname, - displayname_source=self.displayname_source, photo_id=self.photo_id, - is_bot=self.is_bot, matrix_registered=self.is_registered, - disable_updates=self.disable_updates) + db_puppet.next_batch, db_puppet.username, db_puppet.displayname, + db_puppet.displayname_source, db_puppet.photo_id, db_puppet.is_bot, + db_puppet.matrix_registered, db_puppet.disable_updates, + db_instance=db_puppet) # endregion # region Info updating @@ -319,10 +192,10 @@ class Puppet: def _filter_name(name: str) -> str: if not name: return "" - whitespace = ("\ufeff", "\u3164", "\u2063", "\u200b", "\u180e", "\u034f", "\u2800", - "\u180e", "\u200b", "\u202f", "\u205f", "\u3000") - name = "".join(char for char in name if char not in whitespace) - name = name.strip() + whitespace = ("\t\n\r\v\f \u00a0\u034f\u180e\u2063\u202f\u205f\u2800\u3000\u3164\ufeff" + "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b" + "\u200c\u200d\u200e\u200f") + name = "".join(c for c in name.strip(whitespace) if unicodedata.category(c) != 'Cf') return name @classmethod @@ -351,8 +224,7 @@ class Puppet: if not enable_format: return name - return config["bridge.displayname_template"].format( - displayname=name) + return cls.displayname_template.format_full(name) async def update_info(self, source: 'AbstractUser', info: User) -> None: if self.disable_updates: @@ -391,7 +263,8 @@ class Puppet: self.displayname = displayname self.displayname_source = source.tgid try: - await self.default_mxid_intent.set_display_name(displayname[:100]) + await self.default_mxid_intent.set_displayname( + displayname[:config["bridge.displayname_max_length"]]) except MatrixRequestError: self.log.exception("Failed to set displayname") self.displayname = "" @@ -415,7 +288,7 @@ class Puppet: if not photo_id: self.photo_id = "" try: - await self.default_mxid_intent.set_avatar("") + await self.default_mxid_intent.set_avatar_url("") except MatrixRequestError: self.log.exception("Failed to set avatar") self.photo_id = "" @@ -431,7 +304,7 @@ class Puppet: if file: self.photo_id = photo_id try: - await self.default_mxid_intent.set_avatar(file.mxc) + await self.default_mxid_intent.set_avatar_url(file.mxc) except MatrixRequestError: self.log.exception("Failed to set avatar") self.photo_id = "" @@ -460,7 +333,7 @@ class Puppet: return None @classmethod - def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['Puppet']: + def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']: tgid = cls.get_id_from_mxid(mxid) if tgid: return cls.get(tgid, create) @@ -468,7 +341,7 @@ class Puppet: return None @classmethod - def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']: + def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']: if not mxid: raise ValueError("Matrix ID can't be empty") @@ -492,15 +365,12 @@ class Puppet: for puppet in DBPuppet.all_with_custom_mxid()) @classmethod - def get_id_from_mxid(cls, mxid: MatrixUserID) -> Optional[TelegramID]: - match = cls.mxid_regex.match(mxid) - if match: - return TelegramID(int(match.group(1))) - return None + def get_id_from_mxid(cls, mxid: UserID) -> Optional[TelegramID]: + return cls.mxid_template.parse(mxid) @classmethod - def get_mxid_from_id(cls, tgid: TelegramID) -> MatrixUserID: - return MatrixUserID(f"@{cls.username_template.format(userid=tgid)}:{cls.hs_domain}") + def get_mxid_from_id(cls, tgid: TelegramID) -> UserID: + return UserID(cls.mxid_template.format_full(tgid)) @classmethod def find_by_username(cls, username: str) -> Optional['Puppet']: @@ -534,12 +404,15 @@ class Puppet: # endregion -def init(context: 'Context') -> List[Awaitable[Any]]: # [None, None, PuppetError] +def init(context: 'Context') -> Iterable[Awaitable[Any]]: global config Puppet.az, config, Puppet.loop, _ = context.core Puppet.mx = context.mx - Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}") Puppet.hs_domain = config["homeserver"]["domain"] - Puppet.mxid_regex = re.compile( - f"@{Puppet.username_template.format(userid='([0-9]+)')}:{Puppet.hs_domain}") - return [puppet.init_custom_mxid() for puppet in Puppet.all_with_custom_mxid()] + + Puppet.mxid_template = SimpleTemplate(config["bridge.username_template"], "userid", + prefix="@", suffix=f":{Puppet.hs_domain}", type=int) + Puppet.displayname_template = SimpleTemplate(config["bridge.displayname_template"], + "displayname") + + return (puppet.start() for puppet in Puppet.all_with_custom_mxid()) diff --git a/mautrix_telegram/scripts/dbms_migrate/__main__.py b/mautrix_telegram/scripts/dbms_migrate/__main__.py index e9edfffd..09d88eab 100644 --- a/mautrix_telegram/scripts/dbms_migrate/__main__.py +++ b/mautrix_telegram/scripts/dbms_migrate/__main__.py @@ -1,7 +1,9 @@ +from typing import Union import argparse -import sqlalchemy as sql + from sqlalchemy import orm from sqlalchemy.ext.declarative import declarative_base +import sqlalchemy as sql from alchemysession import AlchemySessionContainer @@ -22,16 +24,19 @@ def log(message, end="\n"): def connect(to): - import mautrix_telegram.db.base as base - base.Base = declarative_base(cls=base.BaseBase) - from mautrix_telegram.db import (Portal, Message, UserPortal, User, RoomState, UserProfile, - Contact, Puppet, BotChat, TelegramFile) + from mautrix.bridge.db import Base, RoomState, UserProfile + from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat, + TelegramFile) + db_engine = sql.create_engine(to) db_factory = orm.sessionmaker(bind=db_engine) - db_session = orm.scoped_session(db_factory) # type: orm.Session - base.Base.metadata.bind = db_engine + db_session: Union[orm.Session, orm.scoped_session] = orm.scoped_session(db_factory) + Base.metadata.bind = db_engine + + new_base = declarative_base() + new_base.metadata.bind = db_engine session_container = AlchemySessionContainer(engine=db_engine, session=db_session, - table_base=base.Base, table_prefix="telethon_", + table_base=new_base, table_prefix="telethon_", manage_tables=False) return db_session, { @@ -52,6 +57,7 @@ def connect(to): "TelegramFile": TelegramFile, } + log("Connecting to old database") session, tables = connect(args.from_url) diff --git a/mautrix_telegram/scripts/telematrix_import/__main__.py b/mautrix_telegram/scripts/telematrix_import/__main__.py index 53a3e204..5324cc8b 100644 --- a/mautrix_telegram/scripts/telematrix_import/__main__.py +++ b/mautrix_telegram/scripts/telematrix_import/__main__.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -15,11 +14,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Dict -from sqlalchemy import orm -import sqlalchemy as sql import argparse -from mautrix_telegram.db import Base, Portal, Message, Puppet, BotChat +from sqlalchemy import orm +import sqlalchemy as sql + +from mautrix.bridge.db import Base + +from mautrix_telegram.db import Portal, Message, Puppet, BotChat from mautrix_telegram.config import Config from .models import ChatLink, TgUser, MatrixUser, Message as TMMessage, Base as TelematrixBase @@ -38,8 +40,7 @@ args = parser.parse_args() config = Config(args.config, None, None) config.load() -mxtg_db_engine = sql.create_engine( - config.get("appservice.database", "sqlite:///mautrix-telegram.db")) +mxtg_db_engine = sql.create_engine(config["appservice.database"]) mxtg = orm.sessionmaker(bind=mxtg_db_engine)() Base.metadata.bind = mxtg_db_engine @@ -55,18 +56,18 @@ tm_messages = telematrix.query(TMMessage).all() telematrix.close() telematrix_db_engine.dispose() -portals_by_tgid = {} # type: Dict[int, Portal] -portals_by_mxid = {} # type: Dict[str, Portal] -chats = {} # type: Dict[int, BotChat] -messages = {} # type: Dict[str, Message] -puppets = {} # type: Dict[int, Puppet] +portals_by_tgid: Dict[int, Portal] = {} +portals_by_mxid: Dict[str, Portal] = {} +chats: Dict[int, BotChat] = {} +messages: Dict[str, Message] = {} +puppets: Dict[int, Puppet] = {} for chat_link in chat_links: if type(chat_link.tg_room) is str: - print("Expected tg_room to be a number, got a string. Ignoring %s" % chat_link.tg_room) + print(f"Expected tg_room to be a number, got a string. Ignoring {chat_link.tg_room}") continue if chat_link.tg_room >= 0: - print("Unexpected unprefixed telegram chat ID: %s, ignoring..." % chat_link.tg_room) + print(f"Unexpected unprefixed telegram chat ID: {chat_link.tg_room}, ignoring...") continue tgid = str(chat_link.tg_room) if tgid.startswith("-100"): diff --git a/mautrix_telegram/sqlstatestore.py b/mautrix_telegram/sqlstatestore.py index 82a305ee..3b8c91ad 100644 --- a/mautrix_telegram/sqlstatestore.py +++ b/mautrix_telegram/sqlstatestore.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,106 +13,26 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, Tuple +from mautrix.types import UserID +from mautrix.bridge.db import SQLStateStore as BaseSQLStateStore -from mautrix_appservice import StateStore - -from .types import MatrixUserID, MatrixRoomID from . import puppet as pu -from .db import RoomState, UserProfile -class SQLStateStore(StateStore): - def __init__(self) -> None: - super().__init__() - self.profile_cache = {} # type: Dict[Tuple[str, str], UserProfile] - self.room_state_cache = {} # type: Dict[str, RoomState] +class SQLStateStore(BaseSQLStateStore): + def is_registered(self, user_id: UserID) -> bool: + puppet = pu.Puppet.get_by_mxid(user_id, create=False) + if puppet: + return puppet.is_registered + custom_puppet = pu.Puppet.get_by_custom_mxid(user_id) + if custom_puppet: + return True + return super().is_registered(user_id) - @staticmethod - def is_registered(user: MatrixUserID) -> bool: - puppet = pu.Puppet.get_by_mxid(user) - return puppet.is_registered if puppet else False - - @staticmethod - def registered(user: MatrixUserID) -> None: - puppet = pu.Puppet.get_by_mxid(user) + def registered(self, user_id: UserID) -> None: + puppet = pu.Puppet.get_by_mxid(user_id, create=True) if puppet: puppet.is_registered = True puppet.save() - - def update_state(self, event: Dict) -> None: - event_type = event["type"] - if event_type == "m.room.power_levels": - self.set_power_levels(event["room_id"], event["content"]) - elif event_type == "m.room.member": - self.set_member(event["room_id"], event["state_key"], event["content"]) - - def _get_user_profile(self, room_id: MatrixRoomID, user_id: MatrixUserID, create: bool = True - ) -> UserProfile: - key = (room_id, user_id) - try: - return self.profile_cache[key] - except KeyError: - pass - - profile = UserProfile.get(*key) - if profile: - self.profile_cache[key] = profile - elif create: - profile = UserProfile(room_id=room_id, user_id=user_id, membership="leave") - profile.insert() - self.profile_cache[key] = profile - return profile - - def get_member(self, room: MatrixRoomID, user: MatrixUserID) -> Dict: - return self._get_user_profile(room, user).dict() - - def set_member(self, room: MatrixRoomID, user: MatrixUserID, member: Dict) -> None: - profile = self._get_user_profile(room, user) - profile.membership = member.get("membership", profile.membership or "leave") - profile.displayname = member.get("displayname", profile.displayname) - profile.avatar_url = member.get("avatar_url", profile.avatar_url) - profile.update() - - def set_membership(self, room: MatrixRoomID, user: MatrixUserID, membership: str) -> None: - self.set_member(room, user, { - "membership": membership, - }) - - def _get_room_state(self, room_id: MatrixRoomID, create: bool = True) -> RoomState: - try: - return self.room_state_cache[room_id] - except KeyError: - pass - - room = RoomState.get(room_id) - if room: - self.room_state_cache[room_id] = room - elif create: - room = RoomState(room_id=room_id) - room.insert() - self.room_state_cache[room_id] = room - return room - - def has_power_levels(self, room: MatrixRoomID) -> bool: - return bool(self._get_room_state(room).power_levels) - - def get_power_levels(self, room: MatrixRoomID) -> Dict: - return self._get_room_state(room).power_levels - - def set_power_level(self, room: MatrixRoomID, user: MatrixUserID, level: int) -> None: - room_state = self._get_room_state(room) - power_levels = room_state.power_levels - if not power_levels: - power_levels = { - "users": {}, - "events": {}, - } - power_levels[room]["users"][user] = level - room_state.power_levels = power_levels - room_state.update() - - def set_power_levels(self, room: MatrixRoomID, content: Dict) -> None: - state = self._get_room_state(room) - state.power_levels = content - state.update() + else: + super().registered(user_id) diff --git a/mautrix_telegram/tgclient.py b/mautrix_telegram/tgclient.py index 43e0a1a6..09a7a2fc 100644 --- a/mautrix_telegram/tgclient.py +++ b/mautrix_telegram/tgclient.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -18,18 +17,21 @@ from typing import List, Union, Optional from telethon import TelegramClient, utils from telethon.tl.functions.messages import SendMediaRequest -from telethon.tl.types import ( - InputMediaUploadedDocument, InputMediaUploadedPhoto, TypeDocumentAttribute, TypeInputMedia, - TypeInputPeer, TypeMessageEntity, TypeMessageMedia, TypePeer) +from telethon.tl.types import (InputMediaUploadedDocument, InputMediaUploadedPhoto, + TypeDocumentAttribute, TypeInputMedia, TypeInputPeer, + TypeMessageEntity, TypeMessageMedia, TypePeer) from telethon.tl.patched import Message +from telethon.sessions.abstract import Session class MautrixTelegramClient(TelegramClient): + session: Session + async def upload_file_direct(self, file: bytes, mime_type: str = None, attributes: List[TypeDocumentAttribute] = None, file_name: str = None, max_image_size: float = 10 * 1000 ** 2, ) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]: - file_handle = await super().upload_file(file, file_name=file_name, use_cache=False) + file_handle = await super().upload_file(file, file_name=file_name) if (mime_type == "image/png" or mime_type == "image/jpeg") and len(file) < max_image_size: return InputMediaUploadedPhoto(file_handle) diff --git a/mautrix_telegram/types.py b/mautrix_telegram/types.py index 15cc7094..f5cb2145 100644 --- a/mautrix_telegram/types.py +++ b/mautrix_telegram/types.py @@ -1,9 +1,3 @@ -from typing import Dict, NewType - -MatrixUserID = NewType('MatrixUserID', str) -MatrixRoomID = NewType('MatrixRoomID', str) -MatrixEventID = NewType('MatrixEventID', str) - -MatrixEvent = NewType('MatrixEvent', Dict) +from typing import NewType TelegramID = NewType('TelegramID', int) diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index 0de66515..008ad57d 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,20 +13,22 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Awaitable, Dict, List, Iterable, Match, NewType, Optional, Tuple, TYPE_CHECKING +from typing import (Awaitable, Dict, List, Iterable, NewType, Optional, Tuple, Any, cast, + TYPE_CHECKING) import logging import asyncio -import re -from telethon.tl.types import ( - TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser, - UpdateShortChatMessage, UpdateShortMessage, User as TLUser) +from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser, + UpdateShortChatMessage, UpdateShortMessage, User as TLUser, Chat) from telethon.tl.types.contacts import ContactsNotModified from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest from telethon.tl.functions.account import UpdateStatusRequest -from mautrix_appservice import MatrixRequestError -from .types import MatrixUserID, TelegramID +from mautrix.client import Client +from mautrix.errors import MatrixRequestError +from mautrix.types import UserID + +from .types import TelegramID from .db import User as DBUser from .abstract_user import AbstractUser from . import portal as po, puppet as pu @@ -36,36 +37,46 @@ if TYPE_CHECKING: from .config import Config from .context import Context -config = None # type: Config +config: Optional['Config'] = None SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int]) class User(AbstractUser): - log = logging.getLogger("mau.user") # type: logging.Logger - by_mxid = {} # type: Dict[str, User] - by_tgid = {} # type: Dict[int, User] + log: logging.Logger = logging.getLogger("mau.user") + by_mxid: Dict[str, 'User'] = {} + by_tgid: Dict[int, 'User'] = {} - def __init__(self, mxid: MatrixUserID, tgid: Optional[TelegramID] = None, + phone: Optional[str] + contacts: List['pu.Puppet'] + saved_contacts: int + portals: Dict[Tuple[TelegramID, TelegramID], 'po.Portal'] + command_status: Optional[Dict[str, Any]] + + _db_instance: Optional[DBUser] + _ensure_started_lock: asyncio.Lock + + def __init__(self, mxid: UserID, tgid: Optional[TelegramID] = None, username: Optional[str] = None, phone: Optional[str] = None, db_contacts: Optional[Iterable[TelegramID]] = None, saved_contacts: int = 0, is_bot: bool = False, db_portals: Optional[Iterable[Tuple[TelegramID, TelegramID]]] = None, db_instance: Optional[DBUser] = None) -> None: super().__init__() - self.mxid = mxid # type: MatrixUserID - self.tgid = tgid # type: TelegramID - self.is_bot = is_bot # type: bool - self.username = username # type: str - self.phone = phone # type: str - self.contacts = [] # type: List[pu.Puppet] - self.saved_contacts = saved_contacts # type: int + self.mxid = mxid + self.tgid = tgid + self.is_bot = is_bot + self.username = username + self.phone = phone + self.contacts = [] + self.saved_contacts = saved_contacts self.db_contacts = db_contacts - self.portals = {} # type: Dict[Tuple[TelegramID, TelegramID], po.Portal] + self.portals = {} self.db_portals = db_portals or [] - self._db_instance = db_instance # type: Optional[DBUser] + self._db_instance = db_instance + self._ensure_started_lock = asyncio.Lock() - self.command_status = None # type: Optional[Dict] + self.command_status = None (self.relaybot_whitelisted, self.whitelisted, @@ -78,14 +89,16 @@ class User(AbstractUser): if tgid: self.by_tgid[tgid] = self + self.log = self.log.getChild(self.mxid) + @property def name(self) -> str: return self.mxid @property def mxid_localpart(self) -> str: - match = re.compile("@(.+):(.+)").match(self.mxid) # type: Match - return match.group(1) + localpart, server = Client.parse_user_id(self.mxid) + return localpart @property def human_tg_id(self) -> str: @@ -136,8 +149,8 @@ class User(AbstractUser): saved_contacts=self.saved_contacts, portals=self.db_portals) def save(self, contacts: bool = False, portals: bool = False) -> None: - self.db_instance.update(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone, - saved_contacts=self.saved_contacts) + self.db_instance.edit(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone, + saved_contacts=self.saved_contacts) if contacts: self.db_instance.contacts = self.db_contacts if portals: @@ -161,8 +174,11 @@ class User(AbstractUser): # endregion # region Telegram connection management - def ensure_started(self, even_if_no_session=False) -> Awaitable['User']: - return super().ensure_started(even_if_no_session) + async def ensure_started(self, even_if_no_session=False) -> 'User': + if not self.puppet_whitelisted or self.connected: + return self + async with self._ensure_started_lock: + return cast(User, await super().ensure_started(even_if_no_session)) async def start(self, delete_unless_authenticated: bool = False) -> 'User': await super().start() @@ -229,7 +245,7 @@ class User(AbstractUser): self.phone = info.phone changed = True if self.tgid != info.id: - self.tgid = info.id + self.tgid = TelegramID(info.id) self.by_tgid[self.tgid] = self if changed: self.save() @@ -242,7 +258,8 @@ class User(AbstractUser): if not portal or portal.deleted or not portal.mxid or portal.has_bot: continue try: - await portal.main_intent.kick(portal.mxid, self.mxid, "Logged out of Telegram.") + await portal.main_intent.kick_user(portal.mxid, self.mxid, + "Logged out of Telegram.") except MatrixRequestError: pass self.portals = {} @@ -263,7 +280,7 @@ class User(AbstractUser): def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45 ) -> List[SearchResult]: - results = [] # type: List[SearchResult] + results: List[SearchResult] = [] for contact in self.contacts: similarity = contact.similarity(query) if similarity >= min_similarity: @@ -275,7 +292,7 @@ class User(AbstractUser): if len(query) < 5: return [] server_results = await self.client(SearchRequest(q=query, limit=max_results)) - results = [] # type: List[SearchResult] + results: List[SearchResult] = [] for user in server_results.users: puppet = pu.Puppet.get(user.id) await puppet.update_info(self, user) @@ -295,8 +312,19 @@ class User(AbstractUser): return await self._search_remote(query), True async def sync_dialogs(self, synchronous_create: bool = False) -> None: + if self.is_bot: + return creators = [] - for entity in await self.get_dialogs(limit=config["bridge.sync_dialog_limit"] or None): + limit = config["bridge.sync_dialog_limit"] or None + self.log.debug(f"Syncing dialogs (limit={limit}, synchronous_create={synchronous_create})") + async for dialog in self.client.iter_dialogs(limit=limit, ignore_migrated=True, + archived=False): + entity = dialog.entity + if isinstance(entity, Chat) and (entity.deactivated or entity.left): + self.log.warning(f"Ignoring deactivated or left chat {entity} while syncing") + continue + elif isinstance(entity, TLUser) and not config["bridge.sync_direct_chats"]: + continue portal = po.Portal.get_by_entity(entity) self.portals[portal.tgid_full] = portal creators.append( @@ -304,6 +332,7 @@ class User(AbstractUser): synchronous=synchronous_create)) self.save(portals=True) await asyncio.gather(*creators, loop=self.loop) + self.log.debug("Dialog syncing complete") def register_portal(self, portal: po.Portal) -> None: try: @@ -323,7 +352,7 @@ class User(AbstractUser): async def needs_relaybot(self, portal: po.Portal) -> bool: return not await self.is_logged_in() or ( - (portal.has_bot or self.bot) and portal.tgid_full not in self.portals) + (portal.has_bot or self.is_bot) and portal.tgid_full not in self.portals) def _hash_contacts(self) -> int: acc = 0 @@ -348,7 +377,7 @@ class User(AbstractUser): # region Class instance lookup @classmethod - def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['User']: + def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['User']: if not mxid: raise ValueError("Matrix ID can't be empty") @@ -400,9 +429,9 @@ class User(AbstractUser): # endregion -def init(context: 'Context') -> List[Awaitable['User']]: +def init(context: 'Context') -> Iterable[Awaitable['User']]: global config config = context.config - users = [User.from_db(user) for user in DBUser.all()] - return [user.ensure_started() for user in users if user.tgid] + return (User.from_db(db_user).ensure_started() + for db_user in DBUser.all_with_tgid()) diff --git a/mautrix_telegram/util/__init__.py b/mautrix_telegram/util/__init__.py index 2ba35c28..727224bb 100644 --- a/mautrix_telegram/util/__init__.py +++ b/mautrix_telegram/util/__init__.py @@ -1,7 +1,4 @@ from .file_transfer import transfer_file_to_matrix, convert_image from .format_duration import format_duration -from .signed_token import sign_token, verify_token from .recursive_dict import recursive_del, recursive_set, recursive_get - -def ignore_coro(coro): - pass +from .color_log import ColorFormatter diff --git a/mautrix_telegram/util/color_log.py b/mautrix_telegram/util/color_log.py new file mode 100644 index 00000000..860720c2 --- /dev/null +++ b/mautrix_telegram/util/color_log.py @@ -0,0 +1,29 @@ +# 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 . +from mautrix.util.color_log import ColorFormatter as BaseColorFormatter, PREFIX, MXID_COLOR, RESET + +TELETHON_COLOR = PREFIX + "35;1m" # magenta +TELETHON_MODULE_COLOR = PREFIX + "35m" + + +class ColorFormatter(BaseColorFormatter): + def _color_name(self, module: str) -> str: + if module.startswith("telethon"): + prefix, user_id, module = module.split(".", 2) + return (f"{TELETHON_COLOR}{prefix}{RESET}." + f"{MXID_COLOR}{user_id}{RESET}." + f"{TELETHON_MODULE_COLOR}{module}{RESET}") + return super()._color_name(module) diff --git a/mautrix_telegram/util/file_transfer.py b/mautrix_telegram/util/file_transfer.py index ddd6bfc5..02a3e7ca 100644 --- a/mautrix_telegram/util/file_transfer.py +++ b/mautrix_telegram/util/file_transfer.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -28,7 +27,8 @@ from telethon.tl.types import (Document, InputFileLocation, InputDocumentFileLoc InputPeerPhotoFileLocation) from telethon.errors import (AuthBytesInvalidError, AuthKeyInvalidError, LocationInvalidError, SecurityError, FileIdInvalidError) -from mautrix_appservice import IntentAPI + +from mautrix.appservice import IntentAPI from ..tgclient import MautrixTelegramClient from ..db import TelegramFile as DBTelegramFile @@ -38,6 +38,7 @@ try: from PIL import Image except ImportError: Image = None + try: from moviepy.editor import VideoFileClip import random @@ -47,7 +48,7 @@ try: except ImportError: VideoFileClip = random = string = os = mimetypes = None -log = logging.getLogger("mau.util") # type: logging.Logger +log: logging.Logger = logging.getLogger("mau.util") TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation, InputFileLocation, InputPhotoFileLocation] @@ -59,7 +60,7 @@ def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str if not Image: return source_mime, file, None, None try: - image = Image.open(BytesIO(file)).convert("RGBA") # type: Image.Image + image: Image.Image = Image.open(BytesIO(file)).convert("RGBA") if thumbnail_to: image.thumbnail(thumbnail_to, Image.ANTIALIAS) new_file = BytesIO() @@ -102,8 +103,10 @@ def _read_video_thumbnail(data: bytes, video_ext: str = "mp4", frame_ext: str = def _location_to_id(location: TypeLocation) -> str: - if isinstance(location, (Document, InputDocumentFileLocation, InputPhotoFileLocation)): + if isinstance(location, Document): return f"{location.id}-{location.access_hash}" + elif isinstance(location, (InputDocumentFileLocation, InputPhotoFileLocation)): + return f"{location.id}-{location.access_hash}-{location.thumb_size}" elif isinstance(location, (InputFileLocation, InputPeerPhotoFileLocation)): return f"{location.volume_id}-{location.local_id}" @@ -134,7 +137,7 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In width, height = None, None mime_type = magic.from_buffer(file, mime=True) - content_uri = await intent.upload_file(file, mime_type) + content_uri = await intent.upload_media(file, mime_type) db_file = DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type, was_converted=False, timestamp=int(time.time()), size=len(file), @@ -148,7 +151,7 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In return db_file -transfer_locks = {} # type: Dict[str, asyncio.Lock] +transfer_locks: Dict[str, asyncio.Lock] = {} TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]] @@ -202,7 +205,7 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten mime_type = new_mime_type thumbnail = None - content_uri = await intent.upload_file(file, mime_type) + content_uri = await intent.upload_media(file, mime_type) db_file = DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type, was_converted=image_converted, diff --git a/mautrix_telegram/util/format_duration.py b/mautrix_telegram/util/format_duration.py index b98c6e71..d079cc0d 100644 --- a/mautrix_telegram/util/format_duration.py +++ b/mautrix_telegram/util/format_duration.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # diff --git a/mautrix_telegram/util/recursive_dict.py b/mautrix_telegram/util/recursive_dict.py index ef76fe3e..6fb0b7e2 100644 --- a/mautrix_telegram/util/recursive_dict.py +++ b/mautrix_telegram/util/recursive_dict.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -15,11 +14,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Dict, Any -from ..config import DictWithRecursion + +from mautrix.util.config import RecursiveDict def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool: - key, next_key = DictWithRecursion._parse_key(key) + key, next_key = RecursiveDict.parse_key(key) if next_key is not None: if key not in data: data[key] = {} @@ -32,7 +32,7 @@ def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool: def recursive_get(data: Dict[str, Any], key: str) -> Any: - key, next_key = DictWithRecursion._parse_key(key) + key, next_key = RecursiveDict.parse_key(key) if next_key is not None: next_data = data.get(key, None) if not next_data: @@ -42,7 +42,7 @@ def recursive_get(data: Dict[str, Any], key: str) -> Any: def recursive_del(data: Dict[str, any], key: str) -> bool: - key, next_key = DictWithRecursion._parse_key(key) + key, next_key = RecursiveDict.parse_key(key) if next_key is not None: if key not in data: return False diff --git a/mautrix_telegram/util/sane_mimetypes.py b/mautrix_telegram/util/sane_mimetypes.py index 2d79087b..272e514e 100644 --- a/mautrix_telegram/util/sane_mimetypes.py +++ b/mautrix_telegram/util/sane_mimetypes.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # diff --git a/mautrix_telegram/util/signed_token.py b/mautrix_telegram/util/signed_token.py deleted file mode 100644 index c8ba55d3..00000000 --- a/mautrix_telegram/util/signed_token.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: future_fstrings -*- -# 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 . -from typing import Dict, Optional -import json -import base64 -import hashlib - - -def _get_checksum(key: str, payload: bytes) -> str: - hasher = hashlib.sha256() - hasher.update(payload) - hasher.update(key.encode("utf-8")) - checksum = hasher.hexdigest() - return checksum - - -def sign_token(key: str, payload: Dict) -> str: - payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")) - checksum = _get_checksum(key, payload_b64) - return f"{checksum}:{payload_b64.decode('utf-8')}" - - -def verify_token(key: str, data: str) -> Optional[Dict]: - if not data: - return None - - try: - checksum, payload = data.split(":", 1) - except ValueError: - return None - - if checksum != _get_checksum(key, payload.encode("utf-8")): - return None - - payload = base64.urlsafe_b64decode(payload).decode("utf-8") - try: - return json.loads(payload) - except json.JSONDecodeError: - return None diff --git a/mautrix_telegram/web/common/auth_api.py b/mautrix_telegram/web/common/auth_api.py index ccb4e53c..39bdab4f 100644 --- a/mautrix_telegram/web/common/auth_api.py +++ b/mautrix_telegram/web/common/auth_api.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,27 +13,30 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from abc import abstractmethod from typing import Optional - -from aiohttp import web +from abc import abstractmethod import abc import asyncio import logging +from aiohttp import web + from telethon.errors import * +from mautrix.bridge import OnlyLoginSelf, InvalidAccessToken + from ...commands.telegram.auth import enter_password -from ...util import format_duration, ignore_coro -from ...puppet import Puppet, PuppetError +from ...util import format_duration +from ...puppet import Puppet from ...user import User class AuthAPI(abc.ABC): - log = logging.getLogger("mau.web.auth") # type: logging.Logger + log: logging.Logger = logging.getLogger("mau.web.auth") + loop: asyncio.AbstractEventLoop def __init__(self, loop: asyncio.AbstractEventLoop): - self.loop = loop # type: asyncio.AbstractEventLoop + self.loop = loop @abstractmethod def get_login_response(self, status: int = 200, state: str = "", username: str = "", @@ -56,15 +58,14 @@ class AuthAPI(abc.ABC): error="You have already logged in with your Matrix " "account.", errcode="already-logged-in") - resp = await puppet.switch_mxid(token.strip(), user.mxid) - if resp == PuppetError.OnlyLoginSelf: + try: + await puppet.switch_mxid(token.strip(), user.mxid) + except OnlyLoginSelf: return self.get_mx_login_response(status=403, errcode="only-login-self", error="You can only log in as your own Matrix user.") - elif resp == PuppetError.InvalidAccessToken: + except InvalidAccessToken: return self.get_mx_login_response(status=401, errcode="invalid-access-token", error="Failed to verify access token.") - assert resp == PuppetError.Success, "Encountered an unhandled PuppetError." - return self.get_mx_login_response(mxid=user.mxid, status=200, state="logged-in") async def post_matrix_password(self, user: User, password: str) -> web.Response: @@ -118,7 +119,7 @@ class AuthAPI(abc.ABC): existing_user = User.get_by_tgid(user_info.id) if existing_user and existing_user != user: 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": user.command_status = None diff --git a/mautrix_telegram/web/provisioning/__init__.py b/mautrix_telegram/web/provisioning/__init__.py index 5b793fdf..31b01a87 100644 --- a/mautrix_telegram/web/provisioning/__init__.py +++ b/mautrix_telegram/web/provisioning/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -14,20 +13,23 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from aiohttp import web from typing import Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKING import asyncio import logging import json +from aiohttp import web + from telethon.utils import get_peer_id, resolve_id from telethon.tl.types import ChatForbidden, ChannelForbidden, TypeChat -from mautrix_appservice import AppService, MatrixRequestError, IntentError -from ...types import MatrixUserID, TelegramID +from mautrix.appservice import AppService +from mautrix.errors import MatrixRequestError, IntentError +from mautrix.types import UserID + +from ...types import TelegramID from ...user import User from ...portal import Portal -from ...util import ignore_coro from ...commands.portal.util import user_has_power_level, get_initial_state from ..common import AuthAPI @@ -36,16 +38,19 @@ if TYPE_CHECKING: class ProvisioningAPI(AuthAPI): - log = logging.getLogger("mau.web.provisioning") # type: logging.Logger + log: logging.Logger = logging.getLogger("mau.web.provisioning") + secret: str + az: AppService + context: 'Context' + app: web.Application def __init__(self, context: "Context") -> None: super().__init__(context.loop) - self.secret = context.config["appservice.provisioning.shared_secret"] # type: str - self.az = context.az # type: AppService - self.context = context # type: Context + self.secret = context.config["appservice.provisioning.shared_secret"] + self.az = context.az + self.context = context - self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware] - ) # type: web.Application + self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware]) portal_prefix = "/portal/{mxid:![^/]+}" self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid) @@ -77,18 +82,7 @@ class ProvisioningAPI(AuthAPI): if not portal: return self.get_error_response(404, "portal_not_found", "Portal with given Matrix ID not found.") - user, _ = await self.get_user(request.query.get("user_id", None), expect_logged_in=None, - require_puppeting=False) - return web.json_response({ - "mxid": portal.mxid, - "chat_id": get_peer_id(portal.peer), - "peer_type": portal.peer_type, - "title": portal.title, - "about": portal.about, - "username": portal.username, - "megagroup": portal.megagroup, - "can_unbridge": (await portal.can_user_perform(user, "unbridge")) if user else False, - }) + return await self._get_portal_response(UserID(request.query.get("user_id", "")), portal) async def get_portal_by_tgid(self, request: web.Request) -> web.Response: err = self.check_authorization(request) @@ -104,8 +98,10 @@ class ProvisioningAPI(AuthAPI): if not portal: return self.get_error_response(404, "portal_not_found", "Portal to given Telegram chat not found.") - user, _ = await self.get_user(request.query.get("user_id", None), expect_logged_in=None, - require_puppeting=False) + return await self._get_portal_response(UserID(request.query.get("user_id", "")), portal) + + async def _get_portal_response(self, user_id: UserID, portal: Portal) -> web.Response: + user, _ = await self.get_user(user_id, expect_logged_in=None, require_puppeting=False) return web.json_response({ "mxid": portal.mxid, "chat_id": get_peer_id(portal.peer), @@ -169,7 +165,7 @@ class ProvisioningAPI(AuthAPI): return self.get_login_response(status=403, errcode="not_logged_in", error="You are not logged in and there is no relay bot.") - entity = None # type: Optional[TypeChat] + entity: Optional[TypeChat] = None try: entity = await acting_user.client.get_entity(portal.peer) except Exception: @@ -191,9 +187,8 @@ class ProvisioningAPI(AuthAPI): portal.photo_id = "" portal.save() - ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, - levels=levels), - loop=self.loop)) + asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels), + loop=self.loop) return web.Response(status=202, body="{}") @@ -272,7 +267,8 @@ class ProvisioningAPI(AuthAPI): require_puppeting=False, require_user=False) if err is not None: 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", "You do not have the permissions to unbridge that room.") @@ -287,7 +283,7 @@ class ProvisioningAPI(AuthAPI): self.log.exception("Failed to disconnect chat") return self.get_error_response(500, "exception", "Failed to disconnect chat") 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) async def get_user_info(self, request: web.Request) -> web.Response: @@ -320,11 +316,10 @@ class ProvisioningAPI(AuthAPI): return err if not user.is_bot: - chats = await user.get_dialogs() return web.json_response([{ "id": get_peer_id(chat), "title": chat.title, - } for chat in chats]) + } async for chat in user.client.get_dialogs(ignore_migrated=True, archived=False)]) else: return web.json_response([{ "id": get_peer_id(chat.peer), @@ -365,7 +360,8 @@ class ProvisioningAPI(AuthAPI): async def bridge_info(self, request: web.Request) -> web.Response: return web.json_response({ - "relaybot_username": self.context.bot.username if self.context.bot is not None else None, + "relaybot_username": (self.context.bot.username + if self.context.bot is not None else None), }, status=200) @staticmethod @@ -431,7 +427,7 @@ class ProvisioningAPI(AuthAPI): except json.JSONDecodeError: return None - async def get_user(self, mxid: MatrixUserID, expect_logged_in: Optional[bool] = False, + async def get_user(self, mxid: Optional[UserID], expect_logged_in: Optional[bool] = False, require_puppeting: bool = True, require_user: bool = True ) -> Tuple[Optional[User], Optional[web.Response]]: if not mxid: @@ -460,8 +456,7 @@ class ProvisioningAPI(AuthAPI): expect_logged_in: Optional[bool] = False, require_puppeting: bool = False, want_data: bool = True, - ) -> (Tuple[Optional[Dict], - Optional[User], + ) -> (Tuple[Optional[Dict], Optional[User], Optional[web.Response]]): err = self.check_authorization(request) if err is not None: diff --git a/mautrix_telegram/web/public/__init__.py b/mautrix_telegram/web/public/__init__.py index d1871c20..71f7de88 100644 --- a/mautrix_telegram/web/public/__init__.py +++ b/mautrix_telegram/web/public/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: future_fstrings -*- # mautrix-telegram - A Matrix-Telegram puppeting bridge # Copyright (C) 2019 Tulir Asokan # @@ -15,37 +14,42 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Optional -from aiohttp import web -from mako.template import Template -import pkg_resources import asyncio import logging import random import string import time -from ...types import MatrixUserID -from ...util import sign_token, verify_token +from mako.template import Template +from aiohttp import web +import pkg_resources + +from mautrix.types import UserID +from mautrix.util.signed_token import sign_token, verify_token + from ...user import User from ...puppet import Puppet from ..common import AuthAPI class PublicBridgeWebsite(AuthAPI): - log = logging.getLogger("mau.web.public") # type: logging.Logger + log: logging.Logger = logging.getLogger("mau.web.public") + secret_key: str + login: Template + mx_login: Template + app: web.Application def __init__(self, loop: asyncio.AbstractEventLoop): super().__init__(loop) - self.secret_key = "".join( - random.choice(string.ascii_lowercase + string.digits) for _ in range(64)) # type: str + self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64)) self.login = Template(pkg_resources.resource_string( - "mautrix_telegram", "web/public/login.html.mako")) # type: Template + "mautrix_telegram", "web/public/login.html.mako")) self.mx_login = Template(pkg_resources.resource_string( - "mautrix_telegram", "web/public/matrix-login.html.mako")) # type: Template + "mautrix_telegram", "web/public/matrix-login.html.mako")) - self.app = web.Application(loop=loop) # type: web.Application + self.app = web.Application(loop=loop) self.app.router.add_route("GET", "/login", self.get_login) self.app.router.add_route("POST", "/login", self.post_login) self.app.router.add_route("GET", "/matrix-login", self.get_matrix_login) @@ -60,11 +64,11 @@ class PublicBridgeWebsite(AuthAPI): "expiry": int(time.time()) + expires_in, }) - def verify_token(self, token: str, endpoint: str = "/login") -> Optional[MatrixUserID]: + def verify_token(self, token: str, endpoint: str = "/login") -> Optional[UserID]: token = verify_token(self.secret_key, token) if token and (token.get("expiry", 0) > int(time.time()) and token.get("endpoint", None) == endpoint): - return MatrixUserID(token.get("mxid", None)) + return UserID(token.get("mxid", None)) return None async def get_login(self, request: web.Request) -> web.Response: diff --git a/optional-requirements.txt b/optional-requirements.txt index 13017dc0..9e0a0ff0 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -1,4 +1,5 @@ cryptg Pillow moviepy -prometheus-client +prometheus_client +psycopg2-binary diff --git a/requirements.txt b/requirements.txt index e9e5ccc2..f8da589e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ aiohttp -mautrix-appservice +mautrix ruamel.yaml python-magic SQLAlchemy alembic commonmark -future-fstrings -telethon +#telethon +git+https://github.com/LonamiWebs/Telethon@master#egg=telethon telethon-session-sqlalchemy diff --git a/setup.py b/setup.py index 0f757c09..a8e824a8 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,8 @@ extras = { "fast_crypto": ["cryptg>=0.1,<0.3"], "webp_convert": ["Pillow>=4.3.0,<7"], "hq_thumbnails": ["moviepy>=1.0,<2.0"], - "metrics": ["prometheus-client>=0.6.0,<0.8.0"], + "metrics": ["prometheus_client>=0.6.0,<0.8.0"], + "postgres": ["psycopg2-binary>=2,<3"], } extras["all"] = list({dep for deps in extras.values() for dep in deps}) @@ -31,17 +32,17 @@ setuptools.setup( install_requires=[ "aiohttp>=3.0.1,<4", - "mautrix-appservice>=0.3.11,<0.4.0", + "mautrix>=0.4.0.dev53,<0.5", "SQLAlchemy>=1.2.3,<2", "alembic>=1.0.0,<2", "commonmark>=0.8.1,<1", "ruamel.yaml>=0.15.35,<0.16", - "future-fstrings>=0.4.2", "python-magic>=0.4.15,<0.5", "telethon>=1.9,<1.10", "telethon-session-sqlalchemy>=0.2.14,<0.3", ], extras_require=extras, + python_requires="~=3.6", setup_requires=["pytest-runner"], tests_require=["pytest", "pytest-asyncio", "pytest-mock"], @@ -53,8 +54,8 @@ setuptools.setup( "Framework :: AsyncIO", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", ], entry_points=""" [console_scripts] diff --git a/tests/commands/test_handler.py b/tests/commands/test_handler.py index 1e006db6..0a796378 100644 --- a/tests/commands/test_handler.py +++ b/tests/commands/test_handler.py @@ -5,12 +5,14 @@ import pytest from _pytest.fixtures import FixtureRequest from pytest_mock import MockFixture +from mautrix.types import EventID, RoomID, UserID +import mautrix.bridge.commands.handler + import mautrix_telegram.commands.handler from mautrix_telegram.commands.handler import (CommandEvent, CommandHandler, CommandProcessor, - HelpSection) + HelpSection, HelpCacheKey) from mautrix_telegram.config import Config from mautrix_telegram.context import Context -from mautrix_telegram.types import MatrixEventID, MatrixRoomID, MatrixUserID import mautrix_telegram.user as u from tests.utils.helpers import AsyncMock, list_true_once_each @@ -45,9 +47,9 @@ class TestCommandEvent: evt = CommandEvent( processor=command_processor, - room=MatrixRoomID("#mock_room:example.org"), - event=MatrixEventID("$H45H:example.org"), - sender=u.User(MatrixUserID("@sender:example.org")), + room_id=RoomID("#mock_room:example.org"), + event_id=EventID("$H45H:example.org"), + sender=u.User(UserID("@sender:example.org")), command="help", args=[], is_management=True, @@ -61,7 +63,7 @@ class TestCommandEvent: # html, no markdown evt.reply(message, allow_html=True, render_markdown=False) mock_az.intent.send_notice.assert_called_with( - MatrixRoomID("#mock_room:example.org"), + RoomID("#mock_room:example.org"), "**This** was
    allfun*!", html="**This** was
    allfun*!\n", ) @@ -69,7 +71,7 @@ class TestCommandEvent: # html, markdown (default) evt.reply(message, allow_html=True, render_markdown=True) mock_az.intent.send_notice.assert_called_with( - MatrixRoomID("#mock_room:example.org"), + RoomID("#mock_room:example.org"), "**This** was
    allfun*!", html=( "

    This was
    " @@ -80,7 +82,7 @@ class TestCommandEvent: # no html, no markdown evt.reply(message, allow_html=False, render_markdown=False) mock_az.intent.send_notice.assert_called_with( - MatrixRoomID("#mock_room:example.org"), + RoomID("#mock_room:example.org"), "**This** was
    allfun*!", html=None, ) @@ -88,7 +90,7 @@ class TestCommandEvent: # no html, markdown evt.reply(message, allow_html=False, render_markdown=True) mock_az.intent.send_notice.assert_called_with( - MatrixRoomID("#mock_room:example.org"), + RoomID("#mock_room:example.org"), "**This** was
    allfun*!", html="

    This <i>was</i><br/>" "<strong>all</strong>fun*!

    \n" @@ -100,9 +102,9 @@ class TestCommandEvent: evt = CommandEvent( processor=command_processor, - room=MatrixRoomID("#mock_room:example.org"), - event=MatrixEventID("$H45H:example.org"), - sender=u.User(MatrixUserID("@sender:example.org")), + room_id=RoomID("#mock_room:example.org"), + event_id=EventID("$H45H:example.org"), + sender=u.User(UserID("@sender:example.org")), command="help", args=[], is_management=False, @@ -115,7 +117,7 @@ class TestCommandEvent: render_markdown=False) mock_az.intent.send_notice.assert_called_with( - MatrixRoomID("#mock_room:example.org"), + RoomID("#mock_room:example.org"), "tg ....tg+sp...tg tg", html=None, ) @@ -126,9 +128,9 @@ class TestCommandEvent: evt = CommandEvent( processor=command_processor, - room=MatrixRoomID("#mock_room:example.org"), - event=MatrixEventID("$H45H:example.org"), - sender=u.User(MatrixUserID("@sender:example.org")), + room_id=RoomID("#mock_room:example.org"), + event_id=EventID("$H45H:example.org"), + sender=u.User(UserID("@sender:example.org")), command="help", args=[], is_management=True, @@ -144,7 +146,7 @@ class TestCommandEvent: ) mock_az.intent.send_notice.assert_called_with( - MatrixRoomID("#mock_room:example.org"), + RoomID("#mock_room:example.org"), "....tg+sp...tg tg", html="

    ....tg+sp...tg tg

    \n", ) @@ -195,15 +197,15 @@ class TestCommandHandler: help_section=HelpSection("Mock Section", 42, ""), ) - sender = u.User(MatrixUserID("@sender:example.org")) + sender = u.User(UserID("@sender:example.org")) sender.puppet_whitelisted = False sender.matrix_puppet_whitelisted = False sender.is_admin = False event = CommandEvent( processor=command_processor, - room=MatrixRoomID("#mock_room:example.org"), - event=MatrixEventID("$H45H:example.org"), + room_id=RoomID("#mock_room:example.org"), + event_id=EventID("$H45H:example.org"), sender=sender, command=command, args=[], @@ -212,7 +214,8 @@ class TestCommandHandler: ) assert await command_handler.get_permission_error(event) - assert not command_handler.has_permission(False, False, False, False, False) + assert not command_handler.has_permission( + HelpCacheKey(False, False, False, False, False, False)) @pytest.mark.parametrize( ( @@ -255,7 +258,7 @@ class TestCommandHandler: help_section=HelpSection("Mock Section", 42, ""), ) - sender = u.User(MatrixUserID("@sender:example.org")) + sender = u.User(UserID("@sender:example.org")) sender.puppet_whitelisted = puppet_whitelisted sender.matrix_puppet_whitelisted = matrix_puppet_whitelisted sender.is_admin = is_admin @@ -263,8 +266,8 @@ class TestCommandHandler: event = CommandEvent( processor=command_processor, - room=MatrixRoomID("#mock_room:example.org"), - event=MatrixEventID("$H45H:example.org"), + room_id=RoomID("#mock_room:example.org"), + event_id=EventID("$H45H:example.org"), sender=sender, command=command, args=[], @@ -274,12 +277,12 @@ class TestCommandHandler: assert not await command_handler.get_permission_error(event) assert command_handler.has_permission( - is_management=is_management, - puppet_whitelisted=puppet_whitelisted, - matrix_puppet_whitelisted=matrix_puppet_whitelisted, - is_admin=is_admin, - is_logged_in=is_logged_in, - ) + HelpCacheKey(is_management=is_management, + puppet_whitelisted=puppet_whitelisted, + matrix_puppet_whitelisted=matrix_puppet_whitelisted, + is_admin=is_admin, + is_logged_in=is_logged_in, + is_portal=boolean)) class TestCommandProcessor: @@ -292,41 +295,41 @@ class TestCommandProcessor: mocker: MockFixture) -> None: mocker.patch('mautrix_telegram.user.config', self.config) mocker.patch( - 'mautrix_telegram.commands.handler.command_handlers', + 'mautrix.bridge.commands.handler.command_handlers', {"help": AsyncMock(), "unknown-command": AsyncMock()} ) - sender = u.User(MatrixUserID("@sender:example.org")) + sender = u.User(UserID("@sender:example.org")) result = await command_processor.handle( - room=MatrixRoomID("#mock_room:example.org"), - event_id=MatrixEventID("$H45H:example.org"), + room_id=RoomID("#mock_room:example.org"), + event_id=EventID("$H45H:example.org"), sender=sender, command="hElp", args=[], is_management=boolean2[0], - is_portal=boolean2[1], - ) + is_portal=boolean2[1]) assert result is None - command_handlers = mautrix_telegram.commands.handler.command_handlers + command_handlers = mautrix.bridge.commands.handler.command_handlers command_handlers["help"].mock.assert_called_once() # type: ignore @pytest.mark.asyncio async def test_handle_unknown_command(self, command_processor: CommandProcessor, - boolean2: Tuple[bool, bool], mocker: MockFixture) -> None: + boolean2: Tuple[bool, bool], + mocker: MockFixture) -> None: mocker.patch('mautrix_telegram.user.config', self.config) mocker.patch( - 'mautrix_telegram.commands.handler.command_handlers', + 'mautrix.bridge.commands.handler.command_handlers', {"help": AsyncMock(), "unknown-command": AsyncMock()} ) - sender = u.User(MatrixUserID("@sender:example.org")) + sender = u.User(UserID("@sender:example.org")) sender.command_status = {} result = await command_processor.handle( - room=MatrixRoomID("#mock_room:example.org"), - event_id=MatrixEventID("$H45H:example.org"), + room_id=RoomID("#mock_room:example.org"), + event_id=EventID("$H45H:example.org"), sender=sender, command="foo", args=[], @@ -335,7 +338,7 @@ class TestCommandProcessor: ) assert result is None - command_handlers = mautrix_telegram.commands.handler.command_handlers + command_handlers = mautrix.bridge.commands.handler.command_handlers command_handlers["help"].mock.assert_not_called() # type: ignore command_handlers["unknown-command"].mock.assert_called_once() # type: ignore @@ -345,16 +348,16 @@ class TestCommandProcessor: mocker: MockFixture) -> None: mocker.patch('mautrix_telegram.user.config', self.config) mocker.patch( - 'mautrix_telegram.commands.handler.command_handlers', + 'mautrix.bridge.commands.handler.command_handlers', {"help": AsyncMock(), "unknown-command": AsyncMock()} ) - sender = u.User(MatrixUserID("@sender:example.org")) + sender = u.User(UserID("@sender:example.org")) sender.command_status = {"foo": AsyncMock(), "next": AsyncMock()} result = await command_processor.handle( - room=MatrixRoomID("#mock_room:example.org"), - event_id=MatrixEventID("$H45H:example.org"), + room_id=RoomID("#mock_room:example.org"), + event_id=EventID("$H45H:example.org"), sender=sender, # u.User command="foo", args=[], @@ -363,7 +366,7 @@ class TestCommandProcessor: ) assert result is None - command_handlers = mautrix_telegram.commands.handler.command_handlers + command_handlers = mautrix.bridge.commands.handler.command_handlers command_handlers["help"].mock.assert_not_called() # type: ignore command_handlers["unknown-command"].mock.assert_not_called() # type: ignore sender.command_status["foo"].mock.assert_not_called() # type: ignore