Merge pull request #352 from tulir/mautrix-0.4

Move to mautrix-python
This commit is contained in:
Tulir Asokan
2019-08-10 16:21:11 +03:00
committed by GitHub
79 changed files with 3965 additions and 5013 deletions
+2
View File
@@ -2,3 +2,5 @@
.codeclimate.yml .codeclimate.yml
*.png *.png
*.md *.md
logs
.venv
+16
View File
@@ -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
+2 -1
View File
@@ -37,7 +37,8 @@ RUN apk add --no-cache \
ffmpeg \ ffmpeg \
ca-certificates \ ca-certificates \
su-exec \ 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 VOLUME /data
+4 -10
View File
@@ -7,7 +7,8 @@ from os.path import abspath, dirname
sys.path.insert(0, dirname(dirname(abspath(__file__)))) 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 mautrix_telegram.config import Config
from alchemysession import AlchemySessionContainer 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_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
mxtg_config = Config(mxtg_config_path, None, None) mxtg_config = Config(mxtg_config_path, None, None)
mxtg_config.load() mxtg_config.load()
config.set_main_option("sqlalchemy.url", config.set_main_option("sqlalchemy.url", mxtg_config["appservice.database"])
mxtg_config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
class FakeDB: AlchemySessionContainer.create_table_classes(None, "telethon_", Base)
@staticmethod
def query_property():
return None
AlchemySessionContainer.create_table_classes(FakeDB(), "telethon_", Base)
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
# This line sets up loggers basically. # This line sets up loggers basically.
@@ -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)")
@@ -5,14 +5,16 @@ Revises: 2228d49c383f
Create Date: 2018-06-26 21:31:26.911307 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 json
import re 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.config import Config
from mautrix_telegram.db import Base
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = "6ca3d74d51e4" revision = "6ca3d74d51e4"
@@ -22,7 +24,6 @@ depends_on = None
class RoomState(Base): class RoomState(Base):
query = None
__tablename__ = "mx_room_state" __tablename__ = "mx_room_state"
__table_args__ = {"extend_existing": True} __table_args__ = {"extend_existing": True}
@@ -31,7 +32,6 @@ class RoomState(Base):
class UserProfile(Base): class UserProfile(Base):
query = None
__tablename__ = "mx_user_profile" __tablename__ = "mx_user_profile"
__table_args__ = {"extend_existing": True} __table_args__ = {"extend_existing": True}
@@ -43,7 +43,6 @@ class UserProfile(Base):
class Puppet(Base): class Puppet(Base):
query = None
__tablename__ = "puppet" __tablename__ = "puppet"
__table_args__ = {"extend_existing": True} __table_args__ = {"extend_existing": True}
@@ -83,7 +82,7 @@ def upgrade():
def migrate_state_store(): def migrate_state_store():
conn = op.get_bind() conn = op.get_bind()
session = orm.sessionmaker(bind=conn)() # type: orm.Session session: orm.Session = orm.sessionmaker(bind=conn)()
try: try:
with open("mx-state.json") as file: with open("mx-state.json") as file:
@@ -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")
+24 -15
View File
@@ -103,6 +103,8 @@ bridge:
- full name - full name
- username - username
- phone number - 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 # 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 # synced when they send messages. The maximum is 10000, after which the Telegram server
@@ -119,9 +121,10 @@ bridge:
# their Telegram account at startup. # their Telegram account at startup.
startup_sync: true startup_sync: true
# Number of most recently active dialogs to check when syncing chats. # 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. # Set to 0 to remove limit.
sync_dialog_limit: 30 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. # The maximum number of simultaneous Telegram deletions to handle.
# A large number of simultaneous redactions could put strain on your homeserver. # A large number of simultaneous redactions could put strain on your homeserver.
max_telegram_delete: 10 max_telegram_delete: 10
@@ -177,6 +180,7 @@ bridge:
# The formats to use when sending messages to Telegram via the relay bot. # 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. # 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_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_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) # $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: message_formats:
m.text: "<b>$sender_displayname</b>: $message" m.text: "<b>$sender_displayname</b>: $formatted_body"
m.emote: "* <b>$sender_displayname</b> $message" m.notice: "<b>$sender_displayname</b>: $formatted_body"
m.file: "<b>$sender_displayname</b> sent a file: $message" m.emote: "* <b>$sender_displayname</b> $formatted_body"
m.image: "<b>$sender_displayname</b> sent an image: $message" m.file: "$sender_displayname sent a file: $body"
m.audio: "<b>$sender_displayname</b> sent an audio file: $message" m.image: "$sender_displayname sent an image: $body"
m.video: "<b>$sender_displayname</b> sent a video: $message" m.audio: "$sender_displayname sent an audio file: $body"
m.location: "<b>$sender_displayname</b> sent a location: $message" 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. # The formats to use when sending state events to Telegram via the relay bot.
# #
@@ -307,14 +313,14 @@ telegram:
# Telethon proxy configuration. # Telethon proxy configuration.
# You must install PySocks from pip for proxies to work. # You must install PySocks from pip for proxies to work.
proxy: proxy:
# Allowed types: disabled, socks4, socks5, http # Allowed types: disabled, socks4, socks5, http, mtproxy
type: disabled type: disabled
# Proxy IP address and port. # Proxy IP address and port.
address: 127.0.0.1 address: 127.0.0.1
port: 1080 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 rdns: true
# Proxy authentication (optional). # Proxy authentication (optional). Put MTProxy secret in password field.
username: "" username: ""
password: "" password: ""
@@ -325,18 +331,21 @@ telegram:
logging: logging:
version: 1 version: 1
formatters: 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" format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
handlers: handlers:
file: file:
class: logging.handlers.RotatingFileHandler class: logging.handlers.RotatingFileHandler
formatter: precise formatter: normal
filename: ./mautrix-telegram.log filename: ./mautrix-telegram.log
maxBytes: 10485760 maxBytes: 10485760
backupCount: 10 backupCount: 10
console: console:
class: logging.StreamHandler class: logging.StreamHandler
formatter: precise formatter: colored
loggers: loggers:
mau: mau:
level: DEBUG level: DEBUG
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.6.0" __version__ = "0.7.0+dev"
__author__ = "Tulir Asokan <tulir@maunium.net>" __author__ = "Tulir Asokan <tulir@maunium.net>"
+58 -116
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,32 +13,25 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, List, Any from itertools import chain
from time import time
import argparse
import asyncio
import logging.config
import sys import sys
import copy
import signal
import os
import sqlalchemy as sql
from mautrix_appservice import AppService
from alchemysession import AlchemySessionContainer from alchemysession import AlchemySessionContainer
from mautrix.bridge import Bridge
from mautrix.bridge.db import Base
from .web.provisioning import ProvisioningAPI from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite from .web.public import PublicBridgeWebsite
from .abstract_user import init as init_abstract_user from .abstract_user import init as init_abstract_user
from .bot import init as init_bot from .bot import Bot, init as init_bot
from .config import Config from .config import Config
from .context import Context from .context import Context
from .db import Base, init as init_db from .db import init as init_db
from .formatter import init as init_formatter from .formatter import init as init_formatter
from .matrix import MatrixHandler from .matrix import MatrixHandler
from .portal import init as init_portal from .portal import init as init_portal
from .puppet import init as init_puppet from .puppet import Puppet, init as init_puppet
from .sqlstatestore import SQLStateStore from .sqlstatestore import SQLStateStore
from .user import User, init as init_user from .user import User, init as init_user
from . import __version__ from . import __version__
@@ -49,115 +41,65 @@ try:
except ImportError: except ImportError:
prometheus = None 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="<path>", help="the path to your config file")
parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml",
metavar="<path>", 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="<path>", help="the path to save the generated registration to")
args = parser.parse_args()
config = Config(args.config, args.registration, args.base_config, os.environ) class TelegramBridge(Bridge):
config.load() name = "mautrix-telegram"
config.update() 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: Config
config.generate_registration() session_container: AlchemySessionContainer
config.save() bot: Bot
print(f"Registration generated and saved to {config.registration_path}")
sys.exit(0)
logging.config.dictConfig(copy.deepcopy(config["logging"])) def prepare_db(self) -> None:
log = logging.getLogger("mau.init") # type: logging.Logger super().prepare_db()
log.debug(f"Initializing mautrix-telegram {__version__}") 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") def _prepare_website(self, context: Context) -> None:
Base.metadata.bind = db_engine 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, if self.config["appservice.provisioning.enabled"]:
table_prefix="telethon_", manage_tables=False) provisioning_api = ProvisioningAPI(context)
session_container.core_mode = True self.az.app.add_subapp(self.config["appservice.provisioning.prefix"],
provisioning_api.app)
context.provisioning_api = provisioning_api
try: if self.config["metrics.enabled"]:
import uvloop 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()) def prepare_bridge(self) -> None:
log.debug("Using uvloop for asyncio") self.bot = init_bot(self.config)
except ImportError: context = Context(self.az, self.config, self.loop, self.session_container, self.bot)
pass 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() def prepare_stop(self) -> None:
mebibyte = 1024 ** 2 for puppet in Puppet.by_custom_mxid.values():
appserv = AppService(config["homeserver.address"], config["homeserver.domain"], puppet.stop()
config["appservice.as_token"], config["appservice.hs_token"], self.shutdown_actions = (user.stop() for user in User.by_tgid.values())
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)
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"]: TelegramBridge().run()
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)
+85 -66
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,28 +13,33 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Tuple, Optional, List, Union, Dict, TYPE_CHECKING from typing import Tuple, Optional, Union, Dict, Type, Any, TYPE_CHECKING
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import logging import logging
import platform import platform
import time import time
from telethon.sessions import Session
from telethon.network import (ConnectionTcpMTProxyRandomizedIntermediate, ConnectionTcpFull,
Connection)
from telethon.tl.patched import MessageService, Message from telethon.tl.patched import MessageService, Message
from telethon.tl.types import ( from telethon.tl.types import (
Channel, ChannelForbidden, Chat, ChatForbidden, MessageActionChannelMigrateFrom, PeerUser, Channel, Chat, MessageActionChannelMigrateFrom, PeerUser, TypeUpdate, UpdateChatPinnedMessage,
TypeUpdate, UpdateChannelPinnedMessage, UpdateChatPinnedMessage, UpdateChatParticipantAdmin, UpdateChannelPinnedMessage, UpdateChatParticipantAdmin, UpdateChatParticipants,
UpdateChatParticipants, UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateDeleteMessages, UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateNewMessage, UpdateDeleteMessages,
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateNewMessage, UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateReadHistoryOutbox,
UpdateReadHistoryOutbox, UpdateShortChatMessage, UpdateShortMessage, UpdateUserName, UpdateShortChatMessage, UpdateShortMessage, UpdateUserName, UpdateUserPhoto, UpdateUserStatus,
UpdateUserPhoto, UpdateUserStatus, UpdateUserTyping, User, UserStatusOffline, UserStatusOnline) 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 alchemysession import AlchemySessionContainer
from . import portal as po, puppet as pu, __version__ from . import portal as po, puppet as pu, __version__
from .db import Message as DBMessage from .db import Message as DBMessage
from .types import TelegramID, MatrixUserID from .types import TelegramID
from .tgclient import MautrixTelegramClient from .tgclient import MautrixTelegramClient
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -43,9 +47,9 @@ if TYPE_CHECKING:
from .config import Config from .config import Config
from .bot import Bot from .bot import Bot
config = None # type: Config config: Optional['Config'] = None
# Value updated from config in init() # Value updated from config in init()
MAX_DELETIONS = 10 # type: int MAX_DELETIONS: int = 10
UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage, UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage] UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
@@ -60,47 +64,67 @@ except ImportError:
Histogram = None Histogram = None
UPDATE_TIME = None UPDATE_TIME = None
class AbstractUser(ABC): class AbstractUser(ABC):
session_container = None # type: AlchemySessionContainer session_container: AlchemySessionContainer = None
loop = None # type: asyncio.AbstractEventLoop loop: asyncio.AbstractEventLoop = None
log = None # type: logging.Logger log: logging.Logger
az = None # type: AppService az: AppService
bot = None # type: Bot relaybot: Optional['Bot']
ignore_incoming_bot_events = True # type: bool 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: def __init__(self) -> None:
self.is_admin = False # type: bool self.is_admin = False
self.matrix_puppet_whitelisted = False # type: bool self.matrix_puppet_whitelisted = False
self.puppet_whitelisted = False # type: bool self.puppet_whitelisted = False
self.whitelisted = False # type: bool self.whitelisted = False
self.relaybot_whitelisted = False # type: bool self.relaybot_whitelisted = False
self.client = None # type: MautrixTelegramClient self.client = None
self.tgid = None # type: TelegramID self.is_relaybot = False
self.mxid = None # type: MatrixUserID self.is_bot = False
self.is_relaybot = False # type: bool self.relaybot = None
self.is_bot = False # type: bool
self.relaybot = None # type: Optional[Bot]
@property @property
def connected(self) -> bool: def connected(self) -> bool:
return self.client and self.client.is_connected() return self.client and self.client.is_connected()
@property @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() 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": if proxy_type == "disabled":
return None connection_data = None
elif proxy_type == "socks4": elif proxy_type == "socks4":
proxy_type = 1 connection_data = (1,) + connection_data
elif proxy_type == "socks5": elif proxy_type == "socks5":
proxy_type = 2 connection_data = (2,) + connection_data
elif proxy_type == "http": 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, return connection, connection_data
config["telegram.proxy.address"], config["telegram.proxy.port"],
config["telegram.proxy.rdns"],
config["telegram.proxy.username"], config["telegram.proxy.password"])
def _init_client(self) -> None: def _init_client(self) -> None:
self.log.debug(f"Initializing client for {self.name}") self.log.debug(f"Initializing client for {self.name}")
@@ -119,6 +143,9 @@ class AbstractUser(ABC):
device = config["telegram.device_info.device_model"] device = config["telegram.device_info.device_model"]
sysversion = config["telegram.device_info.system_version"] sysversion = config["telegram.device_info.system_version"]
appversion = config["telegram.device_info.app_version"] appversion = config["telegram.device_info.app_version"]
connection, proxy = self._proxy_settings
assert isinstance(self.session, Session)
self.client = MautrixTelegramClient( self.client = MautrixTelegramClient(
session=self.session, session=self.session,
@@ -127,16 +154,18 @@ class AbstractUser(ABC):
api_hash=config["telegram.api_hash"], api_hash=config["telegram.api_hash"],
app_version=__version__ if appversion == "auto" else appversion, app_version=__version__ if appversion == "auto" else appversion,
system_version=MautrixTelegramClient.__version__ if sysversion == "auto" else sysversion, system_version=(MautrixTelegramClient.__version__
device_model=f"{platform.system()} {platform.release()}" if device == "auto" else device, if sysversion == "auto" else sysversion),
device_model=(f"{platform.system()} {platform.release()}"
if device == "auto" else device),
timeout=config["telegram.connection.timeout"], timeout=config["telegram.connection.timeout"],
connection_retries=config["telegram.connection.retries"], connection_retries=config["telegram.connection.retries"],
retry_delay=config["telegram.connection.retry_delay"], retry_delay=config["telegram.connection.retry_delay"],
flood_sleep_threshold=config["telegram.connection.flood_sleep_threshold"], flood_sleep_threshold=config["telegram.connection.flood_sleep_threshold"],
request_retries=config["telegram.connection.request_retries"], request_retries=config["telegram.connection.request_retries"],
connection=connection,
proxy=self._proxy_settings, proxy=proxy,
loop=self.loop, loop=self.loop,
base_logger=base_logger base_logger=base_logger
@@ -165,26 +194,18 @@ class AbstractUser(ABC):
if not await self.update(update): if not await self.update(update):
await self._update(update) await self._update(update)
except Exception: except Exception:
self.log.exception("Failed to handle Telegram update") self.log.exception(f"Failed to handle Telegram update {update}")
if UPDATE_TIME: if UPDATE_TIME:
UPDATE_TIME.labels(update_type=type(update).__name__).observe(time.time() - start_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 @property
@abstractmethod @abstractmethod
def name(self) -> str: def name(self) -> str:
raise NotImplementedError() raise NotImplementedError()
async def is_logged_in(self) -> bool: 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: async def has_full_access(self, allow_bot: bool = False) -> bool:
return (self.puppet_whitelisted return (self.puppet_whitelisted
@@ -195,14 +216,15 @@ class AbstractUser(ABC):
if not self.client: if not self.client:
self._init_client() self._init_client()
await self.client.connect() 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 return self
async def ensure_started(self, even_if_no_session=False) -> 'AbstractUser': async def ensure_started(self, even_if_no_session=False) -> 'AbstractUser':
if not self.puppet_whitelisted or self.connected: if self.connected:
return self 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): 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) await self.start(delete_unless_authenticated=not even_if_no_session)
return self return self
@@ -317,9 +339,9 @@ class AbstractUser(ABC):
async def update_status(self, update: UpdateUserStatus) -> None: async def update_status(self, update: UpdateUserStatus) -> None:
puppet = pu.Puppet.get(TelegramID(update.user_id)) puppet = pu.Puppet.get(TelegramID(update.user_id))
if isinstance(update.status, UserStatusOnline): 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): elif isinstance(update.status, UserStatusOffline):
await puppet.default_mxid_intent.set_presence("offline") await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE)
else: else:
self.log.warning("Unexpected user status update: %s", update) self.log.warning("Unexpected user status update: %s", update)
return return
@@ -355,7 +377,7 @@ class AbstractUser(ABC):
return return
try: try:
await portal.main_intent.redact(message.mx_room, message.mxid) await portal.main_intent.redact(message.mx_room, message.mxid)
except MatrixRequestError: except MatrixError:
pass pass
async def delete_message(self, update: UpdateDeleteMessages) -> None: async def delete_message(self, update: UpdateDeleteMessages) -> None:
@@ -363,12 +385,10 @@ class AbstractUser(ABC):
return return
for message_id in update.messages: for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid) for message in DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
for message in messages:
message.delete() message.delete()
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room) number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
if number_left == 0: if number_left == 0:
portal = po.Portal.get_by_mxid(message.mx_room)
await self._try_redact(message) await self._try_redact(message)
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None: async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
@@ -378,8 +398,7 @@ class AbstractUser(ABC):
channel_id = TelegramID(update.channel_id) channel_id = TelegramID(update.channel_id)
for message_id in update.messages: for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id) for message in DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id):
for message in messages:
message.delete() message.delete()
await self._try_redact(message) await self._try_redact(message)
@@ -391,7 +410,7 @@ class AbstractUser(ABC):
portal.tgid_log) portal.tgid_log)
return 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) self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log)
return return
@@ -415,7 +434,7 @@ class AbstractUser(ABC):
# endregion # endregion
def init(context: "Context") -> None: def init(context: 'Context') -> None:
global config, MAX_DELETIONS global config, MAX_DELETIONS
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"] AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
+43 -37
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,9 +13,8 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from 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 logging
import re
from telethon.tl.patched import Message, MessageService from telethon.tl.patched import Message, MessageService
from telethon.tl.types import ( 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.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
from telethon.errors import ChannelInvalidError, ChannelPrivateError from telethon.errors import ChannelInvalidError, ChannelPrivateError
from .types import MatrixUserID from mautrix.types import UserID
from .abstract_user import AbstractUser from .abstract_user import AbstractUser
from .db import BotChat from .db import BotChat
from .types import TelegramID from .types import TelegramID
@@ -36,34 +35,41 @@ from . import puppet as pu, portal as po, user as u
if TYPE_CHECKING: if TYPE_CHECKING:
from .config import Config from .config import Config
from .context import Context
config = None # type: Config config: Optional['Config'] = None
ReplyFunc = Callable[[str], Awaitable[Message]] ReplyFunc = Callable[[str], Awaitable[Message]]
class Bot(AbstractUser): class Bot(AbstractUser):
log = logging.getLogger("mau.bot") # type: logging.Logger log: logging.Logger = logging.getLogger("mau.user.bot")
mxid_regex = re.compile("@.+:.+") # type: Pattern
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: def __init__(self, token: str) -> None:
super().__init__() super().__init__()
self.token = token # type: str self.token = token
self.puppet_whitelisted = True # type: bool self.tgid = None
self.whitelisted = True # type: bool self.mxid = None
self.relaybot_whitelisted = True # type: bool self.puppet_whitelisted = True
self.username = None # type: str self.whitelisted = True
self.is_relaybot = True # type: bool self.relaybot_whitelisted = True
self.is_bot = True # type: bool self.username = None
self.chats = {} # type: Dict[int, str] self.is_relaybot = True
self.tg_whitelist = [] # type: List[int] self.is_bot = True
self.chats = {}
self.tg_whitelist = []
self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"] self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"]
or False) # type: bool or False)
self._me_info = None # type: Optional[User] self._me_info = None
self._me_mxid = None # type: Optional[MatrixUserID] 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: if not use_cache or not self._me_mxid:
self._me_info = await self.client.get_me() self._me_info = await self.client.get_me()
self._me_mxid = pu.Puppet.get_mxid_from_id(TelegramID(self._me_info.id)) 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: async def post_login(self) -> None:
await self.init_permissions() await self.init_permissions()
info = await self.client.get_me() info = await self.client.get_me()
self.tgid = info.id self.tgid = TelegramID(info.id)
self.username = info.username self.username = info.username
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid) 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: if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
self.remove_chat(TelegramID(chat.id)) 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() for chat_id, chat_type in self.chats.items()
if chat_type == "channel"] if chat_type == "channel")
for channel_id in channel_ids: for channel_id in channel_ids:
try: try:
await self.client(GetChannelsRequest([channel_id])) await self.client(GetChannelsRequest([channel_id]))
@@ -133,7 +139,7 @@ class Bot(AbstractUser):
del self.chats[chat_id] del self.chats[chat_id]
except KeyError: except KeyError:
pass pass
BotChat.delete(chat_id) BotChat.delete_by_id(chat_id)
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool: async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
if tgid in self.tg_whitelist: if tgid in self.tg_whitelist:
@@ -157,7 +163,7 @@ class Bot(AbstractUser):
return False return False
async def check_can_use_commands(self, event: Message, reply: ReplyFunc) -> bool: 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.") await reply("You do not have the permission to use that command.")
return False return False
return True return True
@@ -166,7 +172,7 @@ class Bot(AbstractUser):
if not config["bridge.relaybot.authless_portals"]: if not config["bridge.relaybot.authless_portals"]:
return await reply("This bridge doesn't allow portal creation from Telegram.") 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.") return await reply("This bridge doesn't allow bridging this chat.")
await portal.create_matrix_room(self) await portal.create_matrix_room(self)
@@ -179,15 +185,15 @@ class Bot(AbstractUser):
"Portal is not public. Use `/invite <mxid>` to get an invite.") "Portal is not public. Use `/invite <mxid>` to get an invite.")
async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc, async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc,
mxid_input: MatrixUserID) -> Message: mxid_input: UserID) -> Message:
if len(mxid_input) == 0: if len(mxid_input) == 0:
return await reply("Usage: `/invite <mxid>`") return await reply("Usage: `/invite <mxid>`")
elif not portal.mxid: elif not portal.mxid:
return await reply("Portal does not have Matrix room. " return await reply("Portal does not have Matrix room. "
"Create one with /portal first.") "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.") 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: if not user.relaybot_whitelisted:
return await reply("That user is not whitelisted to use the bridge.") return await reply("That user is not whitelisted to use the bridge.")
elif await user.is_logged_in(): elif await user.is_logged_in():
@@ -195,7 +201,7 @@ class Bot(AbstractUser):
return await reply("That user seems to be logged in. " return await reply("That user seems to be logged in. "
f"Just invite [{displayname}](tg://user?id={user.tgid})") f"Just invite [{displayname}](tg://user?id={user.tgid})")
else: 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.") return await reply(f"Invited `{user.mxid}` to the portal.")
@staticmethod @staticmethod
@@ -244,15 +250,15 @@ class Bot(AbstractUser):
mxid = text[text.index(" ") + 1:] mxid = text[text.index(" ") + 1:]
except ValueError: except ValueError:
mxid = "" 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: def handle_service_message(self, message: MessageService) -> None:
to_id = message.to_id # type: TelegramID to_peer = message.to_id
if isinstance(to_id, PeerChannel): if isinstance(to_peer, PeerChannel):
to_id = to_id.channel_id to_id = TelegramID(to_peer.channel_id)
chat_type = "channel" chat_type = "channel"
elif isinstance(to_id, PeerChat): elif isinstance(to_peer, PeerChat):
to_id = to_id.chat_id to_id = TelegramID(to_peer.chat_id)
chat_type = "chat" chat_type = "chat"
else: else:
return return
+8 -5
View File
@@ -1,5 +1,8 @@
from .handler import (command_handler, command_handlers as _command_handlers, from .handler import (command_handler, CommandHandler, CommandProcessor, CommandEvent,
CommandHandler, CommandProcessor, CommandEvent, SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT,
SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_MISC, SECTION_ADMIN)
SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN) from . import portal, telegram, clean_rooms, matrix_auth
from . import portal, telegram, clean_rooms, matrix_auth, meta
__all__ = ["command_handler", "CommandHandler", "CommandProcessor", "CommandEvent",
"SECTION_AUTH", "SECTION_MISC", "SECTION_ADMIN", "SECTION_CREATING_PORTALS",
"SECTION_PORTAL_MANAGEMENT"]
+22 -23
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,41 +13,41 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, 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 command_handler, CommandEvent, SECTION_ADMIN
from .. import puppet as pu, portal as po 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']]: List['po.Portal'], List['po.Portal']]:
management_rooms = [] # type: List[ManagementRoom] management_rooms: List[ManagementRoom] = []
unidentified_rooms = [] # type: List[MatrixRoomID] unidentified_rooms: List[RoomID] = []
portals = [] # type: List[po.Portal] portals: List[po.Portal] = []
empty_portals = [] # type: List[po.Portal] empty_portals: List[po.Portal] = []
rooms = await intent.get_joined_rooms() rooms = await intent.get_joined_rooms()
for room_str in rooms: for room_id in rooms:
room = MatrixRoomID(room_str) portal = po.Portal.get_by_mxid(room_id)
portal = po.Portal.get_by_mxid(room)
if not portal: if not portal:
try: try:
members = await intent.get_room_members(room) members = await intent.get_room_members(room_id)
except MatrixRequestError: except MatrixRequestError:
members = [] members = []
if len(members) == 2: 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): if pu.Puppet.get_id_from_mxid(other_member):
unidentified_rooms.append(room) unidentified_rooms.append(room_id)
else: else:
management_rooms.append(ManagementRoom((room, other_member))) management_rooms.append(ManagementRoom(room_id, other_member))
else: else:
unidentified_rooms.append(room) unidentified_rooms.append(room_id)
else: else:
members = await portal.get_authenticated_matrix_users() members = await portal.get_authenticated_matrix_users()
if len(members) == 0: 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", @command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms",
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_text="Clean up unused portal/management rooms.") help_text="Clean up unused portal/management rooms.")
async def clean_rooms(evt: CommandEvent) -> Optional[Dict]: async def clean_rooms(evt: CommandEvent) -> EventID:
management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent) management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
reply = ["#### Management rooms (M)"] reply = ["#### Management rooms (M)"]
@@ -108,10 +107,10 @@ async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom], async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
unidentified_rooms: List[MatrixRoomID], portals: List["po.Portal"], unidentified_rooms: List[RoomID], portals: List["po.Portal"],
empty_portals: List["po.Portal"]) -> None: empty_portals: List["po.Portal"]) -> None:
command = evt.args[0] command = evt.args[0]
rooms_to_clean = [] # type: List[Union[po.Portal, MatrixRoomID]] rooms_to_clean: List[Union[po.Portal, RoomID]] = []
if command == "clean-recommended": if command == "clean-recommended":
rooms_to_clean += empty_portals rooms_to_clean += empty_portals
rooms_to_clean += unidentified_rooms rooms_to_clean += unidentified_rooms
@@ -160,7 +159,7 @@ async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
"`$cmdprefix+sp confirm-clean`.") "`$cmdprefix+sp confirm-clean`.")
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, MatrixRoomID]]) -> None: async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, RoomID]]) -> None:
if len(evt.args) > 0 and evt.args[0] == "confirm-clean": if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. " await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
"This might take a while.") "This might take a while.")
@@ -169,7 +168,7 @@ async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, Matrix
if isinstance(room, po.Portal): if isinstance(room, po.Portal):
await room.cleanup_and_delete() await room.cleanup_and_delete()
cleaned += 1 cleaned += 1
elif isinstance(room, str): # str is aliased by MatrixRoomID else:
await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted") await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted")
cleaned += 1 cleaned += 1
evt.sender.command_status = None evt.sender.command_status = None
+57 -296
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -15,23 +14,23 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
"""This module contains classes handling commands issued by Matrix users.""" """This module contains classes handling commands issued by Matrix users."""
from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional from typing import Awaitable, Callable, List, Optional, NamedTuple, Any
import logging
import traceback
import commonmark
from telethon.errors import FloodWaitError from telethon.errors import FloodWaitError
from ..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 ..util import format_duration
from .. import user as u, context as c 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_AUTH = HelpSection("Authentication", 10, "")
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "") SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "") SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "")
@@ -39,186 +38,42 @@ SECTION_MISC = HelpSection("Miscellaneous", 40, "")
SECTION_ADMIN = HelpSection("Administration", 50, "") SECTION_ADMIN = HelpSection("Administration", 50, "")
class HtmlEscapingRenderer(commonmark.HtmlRenderer): class CommandEvent(BaseCommandEvent):
def __init__(self, allow_html: bool = False): sender: u.User
super().__init__()
self.allow_html = allow_html
def lit(self, s): def __init__(self, processor: 'CommandProcessor', room_id: RoomID, event_id: EventID,
if self.allow_html:
return super().lit(s)
return super().lit(s.replace("<", "&lt;").replace(">", "&gt;"))
def image(self, node, entering):
prev = self.allow_html
self.allow_html = True
super().image(node, entering)
self.allow_html = prev
md_parser = commonmark.Parser()
md_renderer = HtmlEscapingRenderer()
def ensure_trailing_newline(s: str) -> str:
"""Returns the passed string, but with a guaranteed trailing newline."""
return s + ("" if s[-1] == "\n" else "\n")
class CommandEvent:
"""Holds information about a command issued in a Matrix room.
When a Matrix command was issued to the bot, CommandEvent will hold
information regarding the event.
Attributes:
room_id: The id of the Matrix room in which the command was issued.
event_id: The id of the matrix event which contained the command.
sender: The user who issued the command.
command: The issued command.
args: Arguments given with the issued command.
is_management: Determines whether the room in which the command wa
issued is a management room.
is_portal: Determines whether the room in which the command was issued
is a portal.
"""
def __init__(self, processor: 'CommandProcessor', room: MatrixRoomID, event: MatrixEventID,
sender: u.User, command: str, args: List[str], is_management: bool, sender: u.User, command: str, args: List[str], is_management: bool,
is_portal: bool) -> None: is_portal: bool) -> None:
self.az = processor.az super().__init__(processor, room_id, event_id, sender, command, args, is_management,
self.log = processor.log is_portal)
self.loop = processor.loop
self.tgbot = processor.tgbot self.tgbot = processor.tgbot
self.config = processor.config self.config = processor.config
self.public_website = processor.public_website self.public_website = processor.public_website
self.command_prefix = processor.command_prefix
self.room_id = room
self.event_id = event
self.sender = sender
self.command = command
self.args = args
self.is_management = is_management
self.is_portal = is_portal
def reply(self, message: str, allow_html: bool = False, render_markdown: bool = True async def get_help_key(self) -> HelpCacheKey:
) -> Awaitable[Dict]: return HelpCacheKey(self.is_management, self.is_portal, self.sender.puppet_whitelisted,
"""Write a reply to the room in which the command was issued. self.sender.matrix_puppet_whitelisted, self.sender.is_admin,
await self.sender.is_logged_in())
Replaces occurences of "$cmdprefix" in the message with the command
prefix and replaces occurences of "$cmdprefix+sp " with the command
prefix if the command was not issued in a management room.
If allow_html and render_markdown are both False, the message will not
be rendered to html and sending of html is disabled.
Args:
message: The message to post in the room.
allow_html: Escape html in the message or don't render html at all
if markdown is disabled.
render_markdown: Use markdown formatting to render the passed
message to html.
Returns:
Handler for the message sending function.
"""
message_cmd = self._replace_command_prefix(message)
html = self._render_message(message_cmd, allow_html=allow_html,
render_markdown=render_markdown)
return self.az.intent.send_notice(self.room_id, message_cmd, html=html)
def mark_read(self) -> Awaitable[Dict]:
"""Marks the command as read by the bot."""
return self.az.intent.mark_read(self.room_id, self.event_id)
def _replace_command_prefix(self, message: str) -> str:
"""Returns the string with the proper command prefix entered."""
message = message.replace(
"$cmdprefix+sp ", "" if self.is_management else f"{self.command_prefix} "
)
return message.replace("$cmdprefix", self.command_prefix)
@staticmethod
def _render_message(message: str, allow_html: bool, render_markdown: bool) -> Optional[str]:
"""Renders the message as HTML.
Args:
allow_html: Flag to allow custom HTML in the message.
render_markdown: If true, markdown styling is applied to the message.
Returns:
The message rendered as HTML.
None is returned if no styled output is required.
"""
html = ""
if render_markdown:
md_renderer.allow_html = allow_html
html = md_renderer.render(md_parser.parse(message))
elif allow_html:
html = message
return ensure_trailing_newline(html) if html else None
class CommandHandler: class CommandHandler(BaseCommandHandler):
"""A command which can be executed from a Matrix room. name: str
The command manages its permission and help texts. management_only: bool
When called, it will check the permission of the command event and execute needs_auth: bool
the command or, in case of error, report back to the user. needs_puppeting: bool
needs_matrix_puppeting: bool
needs_admin: bool
Attributes: def __init__(self, handler: Callable[[CommandEvent], Awaitable[EventID]],
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,
management_only: bool, name: str, help_text: str, help_args: str, management_only: bool, name: str, help_text: str, help_args: str,
help_section: HelpSection) -> None: help_section: HelpSection, needs_auth: bool, needs_puppeting: bool,
""" needs_matrix_puppeting: bool, needs_admin: bool,) -> None:
Args: super().__init__(handler, management_only, name, help_text, help_args, help_section,
handler: The function handling the execution of this command. needs_auth=needs_auth, needs_puppeting=needs_puppeting,
needs_auth: Flag indicating if the sender is required to be logged in. needs_matrix_puppeting=needs_matrix_puppeting, needs_admin=needs_admin)
needs_puppeting: Flag indicating if the sender is required to use
Telegram puppeteering for this command.
needs_matrix_puppeting: Flag indicating if the sender is required to
use Matrix pupeteering.
needs_admin: Flag for whether only admin users can issue this command.
management_only: Whether the command can exclusively be issued
in a management room.
name: The name of this command.
help_text: The text displayed in the help for this command.
help_args: Help text for the arguments of this command.
help_section: Section of the help in which this command will appear.
"""
self._handler = handler
self.needs_auth = needs_auth
self.needs_puppeting = needs_puppeting
self.needs_matrix_puppeting = needs_matrix_puppeting
self.needs_admin = needs_admin
self.management_only = management_only
self.name = name
self._help_text = help_text
self._help_args = help_args
self.help_section = help_section
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]: async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
"""Returns the reason why the command could not be issued.
Args:
evt: The event for which to get the error information.
Returns:
A string describing the error or None if there was no error.
"""
if self.management_only and not evt.is_management: if self.management_only and not evt.is_management:
return (f"`{evt.command}` is a restricted command: " return (f"`{evt.command}` is a restricted command: "
"you may only run it in management rooms.") "you may only run it in management rooms.")
@@ -232,134 +87,40 @@ class CommandHandler:
return "This command requires you to be logged in." return "This command requires you to be logged in."
return None return None
def has_permission(self, is_management: bool, puppet_whitelisted: bool, def has_permission(self, key: HelpCacheKey) -> bool:
matrix_puppet_whitelisted: bool, is_admin: bool, is_logged_in: bool) -> bool: return ((not self.management_only or key.is_management) and
"""Checks the permission for this command with the given status. (not self.needs_puppeting or key.puppet_whitelisted) and
(not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted) and
Args: (not self.needs_admin or key.is_admin) and
is_management: If the room in which the command will be issued is a (not self.needs_auth or key.is_logged_in))
management room.
puppet_whitelisted: If the connected Telegram account puppet is
allowed to issue the command.
matrix_puppet_whitelisted: If the connected Matrix account puppet is
allowed to issue the command.
is_admin: If the issuing user is an admin.
is_logged_in: If the issuing user is logged in.
Returns:
True if a user with the given state is allowed to issue the
command.
"""
return ((not self.management_only or is_management) and
(not self.needs_puppeting or puppet_whitelisted) and
(not self.needs_matrix_puppeting or matrix_puppet_whitelisted) and
(not self.needs_admin or is_admin) and
(not self.needs_auth or is_logged_in))
async def __call__(self, evt: CommandEvent) -> 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 command_handler(_func: Optional[Callable[[CommandEvent], Awaitable[Dict]]] = None, *, def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: bool = True,
needs_auth: bool = True, needs_puppeting: bool = True, needs_puppeting: bool = True, needs_matrix_puppeting: bool = False,
needs_matrix_puppeting: bool = False, needs_admin: bool = False, needs_admin: bool = False, management_only: bool = False,
management_only: bool = False, name: Optional[str] = None, name: Optional[str] = None, help_text: str = "", help_args: str = "",
help_text: str = "", help_args: str = "", help_section: HelpSection = None help_section: HelpSection = None) -> Callable[[CommandHandlerFunc],
) -> Callable[[Callable[[CommandEvent], Awaitable[Optional[Dict]]]], CommandHandler]:
CommandHandler]: return base_command_handler(
def decorator(func: Callable[[CommandEvent], Awaitable[Optional[Dict]]]) -> CommandHandler: _func, _handler_class=CommandHandler, name=name, help_text=help_text, help_args=help_args,
actual_name = name or func.__name__.replace("_", "-") help_section=help_section, management_only=management_only, needs_auth=needs_auth,
handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting, needs_admin=needs_admin, needs_puppeting=needs_puppeting,
needs_admin, management_only, actual_name, help_text, help_args, needs_matrix_puppeting=needs_matrix_puppeting)
help_section)
command_handlers[handler.name] = handler
return handler
return decorator if _func is None else decorator(_func)
class CommandProcessor: class CommandProcessor(BaseCommandProcessor):
"""Handles the raw commands issued by a user to the Matrix bot."""
log = logging.getLogger("mau.commands")
def __init__(self, context: c.Context) -> None: def __init__(self, context: c.Context) -> None:
super().__init__(az=context.az, config=context.config, event_class=CommandEvent,
loop=context.loop)
self.tgbot = context.bot
self.az, self.config, self.loop, self.tgbot = context.core self.az, self.config, self.loop, self.tgbot = context.core
self.public_website = context.public_website self.public_website = context.public_website
self.command_prefix = self.config["bridge.command_prefix"] self.command_prefix = self.config["bridge.command_prefix"]
async def handle(self, room: MatrixRoomID, event_id: MatrixEventID, sender: u.User, @staticmethod
command: str, args: List[str], is_management: bool, is_portal: bool async def _run_handler(handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent
) -> Optional[Dict]: ) -> Any:
"""Handles the raw commands issued by a user to the Matrix bot.
If the command is not known, it might be a followup command and is
delegated to a command handler registered for that purpose in the
senders command_status as "next".
Args:
room: ID of the Matrix room in which the command was issued.
event_id: ID of the event by which the command was issued.
sender: The sender who issued the command.
command: The issued command, case insensitive.
args: Arguments given with the command.
is_management: Whether the room is a management room.
is_portal: Whether the room is a portal.
Returns:
The result of the error message function or None if no error
occured. Unknown and delegated commands do not count as errors.
"""
if not command_handlers or "unknown-command" not in command_handlers:
raise ValueError("command_handlers are not properly initialized.")
evt = CommandEvent(self, room, event_id, sender, command, args, is_management, is_portal)
orig_command = command
command = command.lower()
try: try:
handler = command_handlers[command] return await handler(evt)
except KeyError:
if sender.command_status and "next" in sender.command_status:
args.insert(0, orig_command)
evt.command = ""
handler = sender.command_status["next"]
else:
handler = command_handlers["unknown-command"]
try:
await handler(evt)
except FloodWaitError as e: except FloodWaitError as e:
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}") return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
except Exception:
self.log.exception("Unhandled error while handling command "
f"{evt.command} {' '.join(args)} from {sender.mxid}")
if evt.sender.is_admin and evt.is_management:
return await evt.reply("Unhandled error while handling command:\n\n"
"```traceback\n"
f"{traceback.format_exc()}"
"```")
return await evt.reply("Unhandled error while handling command. "
"Check logs for more details.")
return None
+33 -21
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,17 +13,17 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional from mautrix.types import EventID
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
from . import command_handler, CommandEvent, SECTION_AUTH from . import command_handler, CommandEvent, SECTION_AUTH
from .. import puppet as pu from .. import puppet as pu
@command_handler(needs_auth=True, needs_matrix_puppeting=True, @command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH, help_section=SECTION_AUTH, help_text="Revert your Telegram account's Matrix "
help_text="Revert your Telegram account's Matrix puppet to use the default Matrix " "puppet to use the default Matrix account.")
"account.") async def logout_matrix(evt: CommandEvent) -> EventID:
async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid) puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user: if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.") return await evt.reply("You are not logged in with your Matrix account.")
@@ -36,7 +35,7 @@ async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Replace your Telegram account's Matrix puppet with your own Matrix " help_text="Replace your Telegram account's Matrix puppet with your own Matrix "
"account.") "account.")
async def login_matrix(evt: CommandEvent) -> Optional[Dict]: async def login_matrix(evt: CommandEvent) -> EventID:
puppet = pu.Puppet.get(evt.sender.tgid) puppet = pu.Puppet.get(evt.sender.tgid)
if puppet.is_real_user: if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. " return await evt.reply("You have already logged in with your Matrix account. "
@@ -71,31 +70,44 @@ async def login_matrix(evt: CommandEvent) -> Optional[Dict]:
@command_handler(needs_auth=True, needs_matrix_puppeting=True, @command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Pings the server with the stored matrix authentication.") help_text="Pings the server with the stored matrix authentication.")
async def ping_matrix(evt: CommandEvent) -> Optional[Dict]: async def ping_matrix(evt: CommandEvent) -> EventID:
puppet = pu.Puppet.get(evt.sender.tgid) puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user: if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.") return await evt.reply("You are not logged in with your Matrix account.")
resp = await puppet.init_custom_mxid() try:
if resp == pu.PuppetError.InvalidAccessToken: await puppet.start()
except InvalidAccessToken:
return await evt.reply("Your access token is invalid.") return await evt.reply("Your access token is invalid.")
elif resp == pu.PuppetError.Success: return await evt.reply("Your Matrix login is working.")
return await evt.reply("Your Matrix login is working.")
return await evt.reply(f"Unknown response while checking your Matrix login: {resp}.")
async def enter_matrix_token(evt: CommandEvent) -> Dict: @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 evt.sender.command_status = None
puppet = pu.Puppet.get(evt.sender.tgid) puppet = pu.Puppet.get(evt.sender.tgid)
if puppet.is_real_user: if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. " return await evt.reply("You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first.") "Log out with `$cmdprefix+sp logout-matrix` first.")
try:
resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
if resp == pu.PuppetError.OnlyLoginSelf: except OnlyLoginSelf:
return await evt.reply("You can only log in as your own Matrix user.") return await evt.reply("You can only log in as your own Matrix user.")
elif resp == pu.PuppetError.InvalidAccessToken: except InvalidAccessToken:
return await evt.reply("Failed to verify access token.") return await evt.reply("Failed to verify access token.")
assert resp == pu.PuppetError.Success, "Encountered an unhandled PuppetError." return await evt.reply("Replaced your Telegram account's Matrix puppet "
return await evt.reply( f"with {puppet.custom_mxid}.")
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.")
-72
View File
@@ -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 <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple
from . import command_handler, CommandEvent, _command_handlers, SECTION_GENERAL
from .handler import HelpSection
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Cancel an ongoing action (such as login)")
async def cancel(evt: CommandEvent) -> Optional[Dict]:
if evt.sender.command_status:
action = evt.sender.command_status["action"]
evt.sender.command_status = None
return await evt.reply(f"{action} cancelled.")
else:
return await evt.reply("No ongoing command.")
@command_handler(needs_auth=False, needs_puppeting=False)
async def unknown_command(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
help_cache = {} # type: Dict[Tuple[bool, bool, bool, bool, bool], str]
async def _get_help_text(evt: CommandEvent) -> str:
cache_key = (evt.is_management, evt.sender.puppet_whitelisted,
evt.sender.matrix_puppet_whitelisted, evt.sender.is_admin,
await evt.sender.is_logged_in())
if cache_key not in help_cache:
help_sections = {} # type: Dict[HelpSection, List[str]]
for handler in _command_handlers.values():
if handler.has_help and handler.has_permission(*cache_key):
help_sections.setdefault(handler.help_section, [])
help_sections[handler.help_section].append(handler.help + " ")
help_sorted = sorted(help_sections.items(), key=lambda item: item[0].order)
helps = ["#### {}\n{}\n".format(key.name, "\n".join(value)) for key, value in help_sorted]
help_cache[cache_key] = "\n".join(helps)
return help_cache[cache_key]
def _get_management_status(evt: CommandEvent) -> str:
if evt.is_management:
return "This is a management room: prefixing commands with `$cmdprefix` is not required."
elif evt.is_portal:
return ("**This is a portal room**: you must always prefix commands with `$cmdprefix`.\n"
"Management commands will not be sent to Telegram.")
return "**This is not a management room**: you must prefix commands with `$cmdprefix`."
@command_handler(name="help", needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Show this help message.")
async def help_cmd(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply(_get_management_status(evt) + "\n" + await _get_help_text(evt))
+11 -14
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,10 +13,10 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
import asyncio import asyncio
from mautrix_appservice import MatrixRequestError from mautrix.errors import MatrixRequestError
from mautrix.types import EventID
from ... import portal as po, puppet as pu, user as u from ... import portal as po, puppet as pu, user as u
from .. import command_handler, CommandEvent, SECTION_ADMIN from .. import command_handler, CommandEvent, SECTION_ADMIN
@@ -27,7 +26,7 @@ from .. import command_handler, CommandEvent, SECTION_ADMIN
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_args="<_level_> [_mxid_]", help_args="<_level_> [_mxid_]",
help_text="Set a temporary power level without affecting Telegram.") help_text="Set a temporary power level without affecting Telegram.")
async def set_power_level(evt: CommandEvent) -> Dict: async def set_power_level(evt: CommandEvent) -> EventID:
try: try:
level = int(evt.args[0]) level = int(evt.args[0])
except KeyError: except KeyError:
@@ -36,20 +35,19 @@ async def set_power_level(evt: CommandEvent) -> Dict:
return await evt.reply("The level must be an integer.") return await evt.reply("The level must be an integer.")
levels = await evt.az.intent.get_power_levels(evt.room_id) levels = await evt.az.intent.get_power_levels(evt.room_id)
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
levels["users"][mxid] = level levels.users[mxid] = level
try: try:
await evt.az.intent.set_power_levels(evt.room_id, levels) return await evt.az.intent.set_power_levels(evt.room_id, levels)
except MatrixRequestError: except MatrixRequestError:
evt.log.exception("Failed to set power level.") evt.log.exception("Failed to set power level.")
return await evt.reply("Failed to set power level.") return await evt.reply("Failed to set power level.")
return {}
@command_handler(needs_admin=True, needs_auth=False, @command_handler(needs_admin=True, needs_auth=False,
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_args="<`portal`|`puppet`|`user`>", help_args="<`portal`|`puppet`|`user`>",
help_text="Clear internal bridge caches") help_text="Clear internal bridge caches")
async def clear_db_cache(evt: CommandEvent) -> Dict: async def clear_db_cache(evt: CommandEvent) -> EventID:
try: try:
section = evt.args[0].lower() section = evt.args[0].lower()
except IndexError: except IndexError:
@@ -63,9 +61,8 @@ async def clear_db_cache(evt: CommandEvent) -> Dict:
for puppet in pu.Puppet.by_custom_mxid.values(): for puppet in pu.Puppet.by_custom_mxid.values():
puppet.sync_task.cancel() puppet.sync_task.cancel()
pu.Puppet.by_custom_mxid = {} pu.Puppet.by_custom_mxid = {}
await asyncio.gather( await asyncio.gather(*[puppet.start() for puppet in pu.Puppet.all_with_custom_mxid()],
*[puppet.init_custom_mxid() for puppet in pu.Puppet.all_with_custom_mxid()], loop=evt.loop)
loop=evt.loop)
await evt.reply("Cleared puppet cache and restarted custom puppet syncers") await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
elif section == "user": elif section == "user":
u.User.by_mxid = { u.User.by_mxid = {
@@ -81,7 +78,7 @@ async def clear_db_cache(evt: CommandEvent) -> Dict:
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_args="[_mxid_]", help_args="[_mxid_]",
help_text="Reload and reconnect a user") help_text="Reload and reconnect a user")
async def reload_user(evt: CommandEvent) -> Dict: async def reload_user(evt: CommandEvent) -> EventID:
if len(evt.args) > 0: if len(evt.args) > 0:
mxid = evt.args[0] mxid = evt.args[0]
else: else:
@@ -97,5 +94,5 @@ async def reload_user(evt: CommandEvent) -> Dict:
user = u.User.get_by_mxid(mxid) user = u.User.get_by_mxid(mxid)
await user.ensure_started() await user.ensure_started()
if puppet: if puppet:
await puppet.init_custom_mxid() await puppet.start()
await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})") return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
+13 -13
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,13 +13,14 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional, Tuple, Coroutine from typing import Optional, Tuple, Coroutine
import asyncio import asyncio
from telethon.tl.types import ChatForbidden, ChannelForbidden from telethon.tl.types import ChatForbidden, ChannelForbidden
from ...types import MatrixRoomID, TelegramID from mautrix.types import EventID, RoomID
from ...util import ignore_coro
from ...types import TelegramID
from ... import portal as po from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state from .util import user_has_power_level, get_initial_state
@@ -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 " help_text="Bridge the current Matrix room to the Telegram chat with the given "
"ID. The ID must be the prefixed version that you get with the `/id` " "ID. The ID must be the prefixed version that you get with the `/id` "
"command of the Telegram-side bot.") "command of the Telegram-side bot.")
async def bridge(evt: CommandEvent) -> Dict: async def bridge(evt: CommandEvent) -> EventID:
if len(evt.args) == 0: if len(evt.args) == 0:
return await evt.reply("**Usage:** " return await evt.reply("**Usage:** "
"`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`") "`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`")
@@ -40,7 +40,7 @@ async def bridge(evt: CommandEvent) -> Dict:
if evt.args[0] == "--usebot" and evt.sender.is_admin: if evt.args[0] == "--usebot" and evt.sender.is_admin:
force_use_bot = True force_use_bot = True
evt.args = evt.args[1:] evt.args = evt.args[1:]
room_id = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
that_this = "This" if room_id == evt.room_id else "That" that_this = "This" if room_id == evt.room_id else "That"
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
@@ -65,7 +65,7 @@ async def bridge(evt: CommandEvent) -> Dict:
"Bridging private chats to existing rooms is not allowed.") "Bridging private chats to existing rooms is not allowed.")
portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type) 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" return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n"
"If you're the bridge admin, try " "If you're the bridge admin, try "
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first.") "`$cmdprefix+sp filter whitelist <Telegram chat ID>` first.")
@@ -105,7 +105,8 @@ async def bridge(evt: CommandEvent) -> Dict:
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal" async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
) -> Tuple[bool, Optional[Coroutine[None, None, None]]]: ) -> Tuple[
bool, Optional[Coroutine[None, None, None]]]:
if not portal.mxid: if not portal.mxid:
await evt.reply("The portal seems to have lost its Matrix room between you" await evt.reply("The portal seems to have lost its Matrix room between you"
"calling `$cmdprefix+sp bridge` and this command.\n\n" "calling `$cmdprefix+sp bridge` and this command.\n\n"
@@ -128,7 +129,7 @@ async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Porta
return False, None return False, None
async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]: async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
status = evt.sender.command_status status = evt.sender.command_status
try: try:
portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"]) portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
@@ -143,7 +144,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
if not ok: if not ok:
return None return None
elif coro: elif coro:
ignore_coro(asyncio.ensure_future(coro, loop=evt.loop)) asyncio.ensure_future(coro, loop=evt.loop)
await evt.reply("Cleaning up previous portal room...") await evt.reply("Cleaning up previous portal room...")
elif portal.mxid: elif portal.mxid:
evt.sender.command_status = None evt.sender.command_status = None
@@ -180,8 +181,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
portal.photo_id = "" portal.photo_id = ""
portal.save() portal.save()
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
levels=levels), loop=evt.loop)
loop=evt.loop))
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
+10 -9
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,10 +13,12 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Awaitable from typing import Awaitable
from io import StringIO from io import StringIO
from ...config import yaml from mautrix.util.config import yaml
from mautrix.types import EventID
from ... import portal as po, util from ... import portal as po, util
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
@@ -55,7 +56,7 @@ async def config(evt: CommandEvent) -> None:
portal.save() portal.save()
def config_help(evt: CommandEvent) -> Awaitable[Dict]: def config_help(evt: CommandEvent) -> Awaitable[EventID]:
return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands: return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
* **help** - View this help text. * **help** - View this help text.
@@ -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() stream = StringIO()
yaml.dump(portal.local_config, stream) yaml.dump(portal.local_config, stream)
return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```") return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```")
def config_defaults(evt: CommandEvent) -> Awaitable[Dict]: def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
stream = StringIO() stream = StringIO()
yaml.dump({ yaml.dump({
"bridge_notices": { "bridge_notices": {
@@ -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()}```") return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```")
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]: def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[EventID]:
if not key or value is None: if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`") return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
elif util.recursive_set(portal.local_config, key, value): elif util.recursive_set(portal.local_config, key, value):
@@ -100,7 +101,7 @@ def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Aw
"Does the path contain non-map types?") "Does the path contain non-map types?")
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Dict]: def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[EventID]:
if not key: if not key:
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`") return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
elif util.recursive_del(portal.local_config, key): elif util.recursive_del(portal.local_config, key):
@@ -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 def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
) -> Awaitable[Dict]: ) -> Awaitable[EventID]:
if not key or value is None: if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`") return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict from mautrix.types import EventID
from ... import portal as po from ... import portal as po
from ...types import TelegramID from ...types import TelegramID
@@ -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. " help_text="Create a Telegram chat of the given type for the current Matrix room. "
"The type is either `group`, `supergroup` or `channel` (defaults to " "The type is either `group`, `supergroup` or `channel` (defaults to "
"`group`).") "`group`).")
async def create(evt: CommandEvent) -> Dict: async def create(evt: CommandEvent) -> EventID:
type = evt.args[0] if len(evt.args) > 0 else "group" type = evt.args[0] if len(evt.args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}: if type not in {"chat", "group", "supergroup", "channel"}:
return await evt.reply( return await evt.reply(
+5 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional from mautrix.types import EventID
from ... import portal as po from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_ADMIN from .. import command_handler, CommandEvent, SECTION_ADMIN
@@ -25,7 +24,7 @@ from .. import command_handler, CommandEvent, SECTION_ADMIN
help_args="<`whitelist`|`blacklist`>", help_args="<`whitelist`|`blacklist`>",
help_text="Change whether the bridge will allow or disallow bridging rooms by " help_text="Change whether the bridge will allow or disallow bridging rooms by "
"default.") "default.")
async def filter_mode(evt: CommandEvent) -> Dict: async def filter_mode(evt: CommandEvent) -> EventID:
try: try:
mode = evt.args[0] mode = evt.args[0]
if mode not in ("whitelist", "blacklist"): if mode not in ("whitelist", "blacklist"):
@@ -50,7 +49,7 @@ async def filter_mode(evt: CommandEvent) -> Dict:
help_section=SECTION_ADMIN, help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`> <_chat ID_>", help_args="<`whitelist`|`blacklist`> <_chat ID_>",
help_text="Allow or disallow bridging a specific chat.") help_text="Allow or disallow bridging a specific chat.")
async def edit_filter(evt: CommandEvent) -> Optional[Dict]: async def edit_filter(evt: CommandEvent) -> EventID:
try: try:
action = evt.args[0] action = evt.args[0]
if action not in ("whitelist", "blacklist", "add", "remove"): if action not in ("whitelist", "blacklist", "add", "remove"):
@@ -92,4 +91,5 @@ async def edit_filter(evt: CommandEvent) -> Optional[Dict]:
filter_id_list.remove(filter_id) filter_id_list.remove(filter_id)
save() save()
return await evt.reply(f"Chat ID removed from {mode}.") return await evt.reply(f"Chat ID removed from {mode}.")
return None else:
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
+28 -8
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,11 +13,11 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError, from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
UsernameNotModifiedError, UsernameOccupiedError) UsernameNotModifiedError, UsernameOccupiedError)
from mautrix.types import EventID
from ... import portal as po from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC
from .util import user_has_power_level from .util import user_has_power_level
@@ -27,7 +26,7 @@ from .util import user_has_power_level
@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False, @command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC, help_section=SECTION_MISC,
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.") help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.")
async def sync_state(evt: CommandEvent) -> Dict: async def sync_state(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id) portal = po.Portal.get_by_mxid(evt.room_id)
if not portal: if not portal:
return await evt.reply("This is not a portal room.") return await evt.reply("This is not a portal room.")
@@ -38,10 +37,31 @@ async def sync_state(evt: CommandEvent) -> Dict:
await evt.reply("Synchronization complete") 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, @command_handler(name="id", needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC, help_section=SECTION_MISC,
help_text="Get the ID of the Telegram chat where this room is bridged.") help_text="Get the ID of the Telegram chat where this room is bridged.")
async def get_id(evt: CommandEvent) -> Dict: async def get_id(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id) portal = po.Portal.get_by_mxid(evt.room_id)
if not portal: if not portal:
return await evt.reply("This is not a portal room.") return await evt.reply("This is not a portal room.")
@@ -55,7 +75,7 @@ async def get_id(evt: CommandEvent) -> Dict:
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, @command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Get a Telegram invite link to the current chat.") help_text="Get a Telegram invite link to the current chat.")
async def invite_link(evt: CommandEvent) -> Dict: async def invite_link(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id) portal = po.Portal.get_by_mxid(evt.room_id)
if not portal: if not portal:
return await evt.reply("This is not a portal room.") return await evt.reply("This is not a portal room.")
@@ -74,7 +94,7 @@ async def invite_link(evt: CommandEvent) -> Dict:
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, @command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.") help_text="Upgrade a normal Telegram group to a supergroup.")
async def upgrade(evt: CommandEvent) -> Dict: async def upgrade(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id) portal = po.Portal.get_by_mxid(evt.room_id)
if not portal: if not portal:
return await evt.reply("This is not a portal room.") return await evt.reply("This is not a portal room.")
@@ -96,7 +116,7 @@ async def upgrade(evt: CommandEvent) -> Dict:
help_args="<_name_|`-`>", help_args="<_name_|`-`>",
help_text="Change the username of a supergroup/channel. " help_text="Change the username of a supergroup/channel. "
"To disable, use a dash (`-`) as the name.") "To disable, use a dash (`-`) as the name.")
async def group_name(evt: CommandEvent) -> Dict: async def group_name(evt: CommandEvent) -> EventID:
if len(evt.args) == 0: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`") return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
+6 -6
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -16,7 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Callable, Optional from typing import Dict, Callable, Optional
from ...types import MatrixRoomID from mautrix.types import RoomID, EventID
from ... import portal as po from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
from .util import user_has_power_level from .util import user_has_power_level
@@ -25,7 +25,7 @@ from .util import user_has_power_level
async def _get_portal_and_check_permission(evt: CommandEvent, permission: str, async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
action: Optional[str] = None action: Optional[str] = None
) -> Optional[po.Portal]: ) -> Optional[po.Portal]:
room_id = MatrixRoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
if not portal: if not portal:
@@ -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, def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str,
completed_message: str) -> Dict: completed_message: str) -> Dict:
async def post_confirm(confirm) -> Optional[Dict]: async def post_confirm(confirm) -> Optional[EventID]:
confirm.sender.command_status = None confirm.sender.command_status = None
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}": if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
await function() await function()
@@ -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. " help_text="Remove all users from the current portal room and forget the portal. "
"Only works for group chats; to delete a private chat portal, simply " "Only works for group chats; to delete a private chat portal, simply "
"leave the room.") "leave the room.")
async def delete_portal(evt: CommandEvent) -> Optional[Dict]: async def delete_portal(evt: CommandEvent) -> Optional[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge") portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal: if not portal:
return None return None
@@ -84,7 +84,7 @@ async def delete_portal(evt: CommandEvent) -> Optional[Dict]:
@command_handler(needs_auth=False, needs_puppeting=False, @command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT, help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove puppets from the current portal room and forget the portal.") help_text="Remove puppets from the current portal room and forget the portal.")
async def unbridge(evt: CommandEvent) -> Optional[Dict]: async def unbridge(evt: CommandEvent) -> Optional[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge") portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal: if not portal:
return None return None
+26 -22
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,43 +13,48 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Tuple from typing import Tuple, Optional
from mautrix_appservice import MatrixRequestError, IntentAPI from mautrix.errors import MatrixRequestError
from mautrix.appservice import IntentAPI
from mautrix.types import RoomID, EventType, PowerLevelStateEventContent
from ... import user as u from ... import user as u
OptStr = Optional[str]
async def get_initial_state(intent: IntentAPI, room_id: str) -> Tuple[str, str, Dict]:
state = await intent.get_room_state(room_id) async def get_initial_state(intent: IntentAPI, room_id: RoomID
title = None ) -> Tuple[OptStr, OptStr, Optional[PowerLevelStateEventContent]]:
about = None state = await intent.get_state(room_id)
levels = None title: OptStr = None
about: OptStr = None
levels: Optional[PowerLevelStateEventContent] = None
for event in state: for event in state:
try: try:
if event["type"] == "m.room.name": if event.type == EventType.ROOM_NAME:
title = event["content"]["name"] title = event.content.name
elif event["type"] == "m.room.topic": elif event.type == EventType.ROOM_TOPIC:
about = event["content"]["topic"] about = event.content.topic
elif event["type"] == "m.room.power_levels": elif event.type == EventType.ROOM_POWER_LEVELS:
levels = event["content"] levels = event.content
elif event["type"] == "m.room.canonical_alias": elif event.type == EventType.ROOM_CANONICAL_ALIAS:
title = title or event["content"]["alias"] title = title or event.content.canonical_alias
except KeyError: except KeyError:
# Some state event probably has empty content # Some state event probably has empty content
pass pass
return title, about, levels return title, about, levels
async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50 async def user_has_power_level(room_id: RoomID, intent: IntentAPI, sender: u.User,
) -> bool: event: str) -> bool:
if sender.is_admin: if sender.is_admin:
return True return True
# Make sure the state store contains the power levels. # Make sure the state store contains the power levels.
try: try:
await intent.get_power_levels(room) await intent.get_power_levels(room_id)
except MatrixRequestError: except MatrixRequestError:
return False return False
return intent.state_store.has_power_level(room, sender.mxid, event_type = EventType.find(f"net.maunium.telegram.{event}")
event=f"net.maunium.telegram.{event}", event_type.t_class = EventType.Class.STATE
default=default) return intent.state_store.has_power_level(room_id, sender.mxid, event_type)
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional from typing import Optional
from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError, from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError,
HashInvalidError, AuthKeyError, FirstNameInvalidError) HashInvalidError, AuthKeyError, FirstNameInvalidError)
@@ -22,6 +21,8 @@ from telethon.tl.types import Authorization
from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest, from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest,
ResetAuthorizationRequest, UpdateProfileRequest) ResetAuthorizationRequest, UpdateProfileRequest)
from mautrix.types import EventID
from .. import command_handler, CommandEvent, SECTION_AUTH from .. import command_handler, CommandEvent, SECTION_AUTH
@@ -29,7 +30,7 @@ from .. import command_handler, CommandEvent, SECTION_AUTH
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_args="<_new username_>", help_args="<_new username_>",
help_text="Change your Telegram 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: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`") return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`")
if evt.sender.is_bot: 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_>", @command_handler(needs_auth=True, help_section=SECTION_AUTH, help_args="<_new displayname_>",
help_text="Change your Telegram 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: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp displayname <new displayname>`") return await evt.reply("**Usage:** `$cmdprefix+sp displayname <new displayname>`")
if evt.sender.is_bot: if evt.sender.is_bot:
@@ -69,7 +70,7 @@ async def displayname(evt: CommandEvent) -> Optional[Dict]:
except FirstNameInvalidError: except FirstNameInvalidError:
return await evt.reply("Invalid first name") return await evt.reply("Invalid first name")
await evt.sender.update_info() await evt.sender.update_info()
await evt.reply("Displayname updated") return await evt.reply("Displayname updated")
def _format_session(sess: Authorization) -> str: def _format_session(sess: Authorization) -> str:
@@ -83,7 +84,7 @@ def _format_session(sess: Authorization) -> str:
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_args="<`list`|`terminate`> [_hash_]", help_args="<`list`|`terminate`> [_hash_]",
help_text="View or delete other Telegram sessions.") 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: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`") return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
elif evt.sender.is_bot: elif evt.sender.is_bot:
+18 -17
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -17,21 +16,23 @@
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import asyncio import asyncio
from telethon.errors import ( from telethon.errors import ( # isort: skip
AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError, AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError,
PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError, PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError,
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError, PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError) 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 ...commands import command_handler, CommandEvent, SECTION_AUTH
from ...util import format_duration, ignore_coro from ...util import format_duration
@command_handler(needs_auth=False, @command_handler(needs_auth=False,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Check if you're logged into Telegram.") 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 me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None
if me: if me:
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}" 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, @command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Get the info of the message relay Telegram bot.") 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: if not evt.tgbot:
return await evt.reply("Telegram message relay bot not configured.") return await evt.reply("Telegram message relay bot not configured.")
info, mxid = await evt.tgbot.get_me(use_cache=False) 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_section=SECTION_AUTH,
help_args="<_phone_> <_full name_>", help_args="<_phone_> <_full name_>",
help_text="Register to Telegram") 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(): if await evt.sender.is_logged_in():
return await evt.reply("You are already logged in.") return await evt.reply("You are already logged in.")
elif len(evt.args) < 1: elif len(evt.args) < 1:
@@ -76,14 +77,14 @@ async def register(evt: CommandEvent) -> Optional[Dict]:
return None return None
async def enter_code_register(evt: CommandEvent) -> Dict: async def enter_code_register(evt: CommandEvent) -> EventID:
if len(evt.args) == 0: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`") return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
try: try:
await evt.sender.ensure_started(even_if_no_session=True) await evt.sender.ensure_started(even_if_no_session=True)
first_name, last_name = evt.sender.command_status["full_name"] first_name, last_name = evt.sender.command_status["full_name"]
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_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 evt.sender.command_status = None
return await evt.reply(f"Successfully registered to Telegram.") return await evt.reply(f"Successfully registered to Telegram.")
except PhoneNumberOccupiedError: except PhoneNumberOccupiedError:
@@ -105,7 +106,7 @@ async def enter_code_register(evt: CommandEvent) -> Dict:
@command_handler(needs_auth=False, management_only=True, @command_handler(needs_auth=False, management_only=True,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Get instructions on how to log in.") 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 override_sender = False
if len(evt.args) > 0 and evt.sender.is_admin: if len(evt.args) > 0 and evt.sender.is_admin:
evt.sender = await u.User.get_by_mxid(evt.args[0]).ensure_started() 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] async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, Any]
) -> Dict: ) -> EventID:
ok = False ok = False
try: try:
await evt.sender.ensure_started(even_if_no_session=True) 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) @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: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`") return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
elif not evt.config.get("bridge.allow_matrix_login", True): 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) @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: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`") return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
elif not evt.config.get("bridge.allow_matrix_login", True): 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) @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: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`") return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
elif not evt.config.get("bridge.allow_matrix_login", True): elif not evt.config.get("bridge.allow_matrix_login", True):
@@ -233,7 +234,7 @@ async def enter_password(evt: CommandEvent) -> Optional[Dict]:
return None return None
async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict: async def _sign_in(evt: CommandEvent, **sign_in_info) -> EventID:
try: try:
await evt.sender.ensure_started(even_if_no_session=True) await evt.sender.ensure_started(even_if_no_session=True)
user = await evt.sender.client.sign_in(**sign_in_info) 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}]" await evt.reply(f"[{existing_user.displayname}]"
f"(https://matrix.to/#/{existing_user.mxid})" f"(https://matrix.to/#/{existing_user.mxid})"
" was logged out from the account.") " 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 evt.sender.command_status = None
name = f"@{user.username}" if user.username else f"+{user.phone}" name = f"@{user.username}" if user.username else f"+{user.phone}"
return await evt.reply(f"Successfully logged in as {name}") 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, @command_handler(needs_auth=True,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Log out from Telegram.") 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(): if await evt.sender.log_out():
return await evt.reply("Logged out successfully.") return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.") return await evt.reply("Failed to log out.")
+19 -13
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,13 +13,14 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple from typing import List, Optional, Tuple
import logging
import codecs import codecs
import base64 import base64
import re import re
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError, from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
UserAlreadyParticipantError) UserAlreadyParticipantError, ChatIdInvalidError)
from telethon.tl.patched import Message from telethon.tl.patched import Message
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll, from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
TypePeer) TypePeer)
@@ -29,6 +29,8 @@ from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatIn
GetBotCallbackAnswerRequest, SendVoteRequest) GetBotCallbackAnswerRequest, SendVoteRequest)
from telethon.tl.functions.channels import JoinChannelRequest from telethon.tl.functions.channels import JoinChannelRequest
from mautrix.types import EventID
from ... import puppet as pu, portal as po from ... import puppet as pu, portal as po
from ...abstract_user import AbstractUser from ...abstract_user import AbstractUser
from ...db import Message as DBMessage 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, @command_handler(help_section=SECTION_MISC,
help_args="[_-r|--remote_] <_query_>", help_args="[_-r|--remote_] <_query_>",
help_text="Search your contacts or the Telegram servers for users.") 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: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`") return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
@@ -60,7 +62,7 @@ async def search(evt: CommandEvent) -> Optional[Dict]:
"Minimum length of remote query is 5 characters.") "Minimum length of remote query is 5 characters.")
return await evt.reply("No results 3:") return await evt.reply("No results 3:")
reply = [] # type: List[str] reply: List[str] = []
if remote: if remote:
reply += ["**Results from Telegram server:**", ""] reply += ["**Results from Telegram server:**", ""]
else: else:
@@ -80,7 +82,7 @@ async def search(evt: CommandEvent) -> Optional[Dict]:
"either the internal user ID, the username or the phone number. " "either the internal user ID, the username or the phone number. "
"**N.B.** The phone numbers you start chats with must already be in " "**N.B.** The phone numbers you start chats with must already be in "
"your contacts.") "your contacts.")
async def pm(evt: CommandEvent) -> Optional[Dict]: async def pm(evt: CommandEvent) -> EventID:
if len(evt.args) == 0: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`") return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
@@ -99,7 +101,7 @@ async def pm(evt: CommandEvent) -> Optional[Dict]:
f"{pu.Puppet.get_displayname(user, False)}") 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/"): if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):] invite_hash = arg[len("joinchat/"):]
try: try:
@@ -122,7 +124,7 @@ async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Opt
@command_handler(help_section=SECTION_CREATING_PORTALS, @command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="<_link_>", help_args="<_link_>",
help_text="Join a chat with an invite 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: if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`") return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
@@ -142,7 +144,11 @@ async def join(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply(f"Invited you to portal of {portal.title}") return await evt.reply(f"Invited you to portal of {portal.title}")
else: else:
await evt.reply(f"Creating room for {chat.title}... This might take a while.") 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 await evt.reply(f"Created room for {portal.title}")
return None return None
@@ -150,7 +156,7 @@ async def join(evt: CommandEvent) -> Optional[Dict]:
@command_handler(help_section=SECTION_MISC, @command_handler(help_section=SECTION_MISC,
help_args="[`chats`|`contacts`|`me`]", help_args="[`chats`|`contacts`|`me`]",
help_text="Synchronize your chat portals, contacts and/or own info.") 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: if len(evt.args) > 0:
sync_only = evt.args[0] sync_only = evt.args[0]
if sync_only not in ("chats", "contacts", "me"): 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, @command_handler(help_section=SECTION_MISC,
help_args="<_play ID_>", help_args="<_play ID_>",
help_text="Play a Telegram game.") help_text="Play a Telegram game.")
async def play(evt: CommandEvent) -> Optional[Dict]: async def play(evt: CommandEvent) -> EventID:
if len(evt.args) < 1: if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp play <play ID>`") return await evt.reply("**Usage:** `$cmdprefix+sp play <play ID>`")
elif not await evt.sender.is_logged_in(): elif not await evt.sender.is_logged_in():
@@ -232,14 +238,14 @@ async def play(evt: CommandEvent) -> Optional[Dict]:
if not isinstance(game, BotCallbackAnswer): if not isinstance(game, BotCallbackAnswer):
return await evt.reply("Game request response invalid") 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}") f"{msg.media.game.description}")
@command_handler(help_section=SECTION_MISC, @command_handler(help_section=SECTION_MISC,
help_args="<_poll ID_> <_choice number_>", help_args="<_poll ID_> <_choice number_>",
help_text="Vote in a Telegram poll.") 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: if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice number>`") return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice number>`")
elif not await evt.sender.is_logged_in(): elif not await evt.sender.is_logged_in():
+35 -171
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,149 +13,27 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, List, NamedTuple
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap from ruamel.yaml.comments import CommentedMap
import random import os
import string
yaml = YAML() # type: YAML from mautrix.types import UserID
yaml.indent(4) 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: class Config(BaseBridgeConfig):
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]
def __getitem__(self, key: str) -> Any: def __getitem__(self, key: str) -> Any:
try: try:
return self._overrides[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"] return os.environ[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
except KeyError: except KeyError:
return super().__getitem__(key) return super().__getitem__(key)
def load(self) -> None: def do_update(self, helper: ConfigUpdateHelper) -> None:
with open(self.path, 'r') as stream: copy, copy_dict, base = helper
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
copy("homeserver.address") copy("homeserver.address")
copy("homeserver.domain") copy("homeserver.domain")
@@ -202,12 +79,14 @@ class Config(DictWithRecursion):
copy("bridge.displayname_template") copy("bridge.displayname_template")
copy("bridge.displayname_preference") copy("bridge.displayname_preference")
copy("bridge.displayname_max_length")
copy("bridge.max_initial_member_sync") copy("bridge.max_initial_member_sync")
copy("bridge.sync_channel_members") copy("bridge.sync_channel_members")
copy("bridge.skip_deleted_members") copy("bridge.skip_deleted_members")
copy("bridge.startup_sync") copy("bridge.startup_sync")
copy("bridge.sync_dialog_limit") copy("bridge.sync_dialog_limit")
copy("bridge.sync_direct_chats")
copy("bridge.max_telegram_delete") copy("bridge.max_telegram_delete")
copy("bridge.sync_matrix_state") copy("bridge.sync_matrix_state")
copy("bridge.allow_matrix_login") copy("bridge.allow_matrix_login")
@@ -302,58 +181,43 @@ class Config(DictWithRecursion):
else: else:
copy("logging") copy("logging")
self._data = base._data def _get_permissions(self, key: str) -> Permissions:
self.save()
def _get_permissions(self, key: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
level = self["bridge.permissions"].get(key, "") level = self["bridge.permissions"].get(key, "")
admin = level == "admin" admin = level == "admin"
matrix_puppeting = level == "full" or admin matrix_puppeting = level == "full" or admin
puppeting = level == "puppeting" or matrix_puppeting puppeting = level == "puppeting" or matrix_puppeting
user = level == "user" or puppeting user = level == "user" or puppeting
relaybot = level == "relaybot" or user 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]: def get_permissions(self, mxid: UserID) -> Permissions:
permissions = self["bridge.permissions"] or {} permissions = self["bridge.permissions"]
if mxid in permissions: if mxid in permissions:
return self._get_permissions(mxid) return self._get_permissions(mxid)
homeserver = mxid[mxid.index(":") + 1:] _, homeserver = Client.parse_user_id(mxid)
if homeserver in permissions: if homeserver in permissions:
return self._get_permissions(homeserver) return self._get_permissions(homeserver)
return self._get_permissions("*") return self._get_permissions("*")
def generate_registration(self) -> None: @property
def namespaces(self) -> Dict[str, List[Dict[str, Any]]]:
homeserver = self["homeserver.domain"] homeserver = self["homeserver.domain"]
username_format = self.get("bridge.username_template", "telegram_{userid}") \ username_format = self["bridge.username_template"].format(userid=".+")
.format(userid=".+") alias_format = self["bridge.alias_template"].format(groupname=".+")
alias_format = self.get("bridge.alias_template", "telegram_{groupname}") \ group_id = ({"group_id": self["appservice.community_id"]}
.format(groupname=".+") if self["appservice.community_id"] else {})
self.set("appservice.as_token", self._new_token()) return {
self.set("appservice.hs_token", self._new_token()) "users": [{
"exclusive": True,
self._registration = { "regex": f"@{username_format}:{homeserver}",
"id": self["appservice.id"] or "telegram", **group_id,
"as_token": self["appservice.as_token"], }],
"hs_token": self["appservice.hs_token"], "aliases": [{
"namespaces": { "exclusive": True,
"users": [{ "regex": f"#{alias_format}:{homeserver}",
"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
} }
if self["appservice.community_id"]:
self._registration["namespaces"]["users"][0]["group_id"] \
= self["appservice.community_id"]
+25 -17
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -15,13 +14,13 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Tuple, TYPE_CHECKING from typing import Optional, Tuple, TYPE_CHECKING
import asyncio
from alchemysession import AlchemySessionContainer
from mautrix.appservice import AppService
if TYPE_CHECKING: if TYPE_CHECKING:
import asyncio
from alchemysession import AlchemySessionContainer
from mautrix_appservice import AppService
from .web import PublicBridgeWebsite, ProvisioningAPI from .web import PublicBridgeWebsite, ProvisioningAPI
from .config import Config from .config import Config
from .bot import Bot from .bot import Bot
@@ -29,17 +28,26 @@ if TYPE_CHECKING:
class Context: class Context:
def __init__(self, az: 'AppService', config: 'Config', loop: 'asyncio.AbstractEventLoop', az: AppService
session_container: 'AlchemySessionContainer', bot: Optional['Bot']) -> None: config: 'Config'
self.az = az # type: AppService loop: asyncio.AbstractEventLoop
self.config = config # type: Config bot: Optional['Bot']
self.loop = loop # type: asyncio.AbstractEventLoop mx: Optional['MatrixHandler']
self.bot = bot # type: Optional[Bot] session_container: AlchemySessionContainer
self.mx = None # type: Optional[MatrixHandler] public_website: Optional['PublicBridgeWebsite']
self.session_container = session_container # type: AlchemySessionContainer provisioning_api: Optional['ProvisioningAPI']
self.public_website = None # type: Optional[PublicBridgeWebsite]
self.provisioning_api = None # type: 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 @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 return self.az, self.config, self.loop, self.bot
+5 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,18 +13,19 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from .base import Base from sqlalchemy.engine.base import Engine
from mautrix.bridge.db import UserProfile, RoomState
from .bot_chat import BotChat from .bot_chat import BotChat
from .message import Message from .message import Message
from .portal import Portal from .portal import Portal
from .puppet import Puppet from .puppet import Puppet
from .room_state import RoomState
from .telegram_file import TelegramFile from .telegram_file import TelegramFile
from .user import User, UserPortal, Contact 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, for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile,
RoomState, BotChat): RoomState, BotChat):
table.db = db_engine table.db = db_engine
-58
View File
@@ -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 <https://www.gnu.org/licenses/>.
from abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class BaseBase:
db = 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)
-26
View File
@@ -1,26 +0,0 @@
from abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class Base(declarative_base):
db: Engine
t: Table
__table__: Table
c: ImmutableColumnCollection
@classmethod
@abstractmethod
def _one_or_none(cls, rows: RowProxy): ...
@classmethod
def _select_one_or_none(cls, *args): ...
def _edit_identity(self): ...
def update(self, **values) -> None: ...
def delete(self) -> None: ...
+11 -9
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -17,28 +16,31 @@
from typing import Iterable from typing import Iterable
from sqlalchemy import Column, Integer, String from sqlalchemy import Column, Integer, String
from sqlalchemy.engine.result import RowProxy
from mautrix.bridge.db import Base
from ..types import TelegramID from ..types import TelegramID
from .base import Base
# Fucking Telegram not telling bots what chats they are in 3:< # Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base): class BotChat(Base):
__tablename__ = "bot_chat" __tablename__ = "bot_chat"
id = Column(Integer, primary_key=True) # type: TelegramID id: TelegramID = Column(Integer, primary_key=True)
type = Column(String, nullable=False) type: str = Column(String, nullable=False)
@classmethod @classmethod
def delete(cls, chat_id: TelegramID) -> None: def delete_by_id(cls, chat_id: TelegramID) -> None:
with cls.db.begin() as conn: with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.id == chat_id)) conn.execute(cls.t.delete().where(cls.c.id == chat_id))
@classmethod
def scan(cls, row: RowProxy) -> 'BotChat':
return cls(id=row[0], type=row[1])
@classmethod @classmethod
def all(cls) -> Iterable['BotChat']: def all(cls) -> Iterable['BotChat']:
rows = cls.db.execute(cls.t.select()) return cls._select_all()
for row in rows:
chat_id, chat_type = row
yield cls(id=chat_id, type=chat_type)
def insert(self) -> None: def insert(self) -> None:
with self.db.begin() as conn: with self.db.begin() as conn:
+20 -28
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,42 +13,35 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Iterator
from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select
from sqlalchemy.engine.result import RowProxy from sqlalchemy.engine.result import RowProxy
from typing import Optional, List from sqlalchemy.sql.expression import ClauseElement
from ..types import MatrixRoomID, MatrixEventID, TelegramID from mautrix.types import RoomID, EventID
from .base import Base from mautrix.bridge.db import Base
from ..types import TelegramID
class Message(Base): class Message(Base):
__tablename__ = "message" __tablename__ = "message"
mxid = Column(String) # type: MatrixEventID mxid: EventID = Column(String)
mx_room = Column(String) # type: MatrixRoomID mx_room: RoomID = Column(String)
tgid = Column(Integer, primary_key=True) # type: TelegramID tgid: TelegramID = Column(Integer, primary_key=True)
tg_space = Column(Integer, primary_key=True) # type: TelegramID tg_space: TelegramID = Column(Integer, primary_key=True)
edit_index = Column(Integer, primary_key=True) # type: int edit_index: int = Column(Integer, primary_key=True)
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),) __table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room_2"),)
@classmethod @classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Message']: def scan(cls, row: RowProxy) -> 'Message':
try: return cls(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3], edit_index=row[4])
mxid, mx_room, tgid, tg_space, edit_index = next(rows)
return cls(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space,
edit_index=edit_index)
except StopIteration:
return None
@staticmethod
def _all(rows: RowProxy) -> List['Message']:
return [Message(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3],
edit_index=row[4])
for row in rows]
@classmethod @classmethod
def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> List['Message']: def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Iterator['Message']:
return cls._all(cls.db.execute(cls.t.select().where(and_(cls.c.tgid == tgid, return cls._all(cls.db.execute(cls.t.select().where(and_(cls.c.tgid == tgid,
cls.c.tg_space == tg_space)))) cls.c.tg_space == tg_space))))
@@ -69,7 +61,7 @@ class Message(Base):
return cls._one_or_none(cls.db.execute(query)) return cls._one_or_none(cls.db.execute(query))
@classmethod @classmethod
def count_spaces_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID) -> int: def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
rows = cls.db.execute(select([func.count(cls.c.tg_space)]) rows = cls.db.execute(select([func.count(cls.c.tg_space)])
.where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room))) .where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)))
try: try:
@@ -79,7 +71,7 @@ class Message(Base):
return 0 return 0
@classmethod @classmethod
def get_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID, tg_space: TelegramID def get_by_mxid(cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
) -> Optional['Message']: ) -> Optional['Message']:
return cls._select_one_or_none(and_(cls.c.mxid == mxid, return cls._select_one_or_none(and_(cls.c.mxid == mxid,
cls.c.mx_room == mx_room, cls.c.mx_room == mx_room,
@@ -95,14 +87,14 @@ class Message(Base):
.values(**values)) .values(**values))
@classmethod @classmethod
def update_by_mxid(cls, s_mxid: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None: def update_by_mxid(cls, s_mxid: EventID, s_mx_room: RoomID, **values) -> None:
with cls.db.begin() as conn: with cls.db.begin() as conn:
conn.execute(cls.t.update() conn.execute(cls.t.update()
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room)) .where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
.values(**values)) .values(**values))
@property @property
def _edit_identity(self): def _edit_identity(self) -> ClauseElement:
return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space, return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space,
self.c.edit_index == self.edit_index) self.c.edit_index == self.edit_index)
+21 -25
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,55 +13,52 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean, Text, and_
from sqlalchemy.engine.result import RowProxy
from typing import Optional from typing import Optional
from ..types import MatrixRoomID, TelegramID from sqlalchemy import Column, Integer, String, Boolean, Text, and_
from .base import Base from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.expression import ClauseElement
from mautrix.types import RoomID
from mautrix.bridge.db import Base
from ..types import TelegramID
class Portal(Base): class Portal(Base):
__tablename__ = "portal" __tablename__ = "portal"
# Telegram chat information # Telegram chat information
tgid = Column(Integer, primary_key=True) # type: TelegramID tgid: TelegramID = Column(Integer, primary_key=True)
tg_receiver = Column(Integer, primary_key=True) # type: TelegramID tg_receiver: TelegramID = Column(Integer, primary_key=True)
peer_type = Column(String, nullable=False) peer_type: str = Column(String, nullable=False)
megagroup = Column(Boolean) megagroup: bool = Column(Boolean)
# Matrix portal information # Matrix portal information
mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID] mxid: RoomID = Column(String, unique=True, nullable=True)
config = Column(Text, nullable=True) config: str = Column(Text, nullable=True)
# Telegram chat metadata # Telegram chat metadata
username = Column(String, nullable=True) username: str = Column(String, nullable=True)
title = Column(String, nullable=True) title: str = Column(String, nullable=True)
about = Column(String, nullable=True) about: str = Column(String, nullable=True)
photo_id = Column(String, nullable=True) photo_id: str = Column(String, nullable=True)
@classmethod @classmethod
def scan(cls, row) -> Optional['Portal']: def scan(cls, row: RowProxy) -> Optional['Portal']:
(tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about, (tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about,
photo_id) = row photo_id) = row
return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup, return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup,
mxid=mxid, config=config, username=username, title=title, about=about, mxid=mxid, config=config, username=username, title=title, about=about,
photo_id=photo_id) photo_id=photo_id)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Portal']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
@classmethod @classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']: def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver)) return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver))
@classmethod @classmethod
def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']: def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.mxid == mxid) return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod @classmethod
@@ -70,7 +66,7 @@ class Portal(Base):
return cls._select_one_or_none(cls.c.username == username) return cls._select_one_or_none(cls.c.username == username)
@property @property
def _edit_identity(self): def _edit_identity(self) -> ClauseElement:
return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver) return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver)
def insert(self) -> None: def insert(self) -> None:
+33 -35
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,44 +13,42 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql import expression
from typing import Optional, Iterable from typing import Optional, Iterable
from ..types import MatrixUserID, MatrixRoomID, TelegramID from sqlalchemy import Column, Integer, String, Boolean
from .base import Base from sqlalchemy.sql import expression
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.expression import ClauseElement
from mautrix.types import UserID, SyncToken
from mautrix.bridge.db import Base
from ..types import TelegramID
class Puppet(Base): class Puppet(Base):
__tablename__ = "puppet" __tablename__ = "puppet"
id = Column(Integer, primary_key=True) # type: TelegramID id: TelegramID = Column(Integer, primary_key=True)
custom_mxid = Column(String, nullable=True) # type: Optional[MatrixUserID] custom_mxid: UserID = Column(String, nullable=True)
access_token = Column(String, nullable=True) access_token: str = Column(String, nullable=True)
displayname = Column(String, nullable=True) next_batch: SyncToken = Column(String, nullable=True)
displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID] displayname: str = Column(String, nullable=True)
username = Column(String, nullable=True) displayname_source: TelegramID = Column(Integer, nullable=True)
photo_id = Column(String, nullable=True) username: str = Column(String, nullable=True)
is_bot = Column(Boolean, nullable=True) photo_id: str = Column(String, nullable=True)
matrix_registered = Column(Boolean, nullable=False, server_default=expression.false()) is_bot: bool = Column(Boolean, nullable=True)
disable_updates = Column(Boolean, nullable=False, server_default=expression.false()) matrix_registered: bool = Column(Boolean, nullable=False, server_default=expression.false())
disable_updates: bool = Column(Boolean, nullable=False, server_default=expression.false())
@classmethod @classmethod
def scan(cls, row) -> Optional['Puppet']: def scan(cls, row: RowProxy) -> Optional['Puppet']:
(id, custom_mxid, access_token, displayname, displayname_source, username, photo_id, (id, custom_mxid, access_token, next_batch, displayname, displayname_source, username,
is_bot, matrix_registered, disable_updates) = row photo_id, is_bot, matrix_registered, disable_updates) = row
return cls(id=id, custom_mxid=custom_mxid, access_token=access_token, return cls(id=id, custom_mxid=custom_mxid, access_token=access_token, username=username,
displayname=displayname, displayname_source=displayname_source, next_batch=next_batch, displayname=displayname, photo_id=photo_id,
username=username, photo_id=photo_id, is_bot=is_bot, displayname_source=displayname_source, matrix_registered=matrix_registered,
matrix_registered=matrix_registered, disable_updates=disable_updates) disable_updates=disable_updates, is_bot=is_bot)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
@classmethod @classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']: def all_with_custom_mxid(cls) -> Iterable['Puppet']:
@@ -64,7 +61,7 @@ class Puppet(Base):
return cls._select_one_or_none(cls.c.id == tgid) return cls._select_one_or_none(cls.c.id == tgid)
@classmethod @classmethod
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']: def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.custom_mxid == mxid) return cls._select_one_or_none(cls.c.custom_mxid == mxid)
@classmethod @classmethod
@@ -76,13 +73,14 @@ class Puppet(Base):
return cls._select_one_or_none(cls.c.displayname == displayname) return cls._select_one_or_none(cls.c.displayname == displayname)
@property @property
def _edit_identity(self): def _edit_identity(self) -> ClauseElement:
return self.c.id == self.id return self.c.id == self.id
def insert(self) -> None: def insert(self) -> None:
with self.db.begin() as conn: with self.db.begin() as conn:
conn.execute(self.t.insert().values( conn.execute(self.t.insert().values(
id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token, id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token,
displayname=self.displayname, displayname_source=self.displayname_source, next_batch=self.next_batch, displayname=self.displayname, username=self.username,
username=self.username, photo_id=self.photo_id, is_bot=self.is_bot, displayname_source=self.displayname_source, photo_id=self.photo_id,
matrix_registered=self.matrix_registered, disable_updates=self.disable_updates)) is_bot=self.is_bot, matrix_registered=self.matrix_registered,
disable_updates=self.disable_updates))
-62
View File
@@ -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 <https://www.gnu.org/licenses/>.
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))
+25 -23
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,38 +13,41 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
from typing import Optional from typing import Optional
from .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): class TelegramFile(Base):
__tablename__ = "telegram_file" __tablename__ = "telegram_file"
id = Column(String, primary_key=True) id: str = Column(String, primary_key=True)
mxc = Column(String) mxc: ContentURI = Column(String)
mime_type = Column(String) mime_type: str = Column(String)
was_converted = Column(Boolean) was_converted: bool = Column(Boolean)
timestamp = Column(BigInteger) timestamp: int = Column(BigInteger)
size = Column(Integer, nullable=True) size: int = Column(Integer, nullable=True)
width = Column(Integer, nullable=True) width: int = Column(Integer, nullable=True)
height = Column(Integer, nullable=True) height: int = Column(Integer, nullable=True)
thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True) thumbnail_id: str = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
thumbnail = None # type: Optional[TelegramFile] thumbnail: Optional['TelegramFile'] = None
@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 @classmethod
def get(cls, loc_id: str) -> Optional['TelegramFile']: def get(cls, loc_id: str) -> Optional['TelegramFile']:
rows = cls.db.execute(cls.t.select().where(cls.c.id == loc_id)) return cls._select_one_or_none(cls.c.id == loc_id)
try:
loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = next(rows)
thumb = None
if thumb_id:
thumb = cls.get(thumb_id)
return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
except StopIteration:
return None
def insert(self) -> None: def insert(self) -> None:
with self.db.begin() as conn: with self.db.begin() as conn:
+27 -31
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,46 +13,43 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
from sqlalchemy.engine.result import RowProxy
from typing import Optional, Iterable, Tuple from typing import Optional, Iterable, Tuple
from ..types import MatrixUserID, TelegramID from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
from .base import Base from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.expression import ClauseElement
from mautrix.types import UserID
from mautrix.bridge.db import Base
from ..types import TelegramID
class User(Base): class User(Base):
__tablename__ = "user" __tablename__ = "user"
mxid = Column(String, primary_key=True) # type: MatrixUserID mxid: UserID = Column(String, primary_key=True)
tgid = Column(Integer, nullable=True, unique=True) # type: Optional[TelegramID] tgid: Optional[TelegramID] = Column(Integer, nullable=True, unique=True)
tg_username = Column(String, nullable=True) tg_username: str = Column(String, nullable=True)
tg_phone = Column(String, nullable=True) tg_phone: str = Column(String, nullable=True)
saved_contacts = Column(Integer, default=0, nullable=False) saved_contacts: int = Column(Integer, default=0, nullable=False)
@classmethod @classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['User']: def scan(cls, row: RowProxy) -> 'User':
try: mxid, tgid, tg_username, tg_phone, saved_contacts = row
mxid, tgid, tg_username, tg_phone, saved_contacts = next(rows) return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone, saved_contacts=saved_contacts)
saved_contacts=saved_contacts)
except StopIteration:
return None
@classmethod @classmethod
def all(cls) -> Iterable['User']: def all_with_tgid(cls) -> Iterable['User']:
rows = cls.db.execute(cls.t.select()) return cls._select_all(cls.c.tgid != None)
for row in rows:
mxid, tgid, tg_username, tg_phone, saved_contacts = row
yield cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
saved_contacts=saved_contacts)
@classmethod @classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']: def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
return cls._select_one_or_none(cls.c.tgid == tgid) return cls._select_one_or_none(cls.c.tgid == tgid)
@classmethod @classmethod
def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']: def get_by_mxid(cls, mxid: UserID) -> Optional['User']:
return cls._select_one_or_none(cls.c.mxid == mxid) return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod @classmethod
@@ -61,7 +57,7 @@ class User(Base):
return cls._select_one_or_none(cls.c.tg_username == username) return cls._select_one_or_none(cls.c.tg_username == username)
@property @property
def _edit_identity(self): def _edit_identity(self) -> ClauseElement:
return self.c.mxid == self.mxid return self.c.mxid == self.mxid
def insert(self) -> None: def insert(self) -> None:
@@ -113,10 +109,10 @@ class User(Base):
class UserPortal(Base): class UserPortal(Base):
__tablename__ = "user_portal" __tablename__ = "user_portal"
user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"), user: TelegramID = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE",
primary_key=True) # type: TelegramID ondelete="CASCADE"), primary_key=True)
portal = Column(Integer, primary_key=True) # type: TelegramID portal: TelegramID = Column(Integer, primary_key=True)
portal_receiver = Column(Integer, primary_key=True) # type: TelegramID portal_receiver: TelegramID = Column(Integer, primary_key=True)
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"), __table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"), ("portal.tgid", "portal.tg_receiver"),
@@ -126,5 +122,5 @@ class UserPortal(Base):
class Contact(Base): class Contact(Base):
__tablename__ = "contact" __tablename__ = "contact"
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID user: TelegramID = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID contact: TelegramID = Column(Integer, ForeignKey("puppet.id"), primary_key=True)
-69
View File
@@ -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 <https://www.gnu.org/licenses/>.
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))
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,29 +13,30 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING, Dict, Any from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING
import re import re
import logging import logging
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic, from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
TypeMessageEntity) TypeMessageEntity)
from telethon.helpers import add_surrogate, del_surrogate
from mautrix.types import RoomID, MessageEventContent
from ... import puppet as pu from ... import puppet as pu
from ...types import TelegramID, MatrixRoomID from ...types import TelegramID
from ...db import Message as DBMessage from ...db import Message as DBMessage
from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text)
from .parser import ParsedMessage, parse_html from .parser import ParsedMessage, parse_html
if TYPE_CHECKING: if TYPE_CHECKING:
from ...context import Context from ...context import Context
log = logging.getLogger("mau.fmt.mx") # type: logging.Logger log: logging.Logger = logging.getLogger("mau.fmt.mx")
should_bridge_plaintext_highlights = False # type: bool should_bridge_plaintext_highlights: bool = False
command_regex = re.compile(r"^!([A-Za-z0-9@]+)") # type: Pattern command_regex: Pattern = re.compile(r"^!([A-Za-z0-9@]+)")
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") # type: Pattern not_command_regex: Pattern = re.compile(r"^\\(![A-Za-z0-9@]+)")
plain_mention_regex = None # type: Optional[Pattern] plain_mention_regex: Optional[Pattern] = None
def plain_mention_to_html(match: Match) -> str: def plain_mention_to_html(match: Match) -> str:
@@ -49,17 +49,22 @@ def plain_mention_to_html(match: Match) -> str:
return "".join(match.groups()) 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: def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
if len(message) > 4096: if len(message) > MAX_LENGTH:
message = message[0:4082] + " [message cut]" message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
new_entities = [] new_entities = []
for entity in entities: for entity in entities:
if entity.offset > 4082: if entity.offset > CUT_MAX_LENGTH:
continue continue
if entity.offset + entity.length > 4082: if entity.offset + entity.length > CUT_MAX_LENGTH:
entity.length = 4082 - entity.offset entity.length = CUT_MAX_LENGTH - entity.offset
new_entities.append(entity) 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 entities = new_entities
return message, entities return message, entities
@@ -76,8 +81,8 @@ def matrix_to_telegram(html: str) -> ParsedMessage:
if should_bridge_plaintext_highlights: if should_bridge_plaintext_highlights:
html = plain_mention_regex.sub(plain_mention_to_html, html) html = plain_mention_regex.sub(plain_mention_to_html, html)
text, entities = parse_html(add_surrogates(html)) text, entities = parse_html(add_surrogate(html))
text = remove_surrogates(text.strip()) text = del_surrogate(text.strip())
text, entities = cut_long_message(text, entities) text, entities = cut_long_message(text, entities)
return 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 raise FormatError(f"Failed to convert Matrix format: {html}") from e
def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID, def matrix_reply_to_telegram(content: MessageEventContent, tg_space: TelegramID,
room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]: room_id: Optional[RoomID] = None) -> Optional[TelegramID]:
relates_to = content.get("m.relates_to", None) or {} event_id = content.get_reply_to()
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)
if not event_id: if not event_id:
return return
content.trim_reply_fallback()
try:
if content["format"] == "org.matrix.custom.html":
content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
except KeyError:
pass
content["body"] = trim_reply_fallback_text(content["body"])
message = DBMessage.get_by_mxid(event_id, room_id, tg_space) message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message: if message:
@@ -124,10 +115,10 @@ def matrix_text_to_telegram(text: str) -> ParsedMessage:
return text, entities 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 = [] entities = []
def replacer(match) -> str: def replacer(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2)) puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet: if puppet:
offset = match.start() offset = match.start()
@@ -148,7 +139,7 @@ def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], st
def init_mx(context: "Context") -> None: def init_mx(context: "Context") -> None:
global plain_mention_regex, should_bridge_plaintext_highlights global plain_mention_regex, should_bridge_plaintext_highlights
config = context.config 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}"), "[^>]+") dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
plain_mention_regex = re.compile(f"^({dn_template})") 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"]
@@ -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 <https://www.gnu.org/licenses/>.
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]
@@ -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: ...
+50 -214
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,240 +13,77 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import List, Tuple, Pattern from typing import List, Tuple, Optional
import re
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command, from telethon.tl.types import TypeMessageEntity
MessageEntityMentionName as MentionName, MessageEntityUrl as URL,
MessageEntityEmail as Email, MessageEntityTextUrl as TextURL, from mautrix.types import UserID, RoomID
MessageEntityBold as Bold, MessageEntityItalic as Italic, from mautrix.util.formatter import MatrixParser as BaseMatrixParser, RecursionContext
MessageEntityCode as Code, MessageEntityPre as Pre, from mautrix.util.formatter.html_reader_htmlparser import read_html, HTMLNode
MessageEntityStrike as Strike, MessageEntityUnderline as Underline,
MessageEntityBlockquote as Blockquote, TypeMessageEntity)
from ... import user as u, puppet as pu, portal as po from ... import user as u, puppet as pu, portal as po
from ...types import MatrixUserID from .telegram_message import TelegramMessage, TelegramEntityType
from .telegram_message import TelegramMessage, Entity, offset_length_multiply
from .html_reader import HTMLNode, read_html
ParsedMessage = Tuple[str, List[TypeMessageEntity]] ParsedMessage = Tuple[str, List[TypeMessageEntity]]
def parse_html(input_html: str) -> ParsedMessage: 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: class MatrixParser(BaseMatrixParser[TelegramMessage]):
def __init__(self, strip_linebreaks: bool = True, ul_depth: int = 0): e = TelegramEntityType
self.strip_linebreaks = strip_linebreaks # type: bool fs = TelegramMessage
self.ul_depth = ul_depth # type: int read_html = read_html
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, ...]
@classmethod @classmethod
def list_bullet(cls, depth: int) -> str: def custom_node_to_fstring(cls, node: HTMLNode, ctx: RecursionContext
return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " " ) -> Optional[TelegramMessage]:
@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:
msg = cls.tag_aware_parse_node(node, ctx) msg = cls.tag_aware_parse_node(node, ctx)
if node.tag in ("b", "strong"): if node.tag == "command":
msg.format(Bold) msg.format(TelegramEntityType.COMMAND)
elif node.tag in ("i", "em"): return None
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)
@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 return msg
@classmethod @classmethod
def link_to_tstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: def url_to_fstring(cls, msg: TelegramMessage, url: str) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx) if url == msg.text:
href = node.attrib.get("href", "") return msg.format(cls.e.URL)
if not href: else:
return msg return msg.format(cls.e.INLINE_URL, url=url)
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))
@classmethod @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) msg = cls.tag_aware_parse_node(node, ctx)
children = msg.trim().split("\n") children = msg.trim().split("\n")
children = [child.prepend("> ") for child in children] children = [child.prepend("> ") for child in children]
return TelegramMessage.join(children, "\n") 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"<body>{data}</body>"), RecursionContext())
return msg.text, msg.entities
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,145 +13,87 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import 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, from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
MessageEntityTextUrl as TextURL, MessageEntityPre as Pre, MessageEntityMentionName as MentionName, MessageEntityUrl as URL,
TypeMessageEntity, InputMessageEntityMentionName as InputMentionName) 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: class TelegramEntityType(Enum):
@staticmethod """EntityType is a Matrix formatting entity type."""
def copy(entity: TypeMessageEntity) -> Optional[TypeMessageEntity]: BOLD = Bold
if not entity: ITALIC = Italic
return None STRIKETHROUGH = Strike
kwargs = { UNDERLINE = Underline
"offset": entity.offset, URL = URL
"length": entity.length, INLINE_URL = TextURL
} EMAIL = Email
if isinstance(entity, Pre): PREFORMATTED = Pre
kwargs["language"] = entity.language INLINE_CODE = Code
elif isinstance(entity, TextURL): BLOCKQUOTE = Blockquote
kwargs["url"] = entity.url MENTION = Mention
elif isinstance(entity, (MentionName, InputMentionName)): MENTION_NAME = MentionName
kwargs["user_id"] = entity.user_id COMMAND = Command
return entity.__class__(**kwargs)
@classmethod USER_MENTION = 1
def adjust(cls, entity: Union[TypeMessageEntity, List[TypeMessageEntity]], ROOM_MENTION = 2
func: Callable[[TypeMessageEntity], None] HEADER = 3
) -> 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
def offset_diff(amount: int) -> Callable[[TypeMessageEntity], None]: class TelegramEntity(SemiAbstractEntity):
def func(entity: TypeMessageEntity) -> None: internal: TypeMessageEntity
entity.offset += amount
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]: class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]):
def func(entity: TypeMessageEntity) -> None: entity_class = TelegramEntity
entity.offset *= amount
entity.length *= amount
return func @property
def telegram_entities(self) -> List[TypeMessageEntity]:
return [entity.internal for entity in self.entities]
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
+82 -106
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING from typing import List, Optional, TYPE_CHECKING
from html import escape from html import escape
import logging import logging
import re import re
@@ -23,48 +22,43 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold, MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
MessageEntityItalic, MessageEntityCode, MessageEntityPre, MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag, MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
MessageEntityPhone, TypeMessageEntity, Message, PeerChannel, MessageEntityPhone, TypeMessageEntity, PeerChannel,
MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader, MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader,
MessageEntityUnderline, PeerUser) MessageEntityUnderline, PeerUser)
from telethon.tl.custom import Message
from telethon.helpers import add_surrogate, del_surrogate
from mautrix_appservice import MatrixRequestError from mautrix.errors import MatrixRequestError
from mautrix_appservice.intent_api import IntentAPI from mautrix.appservice import IntentAPI
from mautrix.types import (TextMessageEventContent, RelatesTo, RelationType, Format, MessageType,
MessageEvent)
from .. import user as u, puppet as pu, portal as po from .. import user as u, puppet as pu, portal as po
from ..types import TelegramID from ..types import TelegramID
from ..db import Message as DBMessage from ..db import Message as DBMessage
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text)
if TYPE_CHECKING: if TYPE_CHECKING:
from ..abstract_user import AbstractUser 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: if evt.reply_to_msg_id:
space = (evt.to_id.channel_id space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel) if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid) else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space) msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
if msg: if msg:
return { return RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
"m.in_reply_to": { return None
"event_id": msg.mxid,
"room_id": msg.mx_room,
},
"rel_type": "m.reference",
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
return {}
async def _add_forward_header(source, text: str, html: Optional[str], async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventContent,
fwd_from: MessageFwdHeader) -> Tuple[str, str]: fwd_from: MessageFwdHeader) -> None:
if not html: if not content.formatted_body or content.format != Format.HTML:
html = escape(text) content.format = Format.HTML
content.formatted_body = escape(content.body)
fwd_from_html, fwd_from_text = None, None fwd_from_html, fwd_from_text = None, None
if fwd_from.from_id: if fwd_from.from_id:
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id)) user = u.User.get_by_tgid(TelegramID(fwd_from.from_id))
@@ -81,11 +75,14 @@ async def _add_forward_header(source, text: str, html: Optional[str],
f"{escape(fwd_from_text)}</a>") f"{escape(fwd_from_text)}</a>")
if not fwd_from_text: if not fwd_from_text:
user = await source.client.get_entity(PeerUser(fwd_from.from_id)) try:
if user: user = await source.client.get_entity(PeerUser(fwd_from.from_id))
fwd_from_text = pu.Puppet.get_displayname(user, False) if user:
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>" fwd_from_text = pu.Puppet.get_displayname(user, False)
else: fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
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)) portal = po.Portal.get_by_tgid(TelegramID(fwd_from.channel_id))
if portal: if portal:
fwd_from_text = portal.title fwd_from_text = portal.title
@@ -93,78 +90,48 @@ async def _add_forward_header(source, text: str, html: Optional[str],
fwd_from_html = (f"<a href='https://matrix.to/#/{portal.alias}'>" fwd_from_html = (f"<a href='https://matrix.to/#/{portal.alias}'>"
f"{escape(fwd_from_text)}</a>") f"{escape(fwd_from_text)}</a>")
else: else:
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>" fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
else: else:
channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id)) try:
if channel: channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
fwd_from_text = channel.title if channel:
fwd_from_html = f"<b>{fwd_from_text}</b>" fwd_from_text = f"channel {channel.title}"
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
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"<b>{escape(fwd_from.from_name)}</b>"
else:
fwd_from_text = "unknown source"
fwd_from_html = f"unknown source"
if not fwd_from_text: content.body = "\n".join([f"> {line}" for line in content.body.split("\n")])
if fwd_from.from_id: content.body = f"Forwarded from {fwd_from_text}:\n{content.body}"
fwd_from_text = "Unknown user" content.formatted_body = (
else: f"Forwarded message from {fwd_from_html}<br/>"
fwd_from_text = "Unknown source" f"<tg-forward><blockquote>{content.formatted_body}</blockquote></tg-forward>")
fwd_from_html = f"<b>{fwd_from_text}</b>"
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}<br/>"
f"<tg-forward><blockquote>{html}</blockquote></tg-forward>")
return text, html
async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message, async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventContent, evt: Message,
relates_to: Dict, main_intent: IntentAPI) -> Tuple[str, str]: main_intent: IntentAPI):
space = (evt.to_id.channel_id space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel) if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid) else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space) msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
if not msg: if not msg:
return text, html return
relates_to["rel_type"] = "m.reference" content.relates_to = RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
relates_to["event_id"] = msg.mxid
relates_to["room_id"] = msg.mx_room
relates_to["m.in_reply_to"] = {
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
try: try:
event = await main_intent.get_event(msg.mx_room, msg.mxid) event: MessageEvent = await main_intent.get_event(msg.mx_room, msg.mxid)
if isinstance(event.content, TextMessageEventContent):
content = event["content"] event.content.trim_reply_fallback()
r_sender = event["sender"] content.set_reply(event)
except MatrixRequestError:
r_text_body = trim_reply_fallback_text(content["body"]) pass
r_html_body = trim_reply_fallback_html(content["formatted_body"]
if "formatted_body" in content
else escape(content["body"]))
puppet = pu.Puppet.get_by_mxid(r_sender, create=False)
r_displayname = puppet.displayname if puppet else r_sender
r_sender_link = f"<a href='https://matrix.to/#/{r_sender}'>{escape(r_displayname)}</a>"
except (ValueError, KeyError, MatrixRequestError):
r_sender_link = "unknown user"
r_displayname = "unknown user"
r_text_body = "Failed to fetch message"
r_html_body = "<em>Failed to fetch message</em>"
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>In reply to</a>"
html = (
f"<mx-reply><blockquote>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote></mx-reply>"
+ (html or escape(text)))
lines = r_text_body.strip().split("\n")
text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
for line in lines:
if line:
text_with_quote += f"\n> {line}"
text_with_quote += "\n\n"
text_with_quote += text
return text_with_quote, html
async def telegram_to_matrix(evt: Message, source: "AbstractUser", async def telegram_to_matrix(evt: Message, source: "AbstractUser",
@@ -172,33 +139,42 @@ async def telegram_to_matrix(evt: Message, source: "AbstractUser",
prefix_text: Optional[str] = None, prefix_html: Optional[str] = None, prefix_text: Optional[str] = None, prefix_html: Optional[str] = None,
override_text: str = None, override_text: str = None,
override_entities: List[TypeMessageEntity] = None, override_entities: List[TypeMessageEntity] = None,
no_reply_fallback: bool = False) -> Tuple[str, str, Dict]: no_reply_fallback: bool = False) -> TextMessageEventContent:
text = add_surrogates(override_text or evt.message) content = TextMessageEventContent(
msgtype=MessageType.TEXT,
body=add_surrogate(override_text or evt.message),
)
entities = override_entities or evt.entities entities = override_entities or evt.entities
html = _telegram_entities_to_matrix_catch(text, entities) if entities else None if entities:
relates_to = {} # type: Dict content.format = Format.HTML
content.formatted_body = _telegram_entities_to_matrix_catch(content.body, entities)
if prefix_html: if prefix_html:
html = prefix_html + (html or escape(text)) if not content.formatted_body:
content.format = Format.HTML
content.formatted_body = escape(content.body)
content.formatted_body = prefix_html + content.formatted_body
if prefix_text: if prefix_text:
text = prefix_text + text content.body = prefix_text + content.body
if evt.fwd_from: if evt.fwd_from:
text, html = await _add_forward_header(source, text, html, evt.fwd_from) await _add_forward_header(source, content, evt.fwd_from)
if evt.reply_to_msg_id and not no_reply_fallback: if evt.reply_to_msg_id and not no_reply_fallback:
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent) await _add_reply_header(source, content, evt, main_intent)
if isinstance(evt, Message) and evt.post and evt.post_author: if isinstance(evt, Message) and evt.post and evt.post_author:
if not html: if not content.formatted_body:
html = escape(text) content.formatted_body = escape(content.body)
text += f"\n- {evt.post_author}" content.body += f"\n- {evt.post_author}"
html += f"<br/><i>- <u>{evt.post_author}</u></i>" content.formatted_body += f"<br/><i>- <u>{evt.post_author}</u></i>"
if html: content.body = del_surrogate(content.body)
html = html.replace("\n", "<br/>")
return remove_surrogates(text), remove_surrogates(html), relates_to if content.formatted_body:
content.formatted_body = del_surrogate(content.formatted_body.replace("\n", "<br/>"))
return content
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str: def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
@@ -313,8 +289,8 @@ def _parse_name_mention(html: List[str], entity_text: str, user_id: TelegramID)
return False return False
message_link_regex = re.compile( message_link_regex = re.compile(r"https?://t(?:elegram)?\.(?:me|dog)/"
r"https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})") 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: def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
-57
View File
@@ -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 <https://www.gnu.org/licenses/>.
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("<HH", x.encode("utf-16-le")))
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text)
def remove_surrogates(text: Optional[str]) -> 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("^<mx-reply>"
r"[\s\S]+?"
"</mx-reply>") # type: Pattern
def trim_reply_fallback_html(html: str) -> str:
return html_reply_fallback_regex.sub("", html)
+145 -261
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,58 +13,56 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Match, Optional, Set, Tuple, TYPE_CHECKING from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING
import logging
import asyncio
import time
import re
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 from . import user as u, portal as po, puppet as pu, commands as com
if TYPE_CHECKING: if TYPE_CHECKING:
from .context import Context from .context import Context
from .bot import Bot
try: try:
from prometheus_client import Histogram from prometheus_client import Histogram
EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events", EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events", ["event_type"])
["event_type"])
except ImportError: except ImportError:
Histogram = None Histogram = None
EVENT_TIME = 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: def __init__(self, context: 'Context') -> None:
self.az, self.config, _, self.tgbot = context.core super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop,
self.commands = com.CommandProcessor(context) # type: com.CommandProcessor command_processor=com.CommandProcessor(context))
self.previously_typing = [] # type: List[MatrixUserID] 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: async def get_portal(self, room_id: RoomID) -> 'po.Portal':
displayname = self.config["appservice.bot_displayname"] return po.Portal.get_by_mxid(room_id)
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")
avatar = self.config["appservice.bot_avatar"] async def get_puppet(self, user_id: UserID) -> 'pu.Puppet':
if avatar: return pu.Puppet.get_by_mxid(user_id)
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 handle_puppet_invite(self, room_id: MatrixRoomID, puppet: pu.Puppet, inviter: u.User async def handle_puppet_invite(self, room_id: RoomID, puppet: pu.Puppet, inviter: u.User,
) -> None: event_id: EventID) -> None:
intent = puppet.default_mxid_intent intent = puppet.default_mxid_intent
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room_id}") self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room_id}")
if not await inviter.is_logged_in(): if not await inviter.is_logged_in():
@@ -83,7 +80,7 @@ class MatrixHandler:
return return
try: try:
members = await self.az.intent.get_room_members(room_id) members = await self.az.intent.get_room_members(room_id)
except MatrixRequestError: except MatrixError:
members = [] members = []
if self.az.bot_mxid not in members: if self.az.bot_mxid not in members:
if len(members) > 1: if len(members) > 1:
@@ -95,18 +92,16 @@ class MatrixHandler:
await intent.join_room(room_id) await intent.join_room(room_id)
portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user") portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
# TODO: if portal is None:
if portal.mxid: if portal.mxid:
try: try:
await intent.invite(portal.mxid, inviter.mxid) await intent.invite_user(portal.mxid, inviter.mxid)
await intent.send_notice(room_id, text=None, html=( await intent.send_notice(
"You already have a private chat with me: " room_id, text=f"You already have a private chat with me: {portal.mxid}",
f"<a href='https://matrix.to/#/{portal.mxid}'>" html=("You already have a private chat with me: "
"Link to room" f"<a href='https://matrix.to/#/{portal.mxid}'>Link to room</a>"))
"</a>"))
await intent.leave_room(room_id) await intent.leave_room(room_id)
return return
except MatrixRequestError: except MatrixError:
pass pass
portal.mxid = room_id portal.mxid = room_id
portal.save() portal.save()
@@ -117,67 +112,25 @@ class MatrixHandler:
await intent.send_notice(room_id, "This puppet will remain inactive until a " await intent.send_notice(room_id, "This puppet will remain inactive until a "
"Telegram chat is created for this room.") "Telegram chat is created for this room.")
async def accept_bot_invite(self, room_id: MatrixRoomID, inviter: u.User) -> None: async def send_welcome_message(self, room_id: RoomID, 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="<p>You are not whitelisted to use this bridge.</p>"
"<p>If you are the owner of this bridge, see the "
"<code>bridge.permissions</code> section in your config file.</p>")
await self.az.intent.leave_room(room_id)
try: try:
is_management = len(await self.az.intent.get_room_members(room_id)) == 2 is_management = len(await self.az.intent.get_room_members(room_id)) == 2
except MatrixRequestError: except MatrixError:
is_management = False # The AS bot is not in the room.
return
cmd_prefix = self.commands.command_prefix cmd_prefix = self.commands.command_prefix
text = html = "Hello, I'm a Telegram bridge bot. " text = html = "Hello, I'm a Telegram bridge bot. "
if is_management and inviter.puppet_whitelisted and not await inviter.is_logged_in(): 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." text += f"Use `{cmd_prefix} help` for help or `{cmd_prefix} login` to log in."
html += (f"Use <code>{cmd_prefix} help</code> for help" html += (f"Use <code>{cmd_prefix} help</code> for help"
f" or <code>{cmd_prefix} login</code> to log in.") f" or <code>{cmd_prefix} login</code> to log in.")
pass
else: else:
text += f"Use `{cmd_prefix} help` for help." text += f"Use `{cmd_prefix} help` for help."
html += f"Use <code>{cmd_prefix} help</code> for help." html += f"Use <code>{cmd_prefix} help</code> for help."
await self.az.intent.send_notice(room_id, text=text, html=html) await self.az.intent.send_notice(room_id, text=text, html=html)
async def handle_invite(self, room_id: MatrixRoomID, user_id: MatrixUserID, async def handle_invite(self, room_id: RoomID, user_id: UserID, inviter: 'u.User',
inviter_mxid: MatrixUserID) -> None: event_id: EventID) -> 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
user = u.User.get_by_mxid(user_id, create=False) user = u.User.get_by_mxid(user_id, create=False)
if not user: if not user:
return return
@@ -187,10 +140,7 @@ class MatrixHandler:
await portal.invite_telegram(inviter, user) await portal.invite_telegram(inviter, user)
return return
# The rest can probably be ignored async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
async def handle_join(self, room_id: MatrixRoomID, user_id: MatrixUserID,
event_id: MatrixEventID) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started() user = await u.User.get_by_mxid(user_id).ensure_started()
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
@@ -198,24 +148,24 @@ class MatrixHandler:
return return
if not user.relaybot_whitelisted: if not user.relaybot_whitelisted:
await portal.main_intent.kick(room_id, user.mxid, await portal.main_intent.kick_user(room_id, user.mxid,
"You are not whitelisted on this Telegram bridge.") "You are not whitelisted on this Telegram bridge.")
return return
elif not await user.is_logged_in() and not portal.has_bot: elif not await user.is_logged_in() and not portal.has_bot:
await portal.main_intent.kick(room_id, user.mxid, await portal.main_intent.kick_user(room_id, user.mxid,
"This chat does not have a bot relaying " "This chat does not have a bot relaying "
"messages for unauthenticated users.") "messages for unauthenticated users.")
return return
self.log.debug(f"{user} joined {room_id}") self.log.debug(f"{user} joined {room_id}")
if await user.is_logged_in() or portal.has_bot: if await user.is_logged_in() or portal.has_bot:
await portal.join_matrix(user, event_id) await portal.join_matrix(user, event_id)
async def handle_part(self, room_id: MatrixRoomID, user_id: MatrixUserID, async def handle_raw_leave(self, room_id: RoomID, user_id: UserID, sender_id: UserID,
sender_mxid: MatrixUserID, event_id: MatrixEventID) -> None: reason: str, event_id: EventID) -> None:
self.log.debug(f"{user_id} left {room_id}") 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: if not sender:
return return
await sender.ensure_started() await sender.ensure_started()
@@ -226,98 +176,67 @@ class MatrixHandler:
puppet = pu.Puppet.get_by_mxid(user_id) puppet = pu.Puppet.get_by_mxid(user_id)
if puppet: if puppet:
if sender: await portal.kick_matrix(puppet, sender)
await portal.kick_matrix(puppet, sender)
return return
user = u.User.get_by_mxid(user_id, create=False) user = u.User.get_by_mxid(user_id, create=False)
if not user: if not user:
return return
await user.ensure_started() await user.ensure_started()
if await user.is_logged_in() or portal.has_bot: if sender_id != user_id:
await portal.leave_matrix(user, sender, event_id) await portal.kick_matrix(user, sender)
else:
def is_command(self, message: Dict) -> Tuple[bool, str]: await portal.leave_matrix(user, event_id)
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)
@staticmethod @staticmethod
async def handle_redaction(room_id: MatrixRoomID, sender_mxid: MatrixUserID, async def allow_message(user: 'u.User') -> bool:
event_id: MatrixEventID) -> None: return user.relaybot_whitelisted
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
@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: if not sender.relaybot_whitelisted:
return return
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(evt.room_id)
if not portal: if not portal:
return return
await portal.handle_matrix_deletion(sender, event_id) await portal.handle_matrix_deletion(sender, evt.redacts)
@staticmethod @staticmethod
async def handle_power_levels(room_id: MatrixRoomID, sender_mxid: MatrixUserID, async def handle_power_levels(evt: StateEvent) -> None:
new: Dict, old: Dict) -> None: portal = po.Portal.get_by_mxid(evt.room_id)
portal = po.Portal.get_by_mxid(room_id) sender = await u.User.get_by_mxid(evt.sender).ensure_started()
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal: 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 @staticmethod
async def handle_room_meta(evt_type: str, room_id: MatrixRoomID, sender_mxid: MatrixUserID, async def handle_room_meta(evt_type: EventType, room_id: RoomID, sender_mxid: UserID,
content: dict) -> None: content: RoomMetaStateEventContent) -> None:
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started() sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal: if await sender.has_full_access(allow_bot=True) and portal:
handler, content_key = { handler, content_key = {
"m.room.name": (portal.handle_matrix_title, "name"), EventType.ROOM_NAME: (portal.handle_matrix_title, "name"),
"m.room.topic": (portal.handle_matrix_about, "topic"), EventType.ROOM_TOPIC: (portal.handle_matrix_about, "topic"),
"m.room.avatar": (portal.handle_matrix_avatar, "url"), EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, "url"),
}[evt_type] }[evt_type]
if content_key not in content: if content_key not in content:
return return
await handler(sender, content[content_key]) await handler(sender, content[content_key])
@staticmethod @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: new_events: Set[str], old_events: Set[str]) -> None:
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started() sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
@@ -325,62 +244,69 @@ class MatrixHandler:
events = new_events - old_events events = new_events - old_events
if len(events) > 0: if len(events) > 0:
# New event pinned, set that as pinned in Telegram. # 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: elif len(new_events) == 0:
# All pinned events removed, remove pinned event in Telegram. # All pinned events removed, remove pinned event in Telegram.
await portal.handle_matrix_pin(sender, None) await portal.handle_matrix_pin(sender, None)
@staticmethod @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) portal = po.Portal.get_by_mxid(room_id)
if portal: if portal:
await portal.handle_matrix_upgrade(new_room_id) await portal.handle_matrix_upgrade(new_room_id)
@staticmethod async def handle_member_info_change(self, room_id: RoomID, user_id: UserID,
async def handle_name_change(room_id: MatrixRoomID, user_id: MatrixUserID, displayname: str, profile: MemberStateEventContent,
prev_displayname: str, event_id: MatrixEventID) -> None: prev_profile: MemberStateEventContent,
event_id: EventID) -> None:
if profile.displayname == prev_profile.displayname:
return
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
if not portal or not portal.has_bot: if not portal or not portal.has_bot:
return return
user = await u.User.get_by_mxid(user_id).ensure_started() user = await u.User.get_by_mxid(user_id).ensure_started()
if await user.needs_relaybot(portal): 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 @staticmethod
def parse_read_receipts(content: Dict) -> Dict[MatrixUserID, MatrixEventID]: def parse_read_receipts(content: ReceiptEventContent) -> Iterable[Tuple[UserID, EventID]]:
return {user_id: event_id return ((user_id, event_id)
for event_id, receipts in content.items() for event_id, receipts in content.items()
for user_id in receipts.get("m.read", {})} for user_id in receipts.get(ReceiptType.READ, {}))
@staticmethod @staticmethod
async def handle_read_receipts(room_id: MatrixRoomID, async def handle_read_receipts(room_id: RoomID, receipts: Iterable[Tuple[UserID, EventID]]
receipts: Dict[MatrixUserID, MatrixEventID]) -> None: ) -> None:
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
if not portal: if not portal:
return 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() user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in(): if not await user.is_logged_in():
continue continue
await portal.mark_read(user, event_id) await portal.mark_read(user, event_id)
@staticmethod @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() user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in(): if not await user.is_logged_in():
return 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) portal = po.Portal.get_by_mxid(room_id)
if not portal: if not portal:
return 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 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: if is_typing and was_typing:
continue continue
@@ -390,88 +316,46 @@ class MatrixHandler:
await portal.set_typing(user, is_typing) 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: def filter_matrix_event(self, evt: Event) -> bool:
sender = event.get("sender", None) if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent)):
if not sender: return True
return False return evt.sender and (evt.sender == self.az.bot_mxid
return (sender == self.az.bot_mxid or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
or pu.Puppet.get_id_from_mxid(sender) is not None)
async def try_handle_ephemeral_event(self, evt: MatrixEvent) -> None: async def handle_ephemeral_event(self, evt: Union[ReceiptEvent, PresenceEvent, TypingEvent]
try: ) -> None:
await self.handle_ephemeral_event(evt) if evt.type == EventType.RECEIPT:
except Exception: await self.handle_read_receipts(evt.room_id, self.parse_read_receipts(evt.content))
self.log.exception("Error handling manually received Matrix event") 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: async def handle_event(self, evt: Event) -> None:
evt_type = evt.get("type", "m.unknown") # type: str if evt.type == EventType.ROOM_REDACTION:
room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID] await self.handle_redaction(evt)
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: MatrixEvent) -> None: async def handle_state_event(self, evt: StateEvent) -> None:
if self.filter_matrix_event(evt): if evt.type == EventType.ROOM_POWER_LEVELS:
return await self.handle_power_levels(evt)
start_time = time.time() elif evt.type in (EventType.ROOM_NAME, EventType.ROOM_AVATAR, EventType.ROOM_TOPIC):
self.log.debug("Received event: %s", evt) await self.handle_room_meta(evt.type, evt.room_id, evt.sender, evt.content)
evt_type = evt.get("type", "m.unknown") # type: str elif evt.type == EventType.ROOM_PINNED_EVENTS:
room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID] new_events = set(evt.content.pinned)
event_id = evt.get("event_id", None) # type: Optional[MatrixEventID] try:
sender = evt.get("sender", None) # type: Optional[MatrixUserID] old_events = set(evt.unsigned.prev_content.pinned)
state_key = evt.get("state_key", None) except (KeyError, ValueError, TypeError, AttributeError):
content = evt.get("content", {}) # type: Dict old_events = set()
if state_key is not None: await self.handle_room_pin(evt.room_id, evt.sender, new_events, old_events)
if evt_type == "m.room.member": elif evt.type == EventType.ROOM_TOMBSTONE:
prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: Dict await self.handle_room_upgrade(evt.room_id, evt.content.replacement_room)
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
if EVENT_TIME: # async def handle_event(self, evt: MatrixEvent) -> None:
EVENT_TIME.labels(event_type=evt_type).observe(time.time() - start_time) # 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)
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"]
+15
View File
@@ -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"]
+474
View File
@@ -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 <https://www.gnu.org/licenses/>.
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}")
+133
View File
@@ -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 <https://www.gnu.org/licenses/>.
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"]
+503
View File
@@ -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 <https://www.gnu.org/licenses/>.
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", "<br/>")
tpl = (self.get_config(f"message_formats.[{content.msgtype.value}]")
or "<b>$sender_displayname</b>: $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
+666
View File
@@ -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 <https://www.gnu.org/licenses/>.
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
+44
View File
@@ -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 <https://www.gnu.org/licenses/>.
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)
+556
View File
@@ -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 <https://www.gnu.org/licenses/>.
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"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>",
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: <a href='{url}'>{body}</a>"
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"<li>{answer.text}</li>" 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} <choice number>",
formatted_body=f"<strong>Poll</strong>: {poll.question}<br/>\n"
f"<ol>{html_answers}</ol>\n"
f"Vote with <code>!tg vote {poll_id} &lt;choice number&gt;</code>",
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"<a href=\"https://matrix.to/#/{editing_msg.mx_room}/"
f"{editing_msg.mxid}\">Edit</a>: "
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
+120 -247
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,21 +13,24 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, 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 difflib import SequenceMatcher
from enum import Enum import unicodedata
from aiohttp import ServerDisconnectedError
import asyncio import asyncio
import logging import logging
import re
from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer, from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer,
InputPeerPhotoFileLocation, UserProfilePhotoEmpty) InputPeerPhotoFileLocation, UserProfilePhotoEmpty, TypeInputUser)
from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
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 .db import Puppet as DBPuppet
from . import util from . import util, portal as p
if TYPE_CHECKING: if TYPE_CHECKING:
from .matrix import MatrixHandler from .matrix import MatrixHandler
@@ -36,26 +38,47 @@ if TYPE_CHECKING:
from .context import Context from .context import Context
from .abstract_user import AbstractUser from .abstract_user import AbstractUser
PuppetError = Enum('PuppetError', 'Success OnlyLoginSelf InvalidAccessToken') config: Optional['Config'] = None
config = None # type: Config
class Puppet: class Puppet(CustomPuppetMixin):
log = logging.getLogger("mau.puppet") # type: logging.Logger log: logging.Logger = logging.getLogger("mau.puppet")
az = None # type: AppService az: AppService
mx = None # type: MatrixHandler mx: 'MatrixHandler'
loop = None # type: asyncio.AbstractEventLoop loop: asyncio.AbstractEventLoop
mxid_regex = None # type: Pattern hs_domain: str
username_template = None # type: str mxid_template: SimpleTemplate[TelegramID]
hs_domain = None # type: str displayname_template: SimpleTemplate[str]
cache = {} # type: Dict[TelegramID, Puppet]
by_custom_mxid = {} # type: Dict[str, Puppet] 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, def __init__(self,
id: TelegramID, id: TelegramID,
access_token: Optional[str] = None, access_token: Optional[str] = None,
custom_mxid: Optional[MatrixUserID] = None, custom_mxid: Optional[UserID] = None,
next_batch: Optional[SyncToken] = None,
username: Optional[str] = None, username: Optional[str] = None,
displayname: Optional[str] = None, displayname: Optional[str] = None,
displayname_source: Optional[TelegramID] = None, displayname_source: Optional[TelegramID] = None,
@@ -64,40 +87,47 @@ class Puppet:
is_registered: bool = False, is_registered: bool = False,
disable_updates: bool = False, disable_updates: bool = False,
db_instance: Optional[DBPuppet] = None) -> None: db_instance: Optional[DBPuppet] = None) -> None:
self.id = id # type: TelegramID self.id = id
self.access_token = access_token # type: Optional[str] self.access_token = access_token
self.custom_mxid = custom_mxid # type: Optional[MatrixUserID] self.custom_mxid = custom_mxid
self.default_mxid = self.get_mxid_from_id(self.id) # type: MatrixUserID self._next_batch = next_batch
self.default_mxid = self.get_mxid_from_id(self.id)
self.username = username # type: Optional[str] self.username = username
self.displayname = displayname # type: Optional[str] self.displayname = displayname
self.displayname_source = displayname_source # type: Optional[TelegramID] self.displayname_source = displayname_source
self.photo_id = photo_id # type: Optional[str] self.photo_id = photo_id
self.is_bot = is_bot # type: bool self.is_bot = is_bot
self.is_registered = is_registered # type: bool self.is_registered = is_registered
self.disable_updates = disable_updates # type: bool self.disable_updates = disable_updates
self._db_instance = db_instance # type: Optional[DBPuppet] self._db_instance = db_instance
self.default_mxid_intent = self.az.intent.user(self.default_mxid) self.default_mxid_intent = self.az.intent.user(self.default_mxid)
self.intent = self._fresh_intent() # type: IntentAPI self.intent = self._fresh_intent()
self.sync_task = None # type: Optional[asyncio.Future] self.sync_task = None
self.cache[id] = self self.cache[id] = self
if self.custom_mxid: if self.custom_mxid:
self.by_custom_mxid[self.custom_mxid] = self self.by_custom_mxid[self.custom_mxid] = self
@property self.log = self.log.getChild(str(self.id))
def mxid(self) -> MatrixUserID:
return self.custom_mxid or self.default_mxid
@property @property
def tgid(self) -> TelegramID: def tgid(self) -> TelegramID:
return self.id return self.id
@property @property
def is_real_user(self) -> bool: def peer(self) -> PeerUser:
""" Is True when the puppet is a real Matrix user. """ return PeerUser(user_id=self.tgid)
return bool(self.custom_mxid and self.access_token)
@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 @staticmethod
async def is_logged_in() -> bool: async def is_logged_in() -> bool:
@@ -106,175 +136,17 @@ class Puppet:
@property @property
def plain_displayname(self) -> str: def plain_displayname(self) -> str:
tpl = config["bridge.displayname_template"] return self.displayname_template.parse(self.displayname) or self.displayname
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
def get_input_entity(self, user: 'AbstractUser') -> Awaitable[TypeInputPeer]: def get_input_entity(self, user: 'AbstractUser'
return user.client.get_input_entity(PeerUser(user_id=self.tgid)) ) -> Awaitable[Union[TypeInputPeer, TypeInputUser]]:
return user.client.get_input_entity(self.peer)
# region Custom puppet management def intent_for(self, portal: 'p.Portal') -> IntentAPI:
def _fresh_intent(self) -> IntentAPI: if portal.tgid == self.tgid:
return (self.az.intent.user(self.custom_mxid, self.access_token) return self.default_mxid_intent
if self.is_real_user else 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 # region DB conversion
@property @property
@@ -283,26 +155,27 @@ class Puppet:
self._db_instance = self.new_db_instance() self._db_instance = self.new_db_instance()
return self._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: def new_db_instance(self) -> DBPuppet:
return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid, return DBPuppet(id=self.id, **self._fields)
username=self.username, displayname=self.displayname,
displayname_source=self.displayname_source, photo_id=self.photo_id, def save(self) -> None:
is_bot=self.is_bot, matrix_registered=self.is_registered, self.db_instance.edit(**self._fields)
disable_updates=self.disable_updates)
@classmethod @classmethod
def from_db(cls, db_puppet: DBPuppet) -> 'Puppet': def from_db(cls, db_puppet: DBPuppet) -> 'Puppet':
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid, 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.next_batch, db_puppet.username, db_puppet.displayname,
db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered, db_puppet.displayname_source, db_puppet.photo_id, db_puppet.is_bot,
db_puppet.disable_updates, db_instance=db_puppet) 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)
# endregion # endregion
# region Info updating # region Info updating
@@ -319,10 +192,10 @@ class Puppet:
def _filter_name(name: str) -> str: def _filter_name(name: str) -> str:
if not name: if not name:
return "" return ""
whitespace = ("\ufeff", "\u3164", "\u2063", "\u200b", "\u180e", "\u034f", "\u2800", whitespace = ("\t\n\r\v\f \u00a0\u034f\u180e\u2063\u202f\u205f\u2800\u3000\u3164\ufeff"
"\u180e", "\u200b", "\u202f", "\u205f", "\u3000") "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b"
name = "".join(char for char in name if char not in whitespace) "\u200c\u200d\u200e\u200f")
name = name.strip() name = "".join(c for c in name.strip(whitespace) if unicodedata.category(c) != 'Cf')
return name return name
@classmethod @classmethod
@@ -351,8 +224,7 @@ class Puppet:
if not enable_format: if not enable_format:
return name return name
return config["bridge.displayname_template"].format( return cls.displayname_template.format_full(name)
displayname=name)
async def update_info(self, source: 'AbstractUser', info: User) -> None: async def update_info(self, source: 'AbstractUser', info: User) -> None:
if self.disable_updates: if self.disable_updates:
@@ -391,7 +263,8 @@ class Puppet:
self.displayname = displayname self.displayname = displayname
self.displayname_source = source.tgid self.displayname_source = source.tgid
try: 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: except MatrixRequestError:
self.log.exception("Failed to set displayname") self.log.exception("Failed to set displayname")
self.displayname = "" self.displayname = ""
@@ -415,7 +288,7 @@ class Puppet:
if not photo_id: if not photo_id:
self.photo_id = "" self.photo_id = ""
try: try:
await self.default_mxid_intent.set_avatar("") await self.default_mxid_intent.set_avatar_url("")
except MatrixRequestError: except MatrixRequestError:
self.log.exception("Failed to set avatar") self.log.exception("Failed to set avatar")
self.photo_id = "" self.photo_id = ""
@@ -431,7 +304,7 @@ class Puppet:
if file: if file:
self.photo_id = photo_id self.photo_id = photo_id
try: try:
await self.default_mxid_intent.set_avatar(file.mxc) await self.default_mxid_intent.set_avatar_url(file.mxc)
except MatrixRequestError: except MatrixRequestError:
self.log.exception("Failed to set avatar") self.log.exception("Failed to set avatar")
self.photo_id = "" self.photo_id = ""
@@ -460,7 +333,7 @@ class Puppet:
return None return None
@classmethod @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) tgid = cls.get_id_from_mxid(mxid)
if tgid: if tgid:
return cls.get(tgid, create) return cls.get(tgid, create)
@@ -468,7 +341,7 @@ class Puppet:
return None return None
@classmethod @classmethod
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']: def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
if not mxid: if not mxid:
raise ValueError("Matrix ID can't be empty") raise ValueError("Matrix ID can't be empty")
@@ -492,15 +365,12 @@ class Puppet:
for puppet in DBPuppet.all_with_custom_mxid()) for puppet in DBPuppet.all_with_custom_mxid())
@classmethod @classmethod
def get_id_from_mxid(cls, mxid: MatrixUserID) -> Optional[TelegramID]: def get_id_from_mxid(cls, mxid: UserID) -> Optional[TelegramID]:
match = cls.mxid_regex.match(mxid) return cls.mxid_template.parse(mxid)
if match:
return TelegramID(int(match.group(1)))
return None
@classmethod @classmethod
def get_mxid_from_id(cls, tgid: TelegramID) -> MatrixUserID: def get_mxid_from_id(cls, tgid: TelegramID) -> UserID:
return MatrixUserID(f"@{cls.username_template.format(userid=tgid)}:{cls.hs_domain}") return UserID(cls.mxid_template.format_full(tgid))
@classmethod @classmethod
def find_by_username(cls, username: str) -> Optional['Puppet']: def find_by_username(cls, username: str) -> Optional['Puppet']:
@@ -534,12 +404,15 @@ class Puppet:
# endregion # endregion
def init(context: 'Context') -> List[Awaitable[Any]]: # [None, None, PuppetError] def init(context: 'Context') -> Iterable[Awaitable[Any]]:
global config global config
Puppet.az, config, Puppet.loop, _ = context.core Puppet.az, config, Puppet.loop, _ = context.core
Puppet.mx = context.mx Puppet.mx = context.mx
Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}")
Puppet.hs_domain = config["homeserver"]["domain"] Puppet.hs_domain = config["homeserver"]["domain"]
Puppet.mxid_regex = re.compile(
f"@{Puppet.username_template.format(userid='([0-9]+)')}:{Puppet.hs_domain}") Puppet.mxid_template = SimpleTemplate(config["bridge.username_template"], "userid",
return [puppet.init_custom_mxid() for puppet in Puppet.all_with_custom_mxid()] 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())
@@ -1,7 +1,9 @@
from typing import Union
import argparse import argparse
import sqlalchemy as sql
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
import sqlalchemy as sql
from alchemysession import AlchemySessionContainer from alchemysession import AlchemySessionContainer
@@ -22,16 +24,19 @@ def log(message, end="\n"):
def connect(to): def connect(to):
import mautrix_telegram.db.base as base from mautrix.bridge.db import Base, RoomState, UserProfile
base.Base = declarative_base(cls=base.BaseBase) from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat,
from mautrix_telegram.db import (Portal, Message, UserPortal, User, RoomState, UserProfile, TelegramFile)
Contact, Puppet, BotChat, TelegramFile)
db_engine = sql.create_engine(to) db_engine = sql.create_engine(to)
db_factory = orm.sessionmaker(bind=db_engine) db_factory = orm.sessionmaker(bind=db_engine)
db_session = orm.scoped_session(db_factory) # type: orm.Session db_session: Union[orm.Session, orm.scoped_session] = orm.scoped_session(db_factory)
base.Base.metadata.bind = db_engine Base.metadata.bind = db_engine
new_base = declarative_base()
new_base.metadata.bind = db_engine
session_container = AlchemySessionContainer(engine=db_engine, session=db_session, 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) manage_tables=False)
return db_session, { return db_session, {
@@ -52,6 +57,7 @@ def connect(to):
"TelegramFile": TelegramFile, "TelegramFile": TelegramFile,
} }
log("Connecting to old database") log("Connecting to old database")
session, tables = connect(args.from_url) session, tables = connect(args.from_url)
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -15,11 +14,14 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict from typing import Dict
from sqlalchemy import orm
import sqlalchemy as sql
import argparse 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 mautrix_telegram.config import Config
from .models import ChatLink, TgUser, MatrixUser, Message as TMMessage, Base as TelematrixBase 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 = Config(args.config, None, None)
config.load() config.load()
mxtg_db_engine = sql.create_engine( mxtg_db_engine = sql.create_engine(config["appservice.database"])
config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
mxtg = orm.sessionmaker(bind=mxtg_db_engine)() mxtg = orm.sessionmaker(bind=mxtg_db_engine)()
Base.metadata.bind = mxtg_db_engine Base.metadata.bind = mxtg_db_engine
@@ -55,18 +56,18 @@ tm_messages = telematrix.query(TMMessage).all()
telematrix.close() telematrix.close()
telematrix_db_engine.dispose() telematrix_db_engine.dispose()
portals_by_tgid = {} # type: Dict[int, Portal] portals_by_tgid: Dict[int, Portal] = {}
portals_by_mxid = {} # type: Dict[str, Portal] portals_by_mxid: Dict[str, Portal] = {}
chats = {} # type: Dict[int, BotChat] chats: Dict[int, BotChat] = {}
messages = {} # type: Dict[str, Message] messages: Dict[str, Message] = {}
puppets = {} # type: Dict[int, Puppet] puppets: Dict[int, Puppet] = {}
for chat_link in chat_links: for chat_link in chat_links:
if type(chat_link.tg_room) is str: 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 continue
if chat_link.tg_room >= 0: 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 continue
tgid = str(chat_link.tg_room) tgid = str(chat_link.tg_room)
if tgid.startswith("-100"): if tgid.startswith("-100"):
+15 -96
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,106 +13,26 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Tuple from 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 . import puppet as pu
from .db import RoomState, UserProfile
class SQLStateStore(StateStore): class SQLStateStore(BaseSQLStateStore):
def __init__(self) -> None: def is_registered(self, user_id: UserID) -> bool:
super().__init__() puppet = pu.Puppet.get_by_mxid(user_id, create=False)
self.profile_cache = {} # type: Dict[Tuple[str, str], UserProfile] if puppet:
self.room_state_cache = {} # type: Dict[str, RoomState] 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 registered(self, user_id: UserID) -> None:
def is_registered(user: MatrixUserID) -> bool: puppet = pu.Puppet.get_by_mxid(user_id, create=True)
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)
if puppet: if puppet:
puppet.is_registered = True puppet.is_registered = True
puppet.save() puppet.save()
else:
def update_state(self, event: Dict) -> None: super().registered(user_id)
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()
+7 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -18,18 +17,21 @@ from typing import List, Union, Optional
from telethon import TelegramClient, utils from telethon import TelegramClient, utils
from telethon.tl.functions.messages import SendMediaRequest from telethon.tl.functions.messages import SendMediaRequest
from telethon.tl.types import ( from telethon.tl.types import (InputMediaUploadedDocument, InputMediaUploadedPhoto,
InputMediaUploadedDocument, InputMediaUploadedPhoto, TypeDocumentAttribute, TypeInputMedia, TypeDocumentAttribute, TypeInputMedia, TypeInputPeer,
TypeInputPeer, TypeMessageEntity, TypeMessageMedia, TypePeer) TypeMessageEntity, TypeMessageMedia, TypePeer)
from telethon.tl.patched import Message from telethon.tl.patched import Message
from telethon.sessions.abstract import Session
class MautrixTelegramClient(TelegramClient): class MautrixTelegramClient(TelegramClient):
session: Session
async def upload_file_direct(self, file: bytes, mime_type: str = None, async def upload_file_direct(self, file: bytes, mime_type: str = None,
attributes: List[TypeDocumentAttribute] = None, attributes: List[TypeDocumentAttribute] = None,
file_name: str = None, max_image_size: float = 10 * 1000 ** 2, file_name: str = None, max_image_size: float = 10 * 1000 ** 2,
) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]: ) -> 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: if (mime_type == "image/png" or mime_type == "image/jpeg") and len(file) < max_image_size:
return InputMediaUploadedPhoto(file_handle) return InputMediaUploadedPhoto(file_handle)
+1 -7
View File
@@ -1,9 +1,3 @@
from typing import Dict, NewType from typing import NewType
MatrixUserID = NewType('MatrixUserID', str)
MatrixRoomID = NewType('MatrixRoomID', str)
MatrixEventID = NewType('MatrixEventID', str)
MatrixEvent = NewType('MatrixEvent', Dict)
TelegramID = NewType('TelegramID', int) TelegramID = NewType('TelegramID', int)
+68 -39
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,20 +13,22 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Dict, List, Iterable, Match, NewType, Optional, Tuple, TYPE_CHECKING from typing import (Awaitable, Dict, List, Iterable, NewType, Optional, Tuple, Any, cast,
TYPE_CHECKING)
import logging import logging
import asyncio import asyncio
import re
from telethon.tl.types import ( from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser, UpdateShortChatMessage, UpdateShortMessage, User as TLUser, Chat)
UpdateShortChatMessage, UpdateShortMessage, User as TLUser)
from telethon.tl.types.contacts import ContactsNotModified from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
from telethon.tl.functions.account import UpdateStatusRequest 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 .db import User as DBUser
from .abstract_user import AbstractUser from .abstract_user import AbstractUser
from . import portal as po, puppet as pu from . import portal as po, puppet as pu
@@ -36,36 +37,46 @@ if TYPE_CHECKING:
from .config import Config from .config import Config
from .context import Context from .context import Context
config = None # type: Config config: Optional['Config'] = None
SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int]) SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int])
class User(AbstractUser): class User(AbstractUser):
log = logging.getLogger("mau.user") # type: logging.Logger log: logging.Logger = logging.getLogger("mau.user")
by_mxid = {} # type: Dict[str, User] by_mxid: Dict[str, 'User'] = {}
by_tgid = {} # type: Dict[int, 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, username: Optional[str] = None, phone: Optional[str] = None,
db_contacts: Optional[Iterable[TelegramID]] = None, db_contacts: Optional[Iterable[TelegramID]] = None,
saved_contacts: int = 0, is_bot: bool = False, saved_contacts: int = 0, is_bot: bool = False,
db_portals: Optional[Iterable[Tuple[TelegramID, TelegramID]]] = None, db_portals: Optional[Iterable[Tuple[TelegramID, TelegramID]]] = None,
db_instance: Optional[DBUser] = None) -> None: db_instance: Optional[DBUser] = None) -> None:
super().__init__() super().__init__()
self.mxid = mxid # type: MatrixUserID self.mxid = mxid
self.tgid = tgid # type: TelegramID self.tgid = tgid
self.is_bot = is_bot # type: bool self.is_bot = is_bot
self.username = username # type: str self.username = username
self.phone = phone # type: str self.phone = phone
self.contacts = [] # type: List[pu.Puppet] self.contacts = []
self.saved_contacts = saved_contacts # type: int self.saved_contacts = saved_contacts
self.db_contacts = db_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_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.relaybot_whitelisted,
self.whitelisted, self.whitelisted,
@@ -78,14 +89,16 @@ class User(AbstractUser):
if tgid: if tgid:
self.by_tgid[tgid] = self self.by_tgid[tgid] = self
self.log = self.log.getChild(self.mxid)
@property @property
def name(self) -> str: def name(self) -> str:
return self.mxid return self.mxid
@property @property
def mxid_localpart(self) -> str: def mxid_localpart(self) -> str:
match = re.compile("@(.+):(.+)").match(self.mxid) # type: Match localpart, server = Client.parse_user_id(self.mxid)
return match.group(1) return localpart
@property @property
def human_tg_id(self) -> str: def human_tg_id(self) -> str:
@@ -136,8 +149,8 @@ class User(AbstractUser):
saved_contacts=self.saved_contacts, portals=self.db_portals) saved_contacts=self.saved_contacts, portals=self.db_portals)
def save(self, contacts: bool = False, portals: bool = False) -> None: 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, self.db_instance.edit(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
saved_contacts=self.saved_contacts) saved_contacts=self.saved_contacts)
if contacts: if contacts:
self.db_instance.contacts = self.db_contacts self.db_instance.contacts = self.db_contacts
if portals: if portals:
@@ -161,8 +174,11 @@ class User(AbstractUser):
# endregion # endregion
# region Telegram connection management # region Telegram connection management
def ensure_started(self, even_if_no_session=False) -> Awaitable['User']: async def ensure_started(self, even_if_no_session=False) -> 'User':
return super().ensure_started(even_if_no_session) 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': async def start(self, delete_unless_authenticated: bool = False) -> 'User':
await super().start() await super().start()
@@ -229,7 +245,7 @@ class User(AbstractUser):
self.phone = info.phone self.phone = info.phone
changed = True changed = True
if self.tgid != info.id: if self.tgid != info.id:
self.tgid = info.id self.tgid = TelegramID(info.id)
self.by_tgid[self.tgid] = self self.by_tgid[self.tgid] = self
if changed: if changed:
self.save() self.save()
@@ -242,7 +258,8 @@ class User(AbstractUser):
if not portal or portal.deleted or not portal.mxid or portal.has_bot: if not portal or portal.deleted or not portal.mxid or portal.has_bot:
continue continue
try: 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: except MatrixRequestError:
pass pass
self.portals = {} self.portals = {}
@@ -263,7 +280,7 @@ class User(AbstractUser):
def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45 def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
) -> List[SearchResult]: ) -> List[SearchResult]:
results = [] # type: List[SearchResult] results: List[SearchResult] = []
for contact in self.contacts: for contact in self.contacts:
similarity = contact.similarity(query) similarity = contact.similarity(query)
if similarity >= min_similarity: if similarity >= min_similarity:
@@ -275,7 +292,7 @@ class User(AbstractUser):
if len(query) < 5: if len(query) < 5:
return [] return []
server_results = await self.client(SearchRequest(q=query, limit=max_results)) server_results = await self.client(SearchRequest(q=query, limit=max_results))
results = [] # type: List[SearchResult] results: List[SearchResult] = []
for user in server_results.users: for user in server_results.users:
puppet = pu.Puppet.get(user.id) puppet = pu.Puppet.get(user.id)
await puppet.update_info(self, user) await puppet.update_info(self, user)
@@ -295,8 +312,19 @@ class User(AbstractUser):
return await self._search_remote(query), True return await self._search_remote(query), True
async def sync_dialogs(self, synchronous_create: bool = False) -> None: async def sync_dialogs(self, synchronous_create: bool = False) -> None:
if self.is_bot:
return
creators = [] 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) portal = po.Portal.get_by_entity(entity)
self.portals[portal.tgid_full] = portal self.portals[portal.tgid_full] = portal
creators.append( creators.append(
@@ -304,6 +332,7 @@ class User(AbstractUser):
synchronous=synchronous_create)) synchronous=synchronous_create))
self.save(portals=True) self.save(portals=True)
await asyncio.gather(*creators, loop=self.loop) await asyncio.gather(*creators, loop=self.loop)
self.log.debug("Dialog syncing complete")
def register_portal(self, portal: po.Portal) -> None: def register_portal(self, portal: po.Portal) -> None:
try: try:
@@ -323,7 +352,7 @@ class User(AbstractUser):
async def needs_relaybot(self, portal: po.Portal) -> bool: async def needs_relaybot(self, portal: po.Portal) -> bool:
return not await self.is_logged_in() or ( 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: def _hash_contacts(self) -> int:
acc = 0 acc = 0
@@ -348,7 +377,7 @@ class User(AbstractUser):
# region Class instance lookup # region Class instance lookup
@classmethod @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: if not mxid:
raise ValueError("Matrix ID can't be empty") raise ValueError("Matrix ID can't be empty")
@@ -400,9 +429,9 @@ class User(AbstractUser):
# endregion # endregion
def init(context: 'Context') -> List[Awaitable['User']]: def init(context: 'Context') -> Iterable[Awaitable['User']]:
global config global config
config = context.config config = context.config
users = [User.from_db(user) for user in DBUser.all()] return (User.from_db(db_user).ensure_started()
return [user.ensure_started() for user in users if user.tgid] for db_user in DBUser.all_with_tgid())
+1 -4
View File
@@ -1,7 +1,4 @@
from .file_transfer import transfer_file_to_matrix, convert_image from .file_transfer import transfer_file_to_matrix, convert_image
from .format_duration import format_duration from .format_duration import format_duration
from .signed_token import sign_token, verify_token
from .recursive_dict import recursive_del, recursive_set, recursive_get from .recursive_dict import recursive_del, recursive_set, recursive_get
from .color_log import ColorFormatter
def ignore_coro(coro):
pass
+29
View File
@@ -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 <https://www.gnu.org/licenses/>.
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)
+11 -8
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -28,7 +27,8 @@ from telethon.tl.types import (Document, InputFileLocation, InputDocumentFileLoc
InputPeerPhotoFileLocation) InputPeerPhotoFileLocation)
from telethon.errors import (AuthBytesInvalidError, AuthKeyInvalidError, LocationInvalidError, from telethon.errors import (AuthBytesInvalidError, AuthKeyInvalidError, LocationInvalidError,
SecurityError, FileIdInvalidError) SecurityError, FileIdInvalidError)
from mautrix_appservice import IntentAPI
from mautrix.appservice import IntentAPI
from ..tgclient import MautrixTelegramClient from ..tgclient import MautrixTelegramClient
from ..db import TelegramFile as DBTelegramFile from ..db import TelegramFile as DBTelegramFile
@@ -38,6 +38,7 @@ try:
from PIL import Image from PIL import Image
except ImportError: except ImportError:
Image = None Image = None
try: try:
from moviepy.editor import VideoFileClip from moviepy.editor import VideoFileClip
import random import random
@@ -47,7 +48,7 @@ try:
except ImportError: except ImportError:
VideoFileClip = random = string = os = mimetypes = None 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, TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation,
InputFileLocation, InputPhotoFileLocation] InputFileLocation, InputPhotoFileLocation]
@@ -59,7 +60,7 @@ def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str
if not Image: if not Image:
return source_mime, file, None, None return source_mime, file, None, None
try: try:
image = Image.open(BytesIO(file)).convert("RGBA") # type: Image.Image image: Image.Image = Image.open(BytesIO(file)).convert("RGBA")
if thumbnail_to: if thumbnail_to:
image.thumbnail(thumbnail_to, Image.ANTIALIAS) image.thumbnail(thumbnail_to, Image.ANTIALIAS)
new_file = BytesIO() 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: def _location_to_id(location: TypeLocation) -> str:
if isinstance(location, (Document, InputDocumentFileLocation, InputPhotoFileLocation)): if isinstance(location, Document):
return f"{location.id}-{location.access_hash}" 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)): elif isinstance(location, (InputFileLocation, InputPeerPhotoFileLocation)):
return f"{location.volume_id}-{location.local_id}" 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 width, height = None, None
mime_type = magic.from_buffer(file, mime=True) 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, db_file = DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
was_converted=False, timestamp=int(time.time()), size=len(file), 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 return db_file
transfer_locks = {} # type: Dict[str, asyncio.Lock] transfer_locks: Dict[str, asyncio.Lock] = {}
TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]] TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]]
@@ -202,7 +205,7 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
mime_type = new_mime_type mime_type = new_mime_type
thumbnail = None 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, db_file = DBTelegramFile(id=loc_id, mxc=content_uri,
mime_type=mime_type, was_converted=image_converted, mime_type=mime_type, was_converted=image_converted,
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
+5 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -15,11 +14,12 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Any 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: 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 next_key is not None:
if key not in data: if key not in data:
data[key] = {} 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: 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: if next_key is not None:
next_data = data.get(key, None) next_data = data.get(key, None)
if not next_data: 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: 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 next_key is not None:
if key not in data: if key not in data:
return False return False
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
-53
View File
@@ -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 <https://www.gnu.org/licenses/>.
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
+15 -14
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,27 +13,30 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from abc import abstractmethod
from typing import Optional from typing import Optional
from abc import abstractmethod
from aiohttp import web
import abc import abc
import asyncio import asyncio
import logging import logging
from aiohttp import web
from telethon.errors import * from telethon.errors import *
from mautrix.bridge import OnlyLoginSelf, InvalidAccessToken
from ...commands.telegram.auth import enter_password from ...commands.telegram.auth import enter_password
from ...util import format_duration, ignore_coro from ...util import format_duration
from ...puppet import Puppet, PuppetError from ...puppet import Puppet
from ...user import User from ...user import User
class AuthAPI(abc.ABC): 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): def __init__(self, loop: asyncio.AbstractEventLoop):
self.loop = loop # type: asyncio.AbstractEventLoop self.loop = loop
@abstractmethod @abstractmethod
def get_login_response(self, status: int = 200, state: str = "", username: str = "", 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 " error="You have already logged in with your Matrix "
"account.", errcode="already-logged-in") "account.", errcode="already-logged-in")
resp = await puppet.switch_mxid(token.strip(), user.mxid) try:
if resp == PuppetError.OnlyLoginSelf: await puppet.switch_mxid(token.strip(), user.mxid)
except OnlyLoginSelf:
return self.get_mx_login_response(status=403, errcode="only-login-self", return self.get_mx_login_response(status=403, errcode="only-login-self",
error="You can only log in as your own Matrix user.") 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", return self.get_mx_login_response(status=401, errcode="invalid-access-token",
error="Failed to verify 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") 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: 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) existing_user = User.get_by_tgid(user_info.id)
if existing_user and existing_user != user: if existing_user and existing_user != user:
await existing_user.log_out() await existing_user.log_out()
ignore_coro(asyncio.ensure_future(user.post_login(user_info), loop=self.loop)) asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
if user.command_status and user.command_status["action"] == "Login": if user.command_status and user.command_status["action"] == "Login":
user.command_status = None user.command_status = None
+32 -37
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -14,20 +13,23 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from aiohttp import web
from typing import Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKING from typing import Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKING
import asyncio import asyncio
import logging import logging
import json import json
from aiohttp import web
from telethon.utils import get_peer_id, resolve_id from telethon.utils import get_peer_id, resolve_id
from telethon.tl.types import ChatForbidden, ChannelForbidden, TypeChat 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 ...user import User
from ...portal import Portal from ...portal import Portal
from ...util import ignore_coro
from ...commands.portal.util import user_has_power_level, get_initial_state from ...commands.portal.util import user_has_power_level, get_initial_state
from ..common import AuthAPI from ..common import AuthAPI
@@ -36,16 +38,19 @@ if TYPE_CHECKING:
class ProvisioningAPI(AuthAPI): 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: def __init__(self, context: "Context") -> None:
super().__init__(context.loop) super().__init__(context.loop)
self.secret = context.config["appservice.provisioning.shared_secret"] # type: str self.secret = context.config["appservice.provisioning.shared_secret"]
self.az = context.az # type: AppService self.az = context.az
self.context = context # type: Context self.context = context
self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware] self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware])
) # type: web.Application
portal_prefix = "/portal/{mxid:![^/]+}" portal_prefix = "/portal/{mxid:![^/]+}"
self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_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: if not portal:
return self.get_error_response(404, "portal_not_found", return self.get_error_response(404, "portal_not_found",
"Portal with given Matrix ID not found.") "Portal with given Matrix ID not found.")
user, _ = await self.get_user(request.query.get("user_id", None), expect_logged_in=None, return await self._get_portal_response(UserID(request.query.get("user_id", "")), portal)
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,
})
async def get_portal_by_tgid(self, request: web.Request) -> web.Response: async def get_portal_by_tgid(self, request: web.Request) -> web.Response:
err = self.check_authorization(request) err = self.check_authorization(request)
@@ -104,8 +98,10 @@ class ProvisioningAPI(AuthAPI):
if not portal: if not portal:
return self.get_error_response(404, "portal_not_found", return self.get_error_response(404, "portal_not_found",
"Portal to given Telegram chat not found.") "Portal to given Telegram chat not found.")
user, _ = await self.get_user(request.query.get("user_id", None), expect_logged_in=None, return await self._get_portal_response(UserID(request.query.get("user_id", "")), portal)
require_puppeting=False)
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({ return web.json_response({
"mxid": portal.mxid, "mxid": portal.mxid,
"chat_id": get_peer_id(portal.peer), "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", return self.get_login_response(status=403, errcode="not_logged_in",
error="You are not logged in and there is no relay bot.") error="You are not logged in and there is no relay bot.")
entity = None # type: Optional[TypeChat] entity: Optional[TypeChat] = None
try: try:
entity = await acting_user.client.get_entity(portal.peer) entity = await acting_user.client.get_entity(portal.peer)
except Exception: except Exception:
@@ -191,9 +187,8 @@ class ProvisioningAPI(AuthAPI):
portal.photo_id = "" portal.photo_id = ""
portal.save() portal.save()
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
levels=levels), loop=self.loop)
loop=self.loop))
return web.Response(status=202, body="{}") return web.Response(status=202, body="{}")
@@ -272,7 +267,8 @@ class ProvisioningAPI(AuthAPI):
require_puppeting=False, require_user=False) require_puppeting=False, require_user=False)
if err is not None: if err is not None:
return err return err
elif user and not await user_has_power_level(portal.mxid, self.az.intent, user, "unbridge"): elif user and not await user_has_power_level(portal.mxid, self.az.intent, user,
"unbridge"):
return self.get_error_response(403, "not_enough_permissions", return self.get_error_response(403, "not_enough_permissions",
"You do not have the permissions to unbridge that room.") "You do not have the permissions to unbridge that room.")
@@ -287,7 +283,7 @@ class ProvisioningAPI(AuthAPI):
self.log.exception("Failed to disconnect chat") self.log.exception("Failed to disconnect chat")
return self.get_error_response(500, "exception", "Failed to disconnect chat") return self.get_error_response(500, "exception", "Failed to disconnect chat")
else: else:
ignore_coro(asyncio.ensure_future(coro, loop=self.loop)) asyncio.ensure_future(coro, loop=self.loop)
return web.json_response({}, status=200 if sync else 202) return web.json_response({}, status=200 if sync else 202)
async def get_user_info(self, request: web.Request) -> web.Response: async def get_user_info(self, request: web.Request) -> web.Response:
@@ -320,11 +316,10 @@ class ProvisioningAPI(AuthAPI):
return err return err
if not user.is_bot: if not user.is_bot:
chats = await user.get_dialogs()
return web.json_response([{ return web.json_response([{
"id": get_peer_id(chat), "id": get_peer_id(chat),
"title": chat.title, "title": chat.title,
} for chat in chats]) } async for chat in user.client.get_dialogs(ignore_migrated=True, archived=False)])
else: else:
return web.json_response([{ return web.json_response([{
"id": get_peer_id(chat.peer), "id": get_peer_id(chat.peer),
@@ -365,7 +360,8 @@ class ProvisioningAPI(AuthAPI):
async def bridge_info(self, request: web.Request) -> web.Response: async def bridge_info(self, request: web.Request) -> web.Response:
return web.json_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) }, status=200)
@staticmethod @staticmethod
@@ -431,7 +427,7 @@ class ProvisioningAPI(AuthAPI):
except json.JSONDecodeError: except json.JSONDecodeError:
return None 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 require_puppeting: bool = True, require_user: bool = True
) -> Tuple[Optional[User], Optional[web.Response]]: ) -> Tuple[Optional[User], Optional[web.Response]]:
if not mxid: if not mxid:
@@ -460,8 +456,7 @@ class ProvisioningAPI(AuthAPI):
expect_logged_in: Optional[bool] = False, expect_logged_in: Optional[bool] = False,
require_puppeting: bool = False, require_puppeting: bool = False,
want_data: bool = True, want_data: bool = True,
) -> (Tuple[Optional[Dict], ) -> (Tuple[Optional[Dict], Optional[User],
Optional[User],
Optional[web.Response]]): Optional[web.Response]]):
err = self.check_authorization(request) err = self.check_authorization(request)
if err is not None: if err is not None:
+18 -14
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2019 Tulir Asokan
# #
@@ -15,37 +14,42 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional from typing import Optional
from aiohttp import web
from mako.template import Template
import pkg_resources
import asyncio import asyncio
import logging import logging
import random import random
import string import string
import time import time
from ...types import MatrixUserID from mako.template import Template
from ...util import sign_token, verify_token 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 ...user import User
from ...puppet import Puppet from ...puppet import Puppet
from ..common import AuthAPI from ..common import AuthAPI
class PublicBridgeWebsite(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): def __init__(self, loop: asyncio.AbstractEventLoop):
super().__init__(loop) super().__init__(loop)
self.secret_key = "".join( self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
random.choice(string.ascii_lowercase + string.digits) for _ in range(64)) # type: str
self.login = Template(pkg_resources.resource_string( 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( 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("GET", "/login", self.get_login)
self.app.router.add_route("POST", "/login", self.post_login) self.app.router.add_route("POST", "/login", self.post_login)
self.app.router.add_route("GET", "/matrix-login", self.get_matrix_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, "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) token = verify_token(self.secret_key, token)
if token and (token.get("expiry", 0) > int(time.time()) and if token and (token.get("expiry", 0) > int(time.time()) and
token.get("endpoint", None) == endpoint): token.get("endpoint", None) == endpoint):
return MatrixUserID(token.get("mxid", None)) return UserID(token.get("mxid", None))
return None return None
async def get_login(self, request: web.Request) -> web.Response: async def get_login(self, request: web.Request) -> web.Response:
+2 -1
View File
@@ -1,4 +1,5 @@
cryptg cryptg
Pillow Pillow
moviepy moviepy
prometheus-client prometheus_client
psycopg2-binary
+3 -3
View File
@@ -1,10 +1,10 @@
aiohttp aiohttp
mautrix-appservice mautrix
ruamel.yaml ruamel.yaml
python-magic python-magic
SQLAlchemy SQLAlchemy
alembic alembic
commonmark commonmark
future-fstrings #telethon
telethon git+https://github.com/LonamiWebs/Telethon@master#egg=telethon
telethon-session-sqlalchemy telethon-session-sqlalchemy
+5 -4
View File
@@ -6,7 +6,8 @@ extras = {
"fast_crypto": ["cryptg>=0.1,<0.3"], "fast_crypto": ["cryptg>=0.1,<0.3"],
"webp_convert": ["Pillow>=4.3.0,<7"], "webp_convert": ["Pillow>=4.3.0,<7"],
"hq_thumbnails": ["moviepy>=1.0,<2.0"], "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}) extras["all"] = list({dep for deps in extras.values() for dep in deps})
@@ -31,17 +32,17 @@ setuptools.setup(
install_requires=[ install_requires=[
"aiohttp>=3.0.1,<4", "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", "SQLAlchemy>=1.2.3,<2",
"alembic>=1.0.0,<2", "alembic>=1.0.0,<2",
"commonmark>=0.8.1,<1", "commonmark>=0.8.1,<1",
"ruamel.yaml>=0.15.35,<0.16", "ruamel.yaml>=0.15.35,<0.16",
"future-fstrings>=0.4.2",
"python-magic>=0.4.15,<0.5", "python-magic>=0.4.15,<0.5",
"telethon>=1.9,<1.10", "telethon>=1.9,<1.10",
"telethon-session-sqlalchemy>=0.2.14,<0.3", "telethon-session-sqlalchemy>=0.2.14,<0.3",
], ],
extras_require=extras, extras_require=extras,
python_requires="~=3.6",
setup_requires=["pytest-runner"], setup_requires=["pytest-runner"],
tests_require=["pytest", "pytest-asyncio", "pytest-mock"], tests_require=["pytest", "pytest-asyncio", "pytest-mock"],
@@ -53,8 +54,8 @@ setuptools.setup(
"Framework :: AsyncIO", "Framework :: AsyncIO",
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
], ],
entry_points=""" entry_points="""
[console_scripts] [console_scripts]
+51 -48
View File
@@ -5,12 +5,14 @@ import pytest
from _pytest.fixtures import FixtureRequest from _pytest.fixtures import FixtureRequest
from pytest_mock import MockFixture from pytest_mock import MockFixture
from mautrix.types import EventID, RoomID, UserID
import mautrix.bridge.commands.handler
import mautrix_telegram.commands.handler import mautrix_telegram.commands.handler
from mautrix_telegram.commands.handler import (CommandEvent, CommandHandler, CommandProcessor, from mautrix_telegram.commands.handler import (CommandEvent, CommandHandler, CommandProcessor,
HelpSection) HelpSection, HelpCacheKey)
from mautrix_telegram.config import Config from mautrix_telegram.config import Config
from mautrix_telegram.context import Context from mautrix_telegram.context import Context
from mautrix_telegram.types import MatrixEventID, MatrixRoomID, MatrixUserID
import mautrix_telegram.user as u import mautrix_telegram.user as u
from tests.utils.helpers import AsyncMock, list_true_once_each from tests.utils.helpers import AsyncMock, list_true_once_each
@@ -45,9 +47,9 @@ class TestCommandEvent:
evt = CommandEvent( evt = CommandEvent(
processor=command_processor, processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"), room_id=RoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"), event_id=EventID("$H45H:example.org"),
sender=u.User(MatrixUserID("@sender:example.org")), sender=u.User(UserID("@sender:example.org")),
command="help", command="help",
args=[], args=[],
is_management=True, is_management=True,
@@ -61,7 +63,7 @@ class TestCommandEvent:
# html, no markdown # html, no markdown
evt.reply(message, allow_html=True, render_markdown=False) evt.reply(message, allow_html=True, render_markdown=False)
mock_az.intent.send_notice.assert_called_with( mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"), RoomID("#mock_room:example.org"),
"**This** <i>was</i><br/><strong>all</strong>fun*!", "**This** <i>was</i><br/><strong>all</strong>fun*!",
html="**This** <i>was</i><br/><strong>all</strong>fun*!\n", html="**This** <i>was</i><br/><strong>all</strong>fun*!\n",
) )
@@ -69,7 +71,7 @@ class TestCommandEvent:
# html, markdown (default) # html, markdown (default)
evt.reply(message, allow_html=True, render_markdown=True) evt.reply(message, allow_html=True, render_markdown=True)
mock_az.intent.send_notice.assert_called_with( mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"), RoomID("#mock_room:example.org"),
"**This** <i>was</i><br/><strong>all</strong>fun*!", "**This** <i>was</i><br/><strong>all</strong>fun*!",
html=( html=(
"<p><strong>This</strong> <i>was</i><br/>" "<p><strong>This</strong> <i>was</i><br/>"
@@ -80,7 +82,7 @@ class TestCommandEvent:
# no html, no markdown # no html, no markdown
evt.reply(message, allow_html=False, render_markdown=False) evt.reply(message, allow_html=False, render_markdown=False)
mock_az.intent.send_notice.assert_called_with( mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"), RoomID("#mock_room:example.org"),
"**This** <i>was</i><br/><strong>all</strong>fun*!", "**This** <i>was</i><br/><strong>all</strong>fun*!",
html=None, html=None,
) )
@@ -88,7 +90,7 @@ class TestCommandEvent:
# no html, markdown # no html, markdown
evt.reply(message, allow_html=False, render_markdown=True) evt.reply(message, allow_html=False, render_markdown=True)
mock_az.intent.send_notice.assert_called_with( mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"), RoomID("#mock_room:example.org"),
"**This** <i>was</i><br/><strong>all</strong>fun*!", "**This** <i>was</i><br/><strong>all</strong>fun*!",
html="<p><strong>This</strong> &lt;i&gt;was&lt;/i&gt;&lt;br/&gt;" html="<p><strong>This</strong> &lt;i&gt;was&lt;/i&gt;&lt;br/&gt;"
"&lt;strong&gt;all&lt;/strong&gt;fun*!</p>\n" "&lt;strong&gt;all&lt;/strong&gt;fun*!</p>\n"
@@ -100,9 +102,9 @@ class TestCommandEvent:
evt = CommandEvent( evt = CommandEvent(
processor=command_processor, processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"), room_id=RoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"), event_id=EventID("$H45H:example.org"),
sender=u.User(MatrixUserID("@sender:example.org")), sender=u.User(UserID("@sender:example.org")),
command="help", command="help",
args=[], args=[],
is_management=False, is_management=False,
@@ -115,7 +117,7 @@ class TestCommandEvent:
render_markdown=False) render_markdown=False)
mock_az.intent.send_notice.assert_called_with( mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"), RoomID("#mock_room:example.org"),
"tg ....tg+sp...tg tg", "tg ....tg+sp...tg tg",
html=None, html=None,
) )
@@ -126,9 +128,9 @@ class TestCommandEvent:
evt = CommandEvent( evt = CommandEvent(
processor=command_processor, processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"), room_id=RoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"), event_id=EventID("$H45H:example.org"),
sender=u.User(MatrixUserID("@sender:example.org")), sender=u.User(UserID("@sender:example.org")),
command="help", command="help",
args=[], args=[],
is_management=True, is_management=True,
@@ -144,7 +146,7 @@ class TestCommandEvent:
) )
mock_az.intent.send_notice.assert_called_with( mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"), RoomID("#mock_room:example.org"),
"....tg+sp...tg tg", "....tg+sp...tg tg",
html="<p>....tg+sp...tg tg</p>\n", html="<p>....tg+sp...tg tg</p>\n",
) )
@@ -195,15 +197,15 @@ class TestCommandHandler:
help_section=HelpSection("Mock Section", 42, ""), 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.puppet_whitelisted = False
sender.matrix_puppet_whitelisted = False sender.matrix_puppet_whitelisted = False
sender.is_admin = False sender.is_admin = False
event = CommandEvent( event = CommandEvent(
processor=command_processor, processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"), room_id=RoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"), event_id=EventID("$H45H:example.org"),
sender=sender, sender=sender,
command=command, command=command,
args=[], args=[],
@@ -212,7 +214,8 @@ class TestCommandHandler:
) )
assert await command_handler.get_permission_error(event) 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( @pytest.mark.parametrize(
( (
@@ -255,7 +258,7 @@ class TestCommandHandler:
help_section=HelpSection("Mock Section", 42, ""), 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.puppet_whitelisted = puppet_whitelisted
sender.matrix_puppet_whitelisted = matrix_puppet_whitelisted sender.matrix_puppet_whitelisted = matrix_puppet_whitelisted
sender.is_admin = is_admin sender.is_admin = is_admin
@@ -263,8 +266,8 @@ class TestCommandHandler:
event = CommandEvent( event = CommandEvent(
processor=command_processor, processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"), room_id=RoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"), event_id=EventID("$H45H:example.org"),
sender=sender, sender=sender,
command=command, command=command,
args=[], args=[],
@@ -274,12 +277,12 @@ class TestCommandHandler:
assert not await command_handler.get_permission_error(event) assert not await command_handler.get_permission_error(event)
assert command_handler.has_permission( assert command_handler.has_permission(
is_management=is_management, HelpCacheKey(is_management=is_management,
puppet_whitelisted=puppet_whitelisted, puppet_whitelisted=puppet_whitelisted,
matrix_puppet_whitelisted=matrix_puppet_whitelisted, matrix_puppet_whitelisted=matrix_puppet_whitelisted,
is_admin=is_admin, is_admin=is_admin,
is_logged_in=is_logged_in, is_logged_in=is_logged_in,
) is_portal=boolean))
class TestCommandProcessor: class TestCommandProcessor:
@@ -292,41 +295,41 @@ class TestCommandProcessor:
mocker: MockFixture) -> None: mocker: MockFixture) -> None:
mocker.patch('mautrix_telegram.user.config', self.config) mocker.patch('mautrix_telegram.user.config', self.config)
mocker.patch( mocker.patch(
'mautrix_telegram.commands.handler.command_handlers', 'mautrix.bridge.commands.handler.command_handlers',
{"help": AsyncMock(), "unknown-command": AsyncMock()} {"help": AsyncMock(), "unknown-command": AsyncMock()}
) )
sender = u.User(MatrixUserID("@sender:example.org")) sender = u.User(UserID("@sender:example.org"))
result = await command_processor.handle( result = await command_processor.handle(
room=MatrixRoomID("#mock_room:example.org"), room_id=RoomID("#mock_room:example.org"),
event_id=MatrixEventID("$H45H:example.org"), event_id=EventID("$H45H:example.org"),
sender=sender, sender=sender,
command="hElp", command="hElp",
args=[], args=[],
is_management=boolean2[0], is_management=boolean2[0],
is_portal=boolean2[1], is_portal=boolean2[1])
)
assert result is None 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 command_handlers["help"].mock.assert_called_once() # type: ignore
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_unknown_command(self, command_processor: CommandProcessor, 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.user.config', self.config)
mocker.patch( mocker.patch(
'mautrix_telegram.commands.handler.command_handlers', 'mautrix.bridge.commands.handler.command_handlers',
{"help": AsyncMock(), "unknown-command": AsyncMock()} {"help": AsyncMock(), "unknown-command": AsyncMock()}
) )
sender = u.User(MatrixUserID("@sender:example.org")) sender = u.User(UserID("@sender:example.org"))
sender.command_status = {} sender.command_status = {}
result = await command_processor.handle( result = await command_processor.handle(
room=MatrixRoomID("#mock_room:example.org"), room_id=RoomID("#mock_room:example.org"),
event_id=MatrixEventID("$H45H:example.org"), event_id=EventID("$H45H:example.org"),
sender=sender, sender=sender,
command="foo", command="foo",
args=[], args=[],
@@ -335,7 +338,7 @@ class TestCommandProcessor:
) )
assert result is None 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["help"].mock.assert_not_called() # type: ignore
command_handlers["unknown-command"].mock.assert_called_once() # type: ignore command_handlers["unknown-command"].mock.assert_called_once() # type: ignore
@@ -345,16 +348,16 @@ class TestCommandProcessor:
mocker: MockFixture) -> None: mocker: MockFixture) -> None:
mocker.patch('mautrix_telegram.user.config', self.config) mocker.patch('mautrix_telegram.user.config', self.config)
mocker.patch( mocker.patch(
'mautrix_telegram.commands.handler.command_handlers', 'mautrix.bridge.commands.handler.command_handlers',
{"help": AsyncMock(), "unknown-command": AsyncMock()} {"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()} sender.command_status = {"foo": AsyncMock(), "next": AsyncMock()}
result = await command_processor.handle( result = await command_processor.handle(
room=MatrixRoomID("#mock_room:example.org"), room_id=RoomID("#mock_room:example.org"),
event_id=MatrixEventID("$H45H:example.org"), event_id=EventID("$H45H:example.org"),
sender=sender, # u.User sender=sender, # u.User
command="foo", command="foo",
args=[], args=[],
@@ -363,7 +366,7 @@ class TestCommandProcessor:
) )
assert result is None 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["help"].mock.assert_not_called() # type: ignore
command_handlers["unknown-command"].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 sender.command_status["foo"].mock.assert_not_called() # type: ignore