Switch from SQLAlchemy to asyncpg/aiosqlite

This commit is contained in:
Tulir Asokan
2021-12-20 22:39:09 +02:00
parent f12f3fe007
commit 89ab29ea5f
61 changed files with 4681 additions and 4628 deletions
-5
View File
@@ -8,10 +8,6 @@ RUN apk add --no-cache \
py3-pillow \
py3-aiohttp \
py3-magic \
py3-sqlalchemy \
py3-telethon-session-sqlalchemy \
py3-alembic \
py3-psycopg2 \
py3-ruamel.yaml \
py3-commonmark \
py3-prometheus-client \
@@ -53,7 +49,6 @@ RUN apk add --virtual .build-deps \
python3-dev \
libffi-dev \
build-base \
&& sed -Ei 's/psycopg2-binary.+//' optional-requirements.txt \
&& pip3 install -r requirements.txt -r optional-requirements.txt \
&& apk del .build-deps
-4
View File
@@ -8,9 +8,7 @@ from os.path import abspath, dirname
sys.path.insert(0, dirname(dirname(abspath(__file__))))
from mautrix.util.db import Base
import mautrix_telegram.db
from mautrix_telegram.config import Config
from alchemysession import AlchemySessionContainer
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -21,8 +19,6 @@ mxtg_config = Config(mxtg_config_path, None, None)
mxtg_config.load()
config.set_main_option("sqlalchemy.url", mxtg_config["appservice.database"].replace("%", "%%"))
AlchemySessionContainer.create_table_classes(None, "telethon_", Base)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
+46 -67
View File
@@ -13,39 +13,27 @@
#
# 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, Any
import sys
from __future__ import annotations
from typing import Any
from telethon import __version__ as __telethon_version__
from alchemysession import AlchemySessionContainer
from mautrix.types import UserID, RoomID
from mautrix.bridge import Bridge
from mautrix.util.db import Base
from mautrix.bridge.state_store.sqlalchemy import SQLBridgeStateStore
from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite
from .abstract_user import init as init_abstract_user
from .bot import Bot, init as init_bot
from .abstract_user import AbstractUser
from .bot import Bot
from .config import Config
from .context import Context
from .db import init as init_db
from .formatter import init as init_formatter
from .db import init as init_db, upgrade_table
from .matrix import MatrixHandler
from .portal import Portal, init as init_portal
from .puppet import Puppet, init as init_puppet
from .user import User, init as init_user
from .portal import Portal
from .puppet import Puppet
from .user import User
from .version import version, linkified_version
import sqlalchemy as sql
from sqlalchemy.engine.base import Engine
try:
import prometheus_client as prometheus
except ImportError:
prometheus = None
class TelegramBridge(Bridge):
module = "mautrix_telegram"
@@ -57,55 +45,46 @@ class TelegramBridge(Bridge):
markdown_version = linkified_version
config_class = Config
matrix_class = MatrixHandler
state_store_class = SQLBridgeStateStore
upgrade_table = upgrade_table
db: 'Engine'
config: Config
session_container: AlchemySessionContainer
bot: Bot
bot: Bot | None
public_website: PublicBridgeWebsite | None
provisioning_api: ProvisioningAPI | None
def prepare_db(self) -> None:
if not sql:
raise RuntimeError("SQLAlchemy is not installed")
self.db = sql.create_engine(self.config["appservice.database"],
**self.config["appservice.database_opts"])
Base.metadata.bind = self.db
if not self.db.has_table("alembic_version"):
self.log.critical("alembic_version table not found. "
"Did you forget to `alembic upgrade head`?")
sys.exit(10)
super().prepare_db()
init_db(self.db)
self.session_container = AlchemySessionContainer(
engine=self.db, table_base=Base, session=False,
table_prefix="telethon_", manage_tables=False)
def make_state_store(self) -> None:
self.state_store = self.state_store_class(self.get_puppet, self.get_double_puppet)
def _prepare_website(self, context: Context) -> None:
def _prepare_website(self) -> None:
if self.config["appservice.public.enabled"]:
public_website = PublicBridgeWebsite(self.loop)
self.az.app.add_subapp(self.config["appservice.public.prefix"], public_website.app)
context.public_website = public_website
self.public_website = PublicBridgeWebsite(self.loop)
self.az.app.add_subapp(
self.config["appservice.public.prefix"], self.public_website.app
)
else:
self.public_website = None
if self.config["appservice.provisioning.enabled"]:
provisioning_api = ProvisioningAPI(context)
self.az.app.add_subapp(self.config["appservice.provisioning.prefix"],
provisioning_api.app)
context.provisioning_api = provisioning_api
self.provisioning_api = ProvisioningAPI(self)
self.az.app.add_subapp(
self.config["appservice.provisioning.prefix"], self.provisioning_api.app
)
else:
self.provisioning_api = None
def prepare_bridge(self) -> None:
self.bot = init_bot(self.config)
context = Context(self.az, self.config, self.loop, self.session_container, self, self.bot)
self._prepare_website(context)
self.matrix = context.mx = MatrixHandler(context)
init_abstract_user(context)
init_formatter(context)
init_portal(context)
self.add_startup_actions(init_puppet(context))
self.add_startup_actions(init_user(context))
self._prepare_website()
AbstractUser.init_cls(self)
bot_token: str = self.config["telegram.bot_token"]
if bot_token and not bot_token.lower().startswith("disable"):
self.bot = AbstractUser.relaybot = Bot(bot_token)
else:
self.bot = AbstractUser.relaybot = None
self.matrix = MatrixHandler(self)
Portal.init_cls(self)
self.add_startup_actions(Puppet.init_cls(self))
self.add_startup_actions(User.init_cls(self))
if self.bot:
self.add_startup_actions(self.bot.start())
if self.config["bridge.resend_bridge_info"]:
@@ -115,7 +94,7 @@ class TelegramBridge(Bridge):
self.config["bridge.resend_bridge_info"] = False
self.config.save()
self.log.info("Re-sending bridge info state event to all portals")
for portal in Portal.all():
async for portal in Portal.all():
await portal.update_bridge_info()
self.log.info("Finished re-sending bridge info state events")
@@ -124,19 +103,19 @@ class TelegramBridge(Bridge):
puppet.stop()
self.shutdown_actions = (user.stop() for user in User.by_tgid.values())
async def get_user(self, user_id: UserID, create: bool = True) -> User:
user = User.get_by_mxid(user_id, create=create)
async def get_user(self, user_id: UserID, create: bool = True) -> User | None:
user = await User.get_by_mxid(user_id, create=create)
if user:
await user.ensure_started()
return user
async def get_portal(self, room_id: RoomID) -> Portal:
return Portal.get_by_mxid(room_id)
async def get_portal(self, room_id: RoomID) -> Portal | None:
return await Portal.get_by_mxid(room_id)
async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet:
async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet | None:
return await Puppet.get_by_mxid(user_id, create=create)
async def get_double_puppet(self, user_id: UserID) -> Puppet:
async def get_double_puppet(self, user_id: UserID) -> Puppet | None:
return await Puppet.get_by_custom_mxid(user_id)
def is_bridge_ghost(self, user_id: UserID) -> bool:
@@ -145,7 +124,7 @@ class TelegramBridge(Bridge):
async def count_logged_in_users(self) -> int:
return len([user for user in User.by_tgid.values() if user.tgid])
async def manhole_global_namespace(self, user_id: UserID) -> Dict[str, Any]:
async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]:
return {
**await super().manhole_global_namespace(user_id),
"User": User,
+124 -107
View File
@@ -13,7 +13,9 @@
#
# 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 Tuple, Optional, Union, Dict, Type, Any, TYPE_CHECKING
from __future__ import annotations
from typing import Type, Any, Union, TYPE_CHECKING
from abc import ABC, abstractmethod
import platform
import asyncio
@@ -39,48 +41,50 @@ from mautrix.errors import MatrixError
from mautrix.appservice import AppService
from mautrix.util.logging import TraceLogger
from mautrix.util.opt_prometheus import Histogram, Counter
from alchemysession import AlchemySessionContainer
from . import portal as po, puppet as pu, __version__
from .db import Message as DBMessage
from .db import Message as DBMessage, PgSession
from .types import TelegramID
from .tgclient import MautrixTelegramClient
from .config import Config
if TYPE_CHECKING:
from .context import Context
from .config import Config
from .bot import Bot
from .__main__ import TelegramBridge
config: Optional['Config'] = None
# Value updated from config in init()
MAX_DELETIONS: int = 10
UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
UpdateMessageContent = Union[UpdateShortMessage, UpdateShortChatMessage, Message, MessageService]
UpdateTyping = Union[UpdateUserTyping, UpdateChatUserTyping, UpdateChannelUserTyping]
UpdateMessageContent = Union[
UpdateShortMessage, UpdateShortChatMessage, Message, MessageService, MessageEmpty
]
UPDATE_TIME = Histogram("bridge_telegram_update", "Time spent processing Telegram updates",
("update_type",))
UPDATE_ERRORS = Counter("bridge_telegram_update_error",
"Number of fatal errors while handling Telegram updates", ("update_type",))
UPDATE_TIME = Histogram(
name="bridge_telegram_update",
documentation="Time spent processing Telegram updates",
labelnames=("update_type",),
)
UPDATE_ERRORS = Counter(
name="bridge_telegram_update_error",
documentation="Number of fatal errors while handling Telegram updates",
labelnames=("update_type",),
)
class AbstractUser(ABC):
session_container: AlchemySessionContainer = None
loop: asyncio.AbstractEventLoop = None
log: TraceLogger
az: AppService
bridge: 'TelegramBridge'
relaybot: Optional['Bot']
config: Config
relaybot: 'Bot'
ignore_incoming_bot_events: bool = True
max_deletions: int = 10
client: Optional[MautrixTelegramClient]
mxid: Optional[UserID]
client: MautrixTelegramClient | None
mxid: UserID | None
tgid: Optional[TelegramID]
username: Optional['str']
tgid: TelegramID | None
username: str | None
is_bot: bool
is_relaybot: bool
@@ -106,14 +110,14 @@ class AbstractUser(ABC):
return self.client and self.client.is_connected()
@property
def _proxy_settings(self) -> Tuple[Type[Connection], Optional[Tuple[Any, ...]]]:
proxy_type = config["telegram.proxy.type"].lower()
def _proxy_settings(self) -> tuple[Type[Connection], tuple[Any, ...] | None]:
proxy_type = self.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"])
connection_data = (self.config["telegram.proxy.address"],
self.config["telegram.proxy.port"],
self.config["telegram.proxy.rdns"],
self.config["telegram.proxy.username"],
self.config["telegram.proxy.password"])
if proxy_type == "disabled":
connection_data = None
elif proxy_type == "socks4":
@@ -128,23 +132,32 @@ class AbstractUser(ABC):
return connection, connection_data
def _init_client(self) -> None:
@classmethod
def init_cls(cls, bridge: 'TelegramBridge') -> None:
cls.bridge = bridge
cls.config = bridge.config
cls.loop = bridge.loop
cls.az = bridge.az
cls.ignore_incoming_bot_events = cls.config["bridge.relaybot.ignore_own_incoming_events"]
cls.max_deletions = cls.config["bridge.max_telegram_delete"]
async def _init_client(self) -> None:
self.log.debug(f"Initializing client for {self.name}")
session = self.session_container.new_session(self.name)
if config["telegram.server.enabled"]:
session.set_dc(config["telegram.server.dc"],
config["telegram.server.ip"],
config["telegram.server.port"])
session = await PgSession.get(self.name)
if self.config["telegram.server.enabled"]:
session.set_dc(self.config["telegram.server.dc"],
self.config["telegram.server.ip"],
self.config["telegram.server.port"])
if self.is_relaybot:
base_logger = logging.getLogger("telethon.relaybot")
else:
base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}")
device = config["telegram.device_info.device_model"]
sysversion = config["telegram.device_info.system_version"]
appversion = config["telegram.device_info.app_version"]
device = self.config["telegram.device_info.device_model"]
sysversion = self.config["telegram.device_info.system_version"]
appversion = self.config["telegram.device_info.app_version"]
connection, proxy = self._proxy_settings
assert isinstance(session, Session)
@@ -152,8 +165,8 @@ class AbstractUser(ABC):
self.client = MautrixTelegramClient(
session=session,
api_id=config["telegram.api_id"],
api_hash=config["telegram.api_hash"],
api_id=self.config["telegram.api_id"],
api_hash=self.config["telegram.api_hash"],
app_version=__version__ if appversion == "auto" else appversion,
system_version=(MautrixTelegramClient.__version__
@@ -161,11 +174,11 @@ class AbstractUser(ABC):
device_model=(f"{platform.system()} {platform.release()}"
if device == "auto" else device),
timeout=config["telegram.connection.timeout"],
connection_retries=config["telegram.connection.retries"],
retry_delay=config["telegram.connection.retry_delay"],
flood_sleep_threshold=config["telegram.connection.flood_sleep_threshold"],
request_retries=config["telegram.connection.request_retries"],
timeout=self.config["telegram.connection.timeout"],
connection_retries=self.config["telegram.connection.retries"],
retry_delay=self.config["telegram.connection.retry_delay"],
flood_sleep_threshold=self.config["telegram.connection.flood_sleep_threshold"],
request_retries=self.config["telegram.connection.request_retries"],
connection=connection,
proxy=proxy,
raise_last_call_error=True,
@@ -216,17 +229,17 @@ class AbstractUser(ABC):
and (not self.is_bot or allow_bot)
and await self.is_logged_in())
async def start(self, delete_unless_authenticated: bool = False) -> 'AbstractUser':
async def start(self, delete_unless_authenticated: bool = False) -> AbstractUser:
if not self.client:
self._init_client()
await self._init_client()
await self.client.connect()
self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {self.connected}")
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 self.connected:
return self
if even_if_no_session or self.session_container.has_session(self.mxid):
if even_if_no_session or await PgSession.has(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)
@@ -281,19 +294,20 @@ class AbstractUser(ABC):
async def update_notify_settings(self, update: UpdateNotifySettings) -> None:
pass
async def update_pinned_messages(self, update: Union[UpdatePinnedMessages,
UpdatePinnedChannelMessages]) -> None:
async def update_pinned_messages(
self, update: UpdatePinnedMessages | UpdatePinnedChannelMessages
) -> None:
if isinstance(update, UpdatePinnedMessages):
portal = po.Portal.get_by_entity(update.peer, receiver_id=self.tgid)
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
else:
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
if portal and portal.mxid:
await portal.receive_telegram_pin_ids(update.messages, self.tgid,
remove=not update.pinned)
@staticmethod
async def update_participants(update: UpdateChatParticipants) -> None:
portal = po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
if portal and portal.mxid:
await portal.update_power_levels(update.participants.participants)
@@ -302,30 +316,36 @@ class AbstractUser(ABC):
self.log.debug("Unexpected read receipt peer: %s", update.peer)
return
portal = po.Portal.get_by_tgid(TelegramID(update.peer.user_id), self.tgid)
portal = await po.Portal.get_by_tgid(
TelegramID(update.peer.user_id), tg_receiver=self.tgid
)
if not portal or not portal.mxid:
return
# We check that these are user read receipts, so tg_space is always the user ID.
message = DBMessage.get_one_by_tgid(TelegramID(update.max_id), self.tgid, edit_index=-1)
message = await DBMessage.get_one_by_tgid(TelegramID(update.max_id), self.tgid,
edit_index=-1)
if not message:
return
puppet = pu.Puppet.get(TelegramID(update.peer.user_id))
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.peer.user_id))
await puppet.intent.mark_read(portal.mxid, message.mxid)
async def update_own_read_receipt(self, update: Union[UpdateReadHistoryInbox,
UpdateReadChannelInbox]) -> None:
puppet = pu.Puppet.get(self.tgid)
async def update_own_read_receipt(
self, update: UpdateReadHistoryInbox | UpdateReadChannelInbox
) -> None:
puppet = await pu.Puppet.get_by_tgid(self.tgid)
if not puppet.is_real_user:
return
if isinstance(update, UpdateReadChannelInbox):
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
elif isinstance(update.peer, PeerChat):
portal = po.Portal.get_by_tgid(TelegramID(update.peer.chat_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.peer.chat_id))
elif isinstance(update.peer, PeerUser):
portal = po.Portal.get_by_tgid(TelegramID(update.peer.user_id), self.tgid)
portal = await po.Portal.get_by_tgid(
TelegramID(update.peer.user_id), tg_receiver=self.tgid
)
else:
self.log.debug("Unexpected own read receipt peer: %s", update.peer)
return
@@ -334,7 +354,8 @@ class AbstractUser(ABC):
return
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
message = DBMessage.get_one_by_tgid(TelegramID(update.max_id), tg_space, edit_index=-1)
message = await DBMessage.get_one_by_tgid(TelegramID(update.max_id), tg_space,
edit_index=-1)
if not message:
return
@@ -342,21 +363,25 @@ class AbstractUser(ABC):
async def update_admin(self, update: UpdateChatParticipantAdmin) -> None:
# TODO duplication not checked
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
if not portal or not portal.mxid:
return
await portal.set_telegram_admin(TelegramID(update.user_id))
async def update_typing(self, update: UpdateTyping) -> None:
async def update_typing(
self, update: UpdateUserTyping | UpdateChatUserTyping | UpdateChannelUserTyping
) -> None:
sender = None
if isinstance(update, UpdateUserTyping):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
sender = pu.Puppet.get(TelegramID(update.user_id))
portal = await po.Portal.get_by_tgid(
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
)
sender = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
elif isinstance(update, UpdateChannelUserTyping):
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
elif isinstance(update, UpdateChatUserTyping):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
else:
return
@@ -364,26 +389,25 @@ class AbstractUser(ABC):
# Can typing notifications come from non-user peers?
if not update.from_id.user_id:
return
sender = pu.Puppet.get(TelegramID(update.from_id.user_id))
sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id.user_id))
if not sender or not portal or not portal.mxid:
return
await portal.handle_telegram_typing(sender, update)
async def _handle_entity_updates(self, entities: Dict[int, Union[User, Chat, Channel]]
) -> None:
async def _handle_entity_updates(self, entities: dict[int, User | Chat | Channel]) -> None:
try:
users = (entity for entity in entities.values() if isinstance(entity, User))
puppets = ((pu.Puppet.get(TelegramID(user.id)), user) for user in users)
puppets = ((await pu.Puppet.get_by_tgid(TelegramID(user.id)), user) for user in users)
await asyncio.gather(*[puppet.try_update_info(self, info)
for puppet, info in puppets if puppet])
async for puppet, info in puppets if puppet])
except Exception:
self.log.exception("Failed to handle entity updates")
async def update_others_info(self, update: Union[UpdateUserName, UpdateUserPhoto]) -> None:
async def update_others_info(self, update: UpdateUserName | UpdateUserPhoto) -> None:
# TODO duplication not checked
puppet = pu.Puppet.get(TelegramID(update.user_id))
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
if isinstance(update, UpdateUserName):
puppet.username = update.username
if await puppet.update_displayname(self, update):
@@ -395,7 +419,7 @@ class AbstractUser(ABC):
self.log.warning(f"Unexpected other user info update: {type(update)}")
async def update_status(self, update: UpdateUserStatus) -> None:
puppet = pu.Puppet.get(TelegramID(update.user_id))
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
if isinstance(update.status, UserStatusOnline):
await puppet.default_mxid_intent.set_presence(PresenceState.ONLINE)
elif isinstance(update.status, UserStatusOffline):
@@ -404,27 +428,29 @@ class AbstractUser(ABC):
self.log.warning(f"Unexpected user status update: type({update})")
return
def get_message_details(self, update: UpdateMessage) -> Tuple[UpdateMessageContent,
Optional[pu.Puppet],
Optional[po.Portal]]:
async def get_message_details(
self, update: UpdateMessage
) -> tuple[UpdateMessageContent, pu.Puppet | None, po.Portal | None]:
if isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
if not portal:
self.log.warning(f"Received message in chat with unknown type {update.chat_id}")
sender = pu.Puppet.get(TelegramID(update.from_id))
sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id))
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
portal = await po.Portal.get_by_tgid(
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
)
sender = await pu.Puppet.get_by_tgid(self.tgid if update.out else update.user_id)
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
UpdateEditMessage, UpdateEditChannelMessage)):
update = update.message
if isinstance(update, MessageEmpty):
return update, None, None
portal = po.Portal.get_by_entity(update.peer_id, receiver_id=self.tgid)
portal = await po.Portal.get_by_entity(update.peer_id, tg_receiver=self.tgid)
if update.out:
sender = pu.Puppet.get(self.tgid)
sender = await pu.Puppet.get_by_tgid(self.tgid)
elif isinstance(update.from_id, PeerUser):
sender = pu.Puppet.get(TelegramID(update.from_id.user_id))
sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id.user_id))
else:
sender = None
else:
@@ -435,7 +461,7 @@ class AbstractUser(ABC):
@staticmethod
async def _try_redact(message: DBMessage) -> None:
portal = po.Portal.get_by_mxid(message.mx_room)
portal = await po.Portal.get_by_mxid(message.mx_room)
if not portal:
return
try:
@@ -444,33 +470,33 @@ class AbstractUser(ABC):
pass
async def delete_message(self, update: UpdateDeleteMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
if len(update.messages) > self.max_deletions:
return
for message_id in update.messages:
for message in DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
if message.redacted:
continue
message.delete()
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
await message.delete()
number_left = await DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
if number_left == 0:
await self._try_redact(message)
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
if len(update.messages) > self.max_deletions:
return
channel_id = TelegramID(update.channel_id)
for message_id in update.messages:
for message in DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id):
for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id):
if message.redacted:
continue
message.delete()
await message.delete()
await self._try_redact(message)
async def update_message(self, original_update: UpdateMessage) -> None:
update, sender, portal = self.get_message_details(original_update)
update, sender, portal = await self.get_message_details(original_update)
if not portal:
return
elif portal and not portal.allow_bridging:
@@ -479,10 +505,10 @@ class AbstractUser(ABC):
if self.is_relaybot:
if update.is_private:
if not config["bridge.relaybot.private_chat.invite"]:
if not self.config["bridge.relaybot.private_chat.invite"]:
self.log.debug(f"Ignoring private message to bot from {sender.id}")
return
elif not portal.mxid and config["bridge.relaybot.ignore_unbridged_group_chat"]:
elif not portal.mxid and self.config["bridge.relaybot.ignore_unbridged_group_chat"]:
self.log.debug("Ignoring message received by bot"
f" in unbridged chat {portal.tgid_log}")
return
@@ -492,7 +518,7 @@ class AbstractUser(ABC):
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
return
await portal.backfill_lock.wait(update.id)
await portal.backfill_lock.wait(f"update {update.id}")
if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom):
@@ -510,12 +536,3 @@ class AbstractUser(ABC):
return await portal.handle_telegram_message(self, sender, update)
# endregion
def init(context: 'Context') -> None:
global config, MAX_DELETIONS
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
AbstractUser.bridge = context.bridge
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
AbstractUser.session_container = context.session_container
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
+29 -41
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -33,10 +33,6 @@ from .db import BotChat
from .types import TelegramID
from . import puppet as pu, portal as po, user as u
if TYPE_CHECKING:
from .config import Config
config: Optional['Config'] = None
ReplyFunc = Callable[[str], Awaitable[Message]]
@@ -59,12 +55,12 @@ class Bot(AbstractUser):
self.puppet_whitelisted = True
self.whitelisted = True
self.relaybot_whitelisted = True
self.username = None
self.tg_username = None
self.is_relaybot = True
self.is_bot = True
self.chats = {}
self.tg_whitelist = []
self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"]
self.whitelist_group_admins = (self.config["bridge.relaybot.whitelist_group_admins"]
or False)
self._me_info = None
self._me_mxid = None
@@ -76,7 +72,7 @@ class Bot(AbstractUser):
return self._me_info, self._me_mxid
async def init_permissions(self) -> None:
whitelist = config["bridge.relaybot.whitelist"] or []
whitelist = self.config["bridge.relaybot.whitelist"] or []
for user_id in whitelist:
if isinstance(user_id, str):
entity = await self.client.get_input_entity(user_id)
@@ -88,7 +84,7 @@ class Bot(AbstractUser):
self.tg_whitelist.append(user_id)
async def start(self, delete_unless_authenticated: bool = False) -> 'Bot':
self.chats = {chat.id: chat.type for chat in BotChat.all()}
self.chats = {chat.id: chat.type for chat in await BotChat.all()}
await super().start(delete_unless_authenticated)
if not await self.is_logged_in():
await self.client.sign_in(bot_token=self.token)
@@ -99,14 +95,14 @@ class Bot(AbstractUser):
await self.init_permissions()
info = await self.client.get_me()
self.tgid = TelegramID(info.id)
self.username = info.username
self.tg_username = info.username
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
chat_ids = [chat_id for chat_id, chat_type in self.chats.items() if chat_type == "chat"]
response = await self.client(GetChatsRequest(chat_ids))
for chat in response.chats:
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
self.remove_chat(TelegramID(chat.id))
await self.remove_chat(TelegramID(chat.id))
channel_ids = [InputChannel(chat_id, 0)
for chat_id, chat_type in self.chats.items()
@@ -115,31 +111,31 @@ class Bot(AbstractUser):
try:
await self.client(GetChannelsRequest([channel_id]))
except (ChannelPrivateError, ChannelInvalidError):
self.remove_chat(TelegramID(channel_id.channel_id))
await self.remove_chat(TelegramID(channel_id.channel_id))
async def register_portal(self, portal: po.Portal) -> None:
self.add_chat(portal.tgid, portal.peer_type)
await self.add_chat(portal.tgid, portal.peer_type)
async def unregister_portal(self, tgid: int, tg_receiver: int) -> None:
self.remove_chat(tgid)
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
await self.remove_chat(tgid)
def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
async def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
if chat_id not in self.chats:
self.chats[chat_id] = chat_type
BotChat(id=TelegramID(chat_id), type=chat_type).insert()
await BotChat(id=chat_id, type=chat_type).insert()
def remove_chat(self, chat_id: TelegramID) -> None:
async def remove_chat(self, chat_id: TelegramID) -> None:
try:
del self.chats[chat_id]
except KeyError:
pass
BotChat.delete_by_id(chat_id)
await BotChat.delete_by_id(chat_id)
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
if tgid in self.tg_whitelist:
return True
user = u.User.get_by_tgid(tgid)
user = await u.User.get_by_tgid(tgid)
if user and user.is_admin:
self.tg_whitelist.append(user.tgid)
return True
@@ -157,13 +153,14 @@ class Bot(AbstractUser):
return False
async def check_can_use_commands(self, event: Message, reply: ReplyFunc) -> bool:
# FIXME event.from_id is not int
if not await self._can_use_commands(event.to_id, TelegramID(event.from_id)):
await reply("You do not have the permission to use that command.")
return False
return True
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> Message:
if not config["bridge.relaybot.authless_portals"]:
if not self.config["bridge.relaybot.authless_portals"]:
return await reply("This bridge doesn't allow portal creation from Telegram.")
if not portal.allow_bridging:
@@ -187,11 +184,11 @@ class Bot(AbstractUser):
"Create one with /portal first.")
if mxid_input[0] != '@' or mxid_input.find(':') < 2:
return await reply("That doesn't look like a Matrix ID.")
user = await u.User.get_by_mxid(mxid_input).ensure_started()
user = await u.User.get_and_start_by_mxid(mxid_input)
if not user.relaybot_whitelisted:
return await reply("That user is not whitelisted to use the bridge.")
elif await user.is_logged_in():
displayname = f"@{user.username}" if user.username else user.displayname
displayname = f"@{user.tg_username}" if user.tg_username else user.displayname
return await reply("That user seems to be logged in. "
f"Just invite [{displayname}](tg://user?id={user.tgid})")
else:
@@ -214,7 +211,7 @@ class Bot(AbstractUser):
def match_command(self, text: str, command: str) -> bool:
text = text.lower()
command = f"/{command.lower()}"
command_targeted = f"{command}@{self.username.lower()}"
command_targeted = f"{command}@{self.tg_username.lower()}"
is_plain_command = text == command or text == command_targeted
if is_plain_command:
@@ -233,7 +230,7 @@ class Bot(AbstractUser):
text = message.message
if self.match_command(text, "start"):
pcm = config["bridge.relaybot.private_chat.message"]
pcm = self.config["bridge.relaybot.private_chat.message"]
if pcm:
await reply(pcm)
return
@@ -243,7 +240,7 @@ class Bot(AbstractUser):
elif message.is_private:
return
portal = po.Portal.get_by_entity(message.to_id)
portal = await po.Portal.get_by_entity(message.to_id)
is_portal_cmd = self.match_command(text, "portal")
is_invite_cmd = self.match_command(text, "invite")
@@ -259,7 +256,7 @@ class Bot(AbstractUser):
mxid = ""
await self.handle_command_invite(portal, reply, mxid_input=UserID(mxid))
def handle_service_message(self, message: MessageService) -> None:
async def handle_service_message(self, message: MessageService) -> None:
to_peer = message.to_id
if isinstance(to_peer, PeerChannel):
to_id = TelegramID(to_peer.channel_id)
@@ -272,18 +269,18 @@ class Bot(AbstractUser):
action = message.action
if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users:
self.add_chat(to_id, chat_type)
await self.add_chat(to_id, chat_type)
elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid:
self.remove_chat(to_id)
await self.remove_chat(to_id)
elif isinstance(action, MessageActionChatMigrateTo):
self.remove_chat(to_id)
self.add_chat(TelegramID(action.channel_id), "channel")
await self.remove_chat(to_id)
await self.add_chat(TelegramID(action.channel_id), "channel")
async def update(self, update) -> bool:
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
return False
if isinstance(update.message, MessageService):
self.handle_service_message(update.message)
await self.handle_service_message(update.message)
return False
is_command = (isinstance(update.message, Message)
@@ -300,12 +297,3 @@ class Bot(AbstractUser):
@property
def name(self) -> str:
return "bot"
def init(cfg: 'Config') -> Optional[Bot]:
global config
config = cfg
token = config["telegram.bot_token"]
if token and not token.lower().startswith("disable"):
return Bot(token)
return None
+17 -13
View File
@@ -13,8 +13,9 @@
#
# 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/>.
"""This module contains classes handling commands issued by Matrix users."""
from typing import Awaitable, Callable, List, Optional, NamedTuple, Any
from __future__ import annotations
from typing import Awaitable, Callable, NamedTuple, Any, TYPE_CHECKING
from telethon.errors import FloodWaitError
@@ -25,7 +26,10 @@ from mautrix.bridge.commands import (HelpSection, CommandEvent as BaseCommandEve
CommandHandlerFunc, command_handler as base_command_handler)
from mautrix.util.format_duration import format_duration
from .. import user as u, context as c, portal as po
from .. import user as u, portal as po
if TYPE_CHECKING:
from ..__main__ import TelegramBridge
class HelpCacheKey(NamedTuple):
@@ -48,9 +52,9 @@ class CommandEvent(BaseCommandEvent):
sender: u.User
portal: po.Portal
def __init__(self, processor: 'CommandProcessor', room_id: RoomID, event_id: EventID,
sender: u.User, command: str, args: List[str], content: MessageEventContent,
portal: Optional['po.Portal'], is_management: bool, has_bridge_bot: bool) -> None:
def __init__(self, processor: CommandProcessor, room_id: RoomID, event_id: EventID,
sender: u.User, command: str, args: list[str], content: MessageEventContent,
portal: po.Portal | None, is_management: bool, has_bridge_bot: bool) -> None:
super().__init__(processor, room_id, event_id, sender, command, args, content,
portal, is_management, has_bridge_bot)
self.bridge = processor.bridge
@@ -83,7 +87,7 @@ class CommandHandler(BaseCommandHandler):
needs_matrix_puppeting=needs_matrix_puppeting, needs_admin=needs_admin,
**kwargs)
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
async def get_permission_error(self, evt: CommandEvent) -> str | None:
if self.needs_puppeting and not evt.sender.puppet_whitelisted:
return "This command requires puppeting privileges."
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
@@ -96,10 +100,10 @@ class CommandHandler(BaseCommandHandler):
(not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted))
def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: bool = True,
def command_handler(_func: CommandHandlerFunc | None = None, *, needs_auth: bool = True,
needs_puppeting: bool = True, needs_matrix_puppeting: bool = False,
needs_admin: bool = False, management_only: bool = False,
name: Optional[str] = None, help_text: str = "", help_args: str = "",
name: str | None = None, help_text: str = "", help_args: str = "",
help_section: HelpSection = None) -> Callable[[CommandHandlerFunc],
CommandHandler]:
return base_command_handler(
@@ -110,10 +114,10 @@ def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: b
class CommandProcessor(BaseCommandProcessor):
def __init__(self, context: c.Context) -> None:
super().__init__(event_class=CommandEvent, bridge=context.bridge)
self.tgbot = context.bot
self.public_website = context.public_website
def __init__(self, bridge: 'TelegramBridge') -> None:
super().__init__(event_class=CommandEvent, bridge=bridge)
self.tgbot = bridge.bot
self.public_website = bridge.public_website
@staticmethod
async def _run_handler(handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent
+5 -5
View File
@@ -24,7 +24,7 @@ from .. import puppet as pu
help_section=SECTION_AUTH, help_text="Revert your Telegram account's Matrix "
"puppet to use the default Matrix account.")
async def logout_matrix(evt: CommandEvent) -> EventID:
puppet = pu.Puppet.get(evt.sender.tgid)
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.")
await puppet.switch_mxid(None, None)
@@ -36,7 +36,7 @@ async def logout_matrix(evt: CommandEvent) -> EventID:
help_text="Replace your Telegram account's Matrix puppet with your own Matrix "
"account.")
async def login_matrix(evt: CommandEvent) -> EventID:
puppet = pu.Puppet.get(evt.sender.tgid)
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first.")
@@ -71,7 +71,7 @@ async def login_matrix(evt: CommandEvent) -> EventID:
help_section=SECTION_AUTH,
help_text="Pings the server with the stored matrix authentication.")
async def ping_matrix(evt: CommandEvent) -> EventID:
puppet = pu.Puppet.get(evt.sender.tgid)
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.")
try:
@@ -84,7 +84,7 @@ async def ping_matrix(evt: CommandEvent) -> EventID:
@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)
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.")
try:
@@ -99,7 +99,7 @@ async def clear_cache_matrix(evt: CommandEvent) -> EventID:
async def enter_matrix_token(evt: CommandEvent) -> EventID:
evt.sender.command_status = None
puppet = pu.Puppet.get(evt.sender.tgid)
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first.")
+11 -9
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -35,12 +35,13 @@ async def clear_db_cache(evt: CommandEvent) -> EventID:
po.Portal.by_mxid = {}
await evt.reply("Cleared portal cache")
elif section == "puppet":
pu.Puppet.cache = {}
pu.Puppet.by_tgid = {}
for puppet in pu.Puppet.by_custom_mxid.values():
puppet.sync_task.cancel()
puppet.stop()
pu.Puppet.by_custom_mxid = {}
await asyncio.gather(*[puppet.try_start() for puppet in pu.Puppet.all_with_custom_mxid()],
loop=evt.loop)
await asyncio.gather(
*[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()]
)
await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
elif section == "user":
u.User.by_mxid = {
@@ -61,15 +62,16 @@ async def reload_user(evt: CommandEvent) -> EventID:
mxid = evt.args[0]
else:
mxid = evt.sender.mxid
user = u.User.get_by_mxid(mxid, create=False)
user = await u.User.get_by_mxid(mxid, create=False)
if not user:
return await evt.reply("User not found")
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
if puppet:
puppet.sync_task.cancel()
puppet.stop()
await user.stop()
user.delete(delete_db=False)
user = u.User.get_by_mxid(mxid)
del u.User.by_tgid[user.tgid]
del u.User.by_mxid[user.mxid]
user = await u.User.get_by_mxid(mxid)
await user.ensure_started()
if puppet:
await puppet.start()
+9 -7
View File
@@ -13,7 +13,9 @@
#
# 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, Tuple, Awaitable
from __future__ import annotations
from typing import Awaitable
import asyncio
from telethon.tl.types import ChatForbidden, ChannelForbidden
@@ -43,7 +45,7 @@ async def bridge(evt: CommandEvent) -> EventID:
room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
that_this = "This" if room_id == evt.room_id else "That"
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if portal:
return await evt.reply(f"{that_this} room is already a portal room.")
@@ -64,7 +66,7 @@ async def bridge(evt: CommandEvent) -> EventID:
"prefix channel IDs with `-100` and normal group IDs with `-`.\n\n"
"Bridging private chats to existing rooms is not allowed.")
portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type)
portal = await po.Portal.get_by_tgid(tgid, peer_type=peer_type)
if not portal.allow_bridging:
return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n"
"If you're the bridge admin, try "
@@ -105,7 +107,7 @@ async def bridge(evt: CommandEvent) -> EventID:
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
) -> Tuple[bool, Optional[Awaitable[None]]]:
) -> tuple[bool, Awaitable[None] | None]:
if not portal.mxid:
await evt.reply("The portal seems to have lost its Matrix room between you"
"calling `$cmdprefix+sp bridge` and this command.\n\n"
@@ -126,10 +128,10 @@ async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Porta
return False, None
async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
async def confirm_bridge(evt: CommandEvent) -> EventID | None:
status = evt.sender.command_status
try:
portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
portal = await po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
bridge_to_mxid = status["bridge_to_mxid"]
except KeyError:
evt.sender.command_status = None
@@ -162,7 +164,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
async def _locked_confirm_bridge(evt: CommandEvent, portal: 'po.Portal', room_id: RoomID,
is_logged_in: bool) -> Optional[EventID]:
is_logged_in: bool) -> EventID | None:
user = evt.sender if is_logged_in else evt.tgbot
try:
entity = await user.client.get_entity(portal.peer)
+1 -1
View File
@@ -37,7 +37,7 @@ async def config(evt: CommandEvent) -> None:
await config_defaults(evt)
return
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
await evt.reply("This is not a portal room.")
return
@@ -32,7 +32,7 @@ async def create(evt: CommandEvent) -> EventID:
return await evt.reply(
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
if po.Portal.get_by_mxid(evt.room_id):
if await po.Portal.get_by_mxid(evt.room_id):
return await evt.reply("This is already a portal room.")
if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
@@ -50,8 +50,8 @@ async def create(evt: CommandEvent) -> EventID:
"group": "chat",
}[type]
portal = po.Portal(tgid=TelegramID(0), peer_type=type, mxid=evt.room_id,
title=title, about=about, encrypted=encrypted)
portal = po.Portal(tgid=TelegramID(0), tg_receiver=TelegramID(0), peer_type=type,
mxid=evt.room_id, title=title, about=about, encrypted=encrypted)
invites, errors = await portal.get_telegram_users_in_matrix_room(evt.sender)
if len(errors) > 0:
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
+11 -10
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,7 +13,8 @@
#
# 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, List, Tuple
from __future__ import annotations
from datetime import timedelta, datetime
import re
@@ -33,7 +34,7 @@ from .util import user_has_power_level
help_section=SECTION_MISC,
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.")
async def sync_state(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
@@ -46,7 +47,7 @@ async def sync_state(evt: CommandEvent) -> EventID:
@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)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
@@ -73,7 +74,7 @@ async def sync_full(evt: CommandEvent) -> EventID:
help_section=SECTION_MISC,
help_text="Get the ID of the Telegram chat where this room is bridged.")
async def get_id(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
tgid = portal.tgid
@@ -92,7 +93,7 @@ invite_link_usage = ("**Usage:** `$cmdprefix+sp invite-link [--uses=<amount>] [-
" A number suffixed with d(ay), h(our), m(inute) or s(econd)")
def _parse_flag(args: List[str]) -> Tuple[str, str]:
def _parse_flag(args: list[str]) -> tuple[str, str]:
arg = args.pop(0).lower()
if arg.startswith("--"):
value_start = arg.index("=")
@@ -116,7 +117,7 @@ def _parse_flag(args: List[str]) -> Tuple[str, str]:
delta_regex = re.compile("([0-9]+)(w(?:eek)?|d(?:ay)?|h(?:our)?|m(?:in(?:ute)?)?|s(?:ec(?:ond)?)?)")
def _parse_delta(value: str) -> Optional[timedelta]:
def _parse_delta(value: str) -> timedelta | None:
match = delta_regex.fullmatch(value)
if not match:
return None
@@ -159,7 +160,7 @@ async def invite_link(evt: CommandEvent) -> EventID:
await evt.reply("Invalid format for expiry time delta")
expire = datetime.now() + expire_delta
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
@@ -178,7 +179,7 @@ async def invite_link(evt: CommandEvent) -> EventID:
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.")
async def upgrade(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type == "channel":
@@ -203,7 +204,7 @@ async def group_name(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type != "channel":
+10 -8
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,7 +13,9 @@
#
# 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, Callable, Optional
from __future__ import annotations
from typing import Callable
from mautrix.types import RoomID, EventID
@@ -22,10 +24,10 @@ from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
from .util import user_has_power_level
async def _get_portal_and_check_permission(evt: CommandEvent) -> Optional[po.Portal]:
async def _get_portal_and_check_permission(evt: CommandEvent) -> po.Portal | None:
room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if not portal:
that_this = "This" if room_id == evt.room_id else "That"
await evt.reply(f"{that_this} is not a portal room.")
@@ -44,8 +46,8 @@ async def _get_portal_and_check_permission(evt: CommandEvent) -> Optional[po.Por
def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str,
completed_message: str) -> Dict:
async def post_confirm(confirm) -> Optional[EventID]:
completed_message: str) -> dict:
async def post_confirm(confirm) -> EventID | None:
confirm.sender.command_status = None
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
await function()
@@ -66,7 +68,7 @@ def _get_portal_murder_function(action: str, room_id: str, function: Callable, c
help_text="Remove all users from the current portal room and forget the portal. "
"Only works for group chats; to delete a private chat portal, simply "
"leave the room.")
async def delete_portal(evt: CommandEvent) -> Optional[EventID]:
async def delete_portal(evt: CommandEvent) -> EventID | None:
portal = await _get_portal_and_check_permission(evt)
if not portal:
return None
@@ -87,7 +89,7 @@ async def delete_portal(evt: CommandEvent) -> Optional[EventID]:
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove puppets from the current portal room and forget the portal.")
async def unbridge(evt: CommandEvent) -> Optional[EventID]:
async def unbridge(evt: CommandEvent) -> EventID | None:
portal = await _get_portal_and_check_permission(evt)
if not portal:
return None
+6 -8
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,7 +13,7 @@
#
# 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 Tuple, Optional
from __future__ import annotations
from mautrix.errors import MatrixRequestError
from mautrix.appservice import IntentAPI
@@ -22,16 +22,14 @@ from .. import CommandEvent
from ... import user as u
OptStr = Optional[str]
async def get_initial_state(
intent: IntentAPI, room_id: RoomID
) -> Tuple[OptStr, OptStr, Optional[PowerLevelStateEventContent], bool]:
) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool]:
state = await intent.get_state(room_id)
title: OptStr = None
about: OptStr = None
levels: Optional[PowerLevelStateEventContent] = None
title: str | None = None
about: str | None = None
levels: PowerLevelStateEventContent | None = None
encrypted: bool = False
for event in state:
try:
@@ -13,10 +13,9 @@
#
# 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
from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError,
HashInvalidError, AuthKeyError, FirstNameInvalidError, AboutTooLongError)
HashInvalidError, AuthKeyError, FirstNameInvalidError,
AboutTooLongError)
from telethon.tl.types import Authorization
from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest,
ResetAuthorizationRequest, UpdateProfileRequest)
@@ -48,10 +47,11 @@ async def username(evt: CommandEvent) -> EventID:
except UsernameOccupiedError:
return await evt.reply("That username is already in use.")
await evt.sender.update_info()
if not evt.sender.username:
if not evt.sender.tg_username:
await evt.reply("Username removed")
else:
await evt.reply(f"Username changed to {evt.sender.username}")
await evt.reply(f"Username changed to {evt.sender.tg_username}")
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
@@ -71,6 +71,7 @@ async def about(evt: CommandEvent) -> EventID:
return await evt.reply("The provided about section is too long")
return await evt.reply("About section updated")
@command_handler(needs_auth=True, help_section=SECTION_AUTH, help_args="<_new displayname_>",
help_text="Change your Telegram displayname.")
async def displayname(evt: CommandEvent) -> EventID:
+15 -13
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,11 +13,13 @@
#
# 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 Any, Dict, Optional
from __future__ import annotations
from typing import Any
import asyncio
import io
from telethon.errors import ( # isort: skip
from telethon.errors import (
AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError,
PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError,
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
@@ -125,7 +127,7 @@ async def enter_code_register(evt: CommandEvent) -> EventID:
async def login_qr(evt: CommandEvent) -> EventID:
login_as = evt.sender
if len(evt.args) > 0 and evt.sender.is_admin:
login_as = u.User.get_by_mxid(UserID(evt.args[0]))
login_as = await u.User.get_by_mxid(UserID(evt.args[0]))
if not qrcode or not QRLogin:
return await evt.reply("This bridge instance does not support logging in with a QR code.")
if await login_as.is_logged_in():
@@ -133,7 +135,7 @@ async def login_qr(evt: CommandEvent) -> EventID:
await login_as.ensure_started(even_if_no_session=True)
qr_login = QRLogin(login_as.client, ignored_ids=[])
qr_event_id: Optional[EventID] = None
qr_event_id: EventID | None = None
async def upload_qr() -> None:
nonlocal qr_event_id
@@ -184,7 +186,7 @@ async def login_qr(evt: CommandEvent) -> EventID:
async def login(evt: CommandEvent) -> EventID:
override_sender = False
if len(evt.args) > 0 and evt.sender.is_admin:
evt.sender = await u.User.get_by_mxid(UserID(evt.args[0])).ensure_started()
evt.sender = await u.User.get_and_start_by_mxid(UserID(evt.args[0]))
override_sender = True
if await evt.sender.is_logged_in():
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
@@ -217,7 +219,7 @@ async def login(evt: CommandEvent) -> EventID:
return await evt.reply("This bridge instance has been configured to not allow logging in.")
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]
) -> EventID:
ok = False
try:
@@ -249,7 +251,7 @@ async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[
@command_handler(needs_auth=False)
async def enter_phone_or_token(evt: CommandEvent) -> Optional[EventID]:
async def enter_phone_or_token(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
@@ -273,7 +275,7 @@ async def enter_phone_or_token(evt: CommandEvent) -> Optional[EventID]:
@command_handler(needs_auth=False)
async def enter_code(evt: CommandEvent) -> Optional[EventID]:
async def enter_code(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
@@ -289,7 +291,7 @@ async def enter_code(evt: CommandEvent) -> Optional[EventID]:
@command_handler(needs_auth=False)
async def enter_password(evt: CommandEvent) -> Optional[EventID]:
async def enter_password(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
@@ -309,7 +311,7 @@ async def enter_password(evt: CommandEvent) -> Optional[EventID]:
return None
async def _sign_in(evt: CommandEvent, login_as: 'u.User' = None, **sign_in_info) -> EventID:
async def _sign_in(evt: CommandEvent, login_as: u.User = None, **sign_in_info) -> EventID:
login_as = login_as or evt.sender
try:
await login_as.ensure_started(even_if_no_session=True)
@@ -330,9 +332,9 @@ async def _sign_in(evt: CommandEvent, login_as: 'u.User' = None, **sign_in_info)
"Please send your password here.")
async def _finish_sign_in(evt: CommandEvent, user: User, login_as: 'u.User' = None) -> EventID:
async def _finish_sign_in(evt: CommandEvent, user: User, login_as: u.User = None) -> EventID:
login_as = login_as or evt.sender
existing_user = u.User.get_by_tgid(TelegramID(user.id))
existing_user = await u.User.get_by_tgid(TelegramID(user.id))
if existing_user and existing_user != login_as:
await existing_user.log_out()
await evt.reply(f"[{existing_user.displayname}]"
+16 -14
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2020 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,7 +13,9 @@
#
# 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, cast
from __future__ import annotations
from typing import cast
import codecs
import base64
import re
@@ -81,7 +83,7 @@ async def search(evt: CommandEvent) -> EventID:
"Minimum length of remote query is 5 characters.")
return await evt.reply("No results 3:")
reply: List[str] = []
reply: list[str] = []
if remote:
reply += ["**Results from Telegram server:**", ""]
else:
@@ -114,14 +116,14 @@ async def pm(evt: CommandEvent) -> EventID:
return await evt.reply("User not found.")
elif not isinstance(user, TLUser):
return await evt.reply("That doesn't seem to be a user.")
portal = po.Portal.get_by_entity(user, evt.sender.tgid)
portal = await po.Portal.get_by_entity(user, tg_receiver=evt.sender.tgid)
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
displayname, _ = pu.Puppet.get_displayname(user, False)
return await evt.reply(f"Created private chat room with {displayname}")
async def _join(evt: CommandEvent, identifier: str, link_type: str
) -> Tuple[Optional[TypeUpdates], Optional[EventID]]:
) -> tuple[TypeUpdates | None, EventID | None]:
if link_type == "joinchat":
try:
await evt.sender.client(CheckChatInviteRequest(identifier))
@@ -143,7 +145,7 @@ async def _join(evt: CommandEvent, identifier: str, link_type: str
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="<_link_>",
help_text="Join a chat with an invite link.")
async def join(evt: CommandEvent) -> Optional[EventID]:
async def join(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
@@ -171,7 +173,7 @@ async def join(evt: CommandEvent) -> Optional[EventID]:
return None
for chat in updates.chats:
portal = po.Portal.get_by_entity(chat)
portal = await po.Portal.get_by_entity(chat)
if portal.mxid:
await portal.invite_to_matrix([evt.sender.mxid])
return await evt.reply(f"Invited you to portal of {portal.title}")
@@ -219,7 +221,7 @@ class MessageIDError(ValueError):
async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
) -> Tuple[TypeInputPeer, Message]:
) -> tuple[TypeInputPeer, Message]:
try:
enc_id += (4 - len(enc_id) % 4) * "="
enc_id = base64.b64decode(enc_id)
@@ -233,10 +235,10 @@ async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
raise MessageIDError(f"Invalid {type_name} ID (format)") from e
if peer_type == PEER_TYPE_CHAT:
orig_msg = DBMessage.get_one_by_tgid(msg_id, space)
orig_msg = await DBMessage.get_one_by_tgid(msg_id, space)
if not orig_msg:
raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)")
new_msg = DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
new_msg = await DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
if not new_msg:
raise MessageIDError(f"Invalid {type_name} ID (your copy of message not found in db)")
msg_id = new_msg.tgid
@@ -282,7 +284,7 @@ async def play(evt: CommandEvent) -> EventID:
@command_handler(help_section=SECTION_MISC,
help_args="<_poll ID_> <_choice number_>",
help_text="Vote in a Telegram poll.")
async def vote(evt: CommandEvent) -> EventID:
async def vote(evt: CommandEvent) -> EventID | None:
if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice number>`")
elif not await evt.sender.is_logged_in():
@@ -319,7 +321,7 @@ async def vote(evt: CommandEvent) -> EventID:
options = [msg.media.poll.answers[int(option) - 1].option
for option in evt.args[1:]]
try:
resp = await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
except OptionsTooMuchError:
return await evt.reply("You passed too many options.")
# TODO use response
@@ -332,7 +334,7 @@ async def vote(evt: CommandEvent) -> EventID:
async def random(evt: CommandEvent) -> EventID:
if not evt.is_portal:
return await evt.reply("You can only randomize values in portal rooms")
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
arg = evt.args[0] if len(evt.args) > 0 else "dice"
emoticon = {
"dart": "\U0001F3AF",
@@ -359,7 +361,7 @@ async def backfill(evt: CommandEvent) -> None:
limit = int(evt.args[0])
except (ValueError, IndexError):
limit = -1
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not evt.config["bridge.backfill.normal_groups"] and portal.peer_type == "chat":
await evt.reply("Backfilling normal groups is disabled in the bridge config")
return
+3 -1
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2020 Tulir Asokan
# Copyright (C) 2021 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
@@ -132,6 +132,8 @@ class Config(BaseBridgeConfig):
copy("bridge.pinned_tag")
copy("bridge.archive_tag")
copy("bridge.tag_only_on_create")
copy("bridge.bridge_matrix_leave")
copy("bridge.kick_on_logout")
copy("bridge.backfill.invite_own_puppet")
copy("bridge.backfill.takeout_limit")
copy("bridge.backfill.initial_limit")
-57
View File
@@ -1,57 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Tuple, TYPE_CHECKING
import asyncio
from alchemysession import AlchemySessionContainer
from mautrix.appservice import AppService
if TYPE_CHECKING:
from .web import PublicBridgeWebsite, ProvisioningAPI
from .config import Config
from .bot import Bot
from .matrix import MatrixHandler
from .__main__ import TelegramBridge
class Context:
az: AppService
config: 'Config'
loop: asyncio.AbstractEventLoop
bridge: 'TelegramBridge'
bot: Optional['Bot']
mx: Optional['MatrixHandler']
session_container: AlchemySessionContainer
public_website: Optional['PublicBridgeWebsite']
provisioning_api: Optional['ProvisioningAPI']
def __init__(self, az: AppService, config: 'Config', loop: asyncio.AbstractEventLoop,
session_container: AlchemySessionContainer, bridge: 'TelegramBridge',
bot: Optional['Bot']) -> None:
self.az = az
self.config = config
self.loop = loop
self.bridge = bridge
self.bot = bot
self.mx = None
self.session_container = session_container
self.public_website = None
self.provisioning_api = None
@property
def core(self) -> Tuple[AppService, 'Config', asyncio.AbstractEventLoop, Optional['Bot']]:
return self.az, self.config, self.loop, self.bot
+12 -8
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,19 +13,23 @@
#
# 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.engine.base import Engine
from mautrix.util.async_db import Database
from mautrix.client.state_store.sqlalchemy import UserProfile, RoomState
from .upgrade import upgrade_table
from .bot_chat import BotChat
from .message import Message
from .portal import Portal
from .puppet import Puppet
from .telegram_file import TelegramFile
from .user import User, UserPortal, Contact
from .user import User
from .telethon_session import PgSession
def init(db_engine: Engine) -> None:
for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile,
RoomState, BotChat):
table.bind(db_engine)
def init(db: Database) -> None:
for table in (Portal, Message, User, Puppet, TelegramFile, BotChat, PgSession):
table.db = db
__all__ = ["upgrade_table", "init", "Portal", "Message", "User", "Puppet", "TelegramFile",
"BotChat", "PgSession"]
+30 -13
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,26 +13,43 @@
#
# 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 Iterable
from __future__ import annotations
from sqlalchemy import Column, BigInteger, String
from typing import ClassVar, TYPE_CHECKING
from mautrix.util.db import Base
from asyncpg import Record
from attr import dataclass
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
# Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base):
__tablename__ = "bot_chat"
id: TelegramID = Column(BigInteger, primary_key=True)
type: str = Column(String, nullable=False)
@dataclass
class BotChat:
db: ClassVar[Database] = fake_db
id: TelegramID
type: str
@classmethod
def delete_by_id(cls, chat_id: TelegramID) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.id == chat_id))
def _from_row(cls, row: Record | None) -> BotChat | None:
if row is None:
return None
return cls(**row)
@classmethod
def all(cls) -> Iterable['BotChat']:
return cls._select_all()
async def delete_by_id(cls, chat_id: TelegramID) -> None:
await cls.db.execute("DELETE FROM bot_chat WHERE id=$1", chat_id)
@classmethod
async def all(cls) -> list[BotChat]:
rows = await cls.db.fetch("SELECT id, type FROM bot_chat")
return [cls._from_row(row) for row in rows]
async def insert(self) -> None:
q = "INSERT INTO bot_chat (id, type) VALUES ($1, $2)"
await self.db.execute(q, self.id, self.type)
+114 -64
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,96 +13,146 @@
#
# 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, Iterator, List
from __future__ import annotations
from sqlalchemy import (Column, UniqueConstraint, BigInteger, Integer, String, Boolean, and_, func,
desc, select, false)
from typing import ClassVar, TYPE_CHECKING
from asyncpg import Record
from attr import dataclass
from mautrix.types import RoomID, EventID
from mautrix.util.db import Base
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
class Message(Base):
__tablename__ = "message"
mxid: EventID = Column(String)
mx_room: RoomID = Column(String)
tgid: TelegramID = Column(BigInteger, primary_key=True)
tg_space: TelegramID = Column(BigInteger, primary_key=True)
edit_index: int = Column(Integer, primary_key=True)
redacted: bool = Column(Boolean, server_default=false())
@dataclass
class Message:
db: ClassVar[Database] = fake_db
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room_2"),)
mxid: EventID
mx_room: RoomID
tgid: TelegramID
tg_space: TelegramID
edit_index: int
redacted: bool = False
@classmethod
def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Iterator['Message']:
return cls._select_all(cls.c.tgid == tgid, cls.c.tg_space == tg_space)
def _from_row(cls, row: Record | None) -> Message | None:
if row is None:
return None
return cls(**row)
columns: ClassVar[str] = "mxid, mx_room, tgid, tg_space, edit_index, redacted"
@classmethod
def get_one_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
) -> Optional['Message']:
async def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> list[Message]:
q = f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2"
rows = await cls.db.fetch(q, tgid, tg_space)
return [cls._from_row(row) for row in rows]
@classmethod
async def get_one_by_tgid(
cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
) -> Message | None:
if edit_index < 0:
return cls._one_or_none(cls.db.execute(
cls.t.select()
.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space))
.order_by(desc(cls.c.edit_index))
.limit(1).offset(-edit_index - 1)))
q = (
f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2 "
f"ORDER BY edit_index DESC LIMIT 1 OFFSET {-edit_index - 1}"
)
row = await cls.db.fetchrow(q, tgid, tg_space)
else:
return cls._select_one_or_none(cls.c.tgid == tgid, cls.c.tg_space == tg_space,
cls.c.edit_index == edit_index)
q = (
f"SELECT {cls.columns} FROM message"
" WHERE tgid=$1 AND tg_space=$2 AND edit_index=$3"
)
row = await cls.db.fetchrow(q, tgid, tg_space, edit_index)
return cls._from_row(row)
@classmethod
def get_first_by_tgids(cls, tgids: List[TelegramID], tg_space: TelegramID
) -> Iterator['Message']:
return cls._select_all(cls.c.tgid.in_(tgids), cls.c.tg_space == tg_space,
cls.c.edit_index == 0)
async def get_first_by_tgids(
cls, tgids: list[TelegramID], tg_space: TelegramID
) -> list[Message]:
if cls.db.scheme == "postgres":
q = (
f"SELECT {cls.columns} FROM message"
" WHERE tgid=ANY($1) AND tg_space=$2 AND edit_index=0"
)
rows = await cls.db.fetch(q, tgids, tg_space)
else:
tgid_placeholders = ("?," * len(tgids)).rstrip(",")
q = (
f"SELECT {cls.columns} FROM message "
f"WHERE tg_space=? AND edit_index=0 AND tgid IN ({tgid_placeholders})"
)
rows = await cls.db.fetch(q, tg_space, *tgids)
return [cls._from_row(row) for row in rows]
@classmethod
def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
rows = cls.db.execute(select([func.count(cls.c.tg_space)])
.where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)))
try:
count, = next(rows)
return count
except StopIteration:
return 0
async def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
return await cls.db.fetchval(
"SELECT COUNT(tg_space) FROM message WHERE mxid=$1 AND mx_room=$2", mxid, mx_room
) or 0
@classmethod
def find_last(cls, mx_room: RoomID, tg_space: TelegramID) -> Optional['Message']:
return cls._one_or_none(cls.db.execute(
cls._make_simple_select(cls.c.mx_room == mx_room, cls.c.tg_space == tg_space)
.order_by(desc(cls.c.tgid)).limit(1)))
async def find_last(cls, mx_room: RoomID, tg_space: TelegramID) -> Message | None:
q = (
f"SELECT {cls.columns} FROM message WHERE mx_room=$1 AND tg_space=$2 "
f"ORDER BY tgid DESC LIMIT 1"
)
return cls._from_row(await cls.db.fetchrow(q, mx_room, tg_space))
@classmethod
def delete_all(cls, mx_room: RoomID) -> None:
cls.db.execute(cls.t.delete().where(cls.c.mx_room == mx_room))
async def delete_all(cls, mx_room: RoomID) -> None:
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", mx_room)
@classmethod
def get_by_mxid(cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
) -> Optional['Message']:
return cls._select_one_or_none(cls.c.mxid == mxid, cls.c.mx_room == mx_room,
cls.c.tg_space == tg_space)
async def get_by_mxid(
cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
) -> Message | None:
q = f"SELECT {cls.columns} FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room, tg_space))
@classmethod
def get_by_mxids(cls, mxids: List[EventID], mx_room: RoomID, tg_space: TelegramID
) -> Iterator['Message']:
return cls._select_all(cls.c.mxid.in_(mxids), cls.c.mx_room == mx_room,
cls.c.tg_space == tg_space)
async def get_by_mxids(
cls, mxids: list[EventID], mx_room: RoomID, tg_space: TelegramID
) -> list[Message]:
if cls.db.scheme == "postgres":
q = (
f"SELECT {cls.columns} FROM message"
" WHERE mxid=ANY($1) AND mx_room=$2 AND tg_space=$3"
)
rows = await cls.db.fetch(q, mxids, mx_room, tg_space)
else:
mxid_placeholders = ("?," * len(mxids)).rstrip(",")
q = (
f"SELECT {cls.columns} FROM message "
f"WHERE mx_room=? AND tg_space=? AND mxid IN ({mxid_placeholders})"
)
rows = await cls.db.fetch(q, mx_room, tg_space, *mxids)
return [cls._from_row(row) for row in rows]
@classmethod
def update_by_tgid(cls, s_tgid: TelegramID, s_tg_space: TelegramID, s_edit_index: int,
**values) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.update()
.where(and_(cls.c.tgid == s_tgid, cls.c.tg_space == s_tg_space,
cls.c.edit_index == s_edit_index))
.values(**values))
async def replace_temp_mxid(cls, temp_mxid: str, mx_room: RoomID, real_mxid: EventID) -> None:
q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3"
await cls.db.execute(q, real_mxid, temp_mxid, mx_room)
@classmethod
def update_by_mxid(cls, s_mxid: EventID, s_mx_room: RoomID, **values) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.update()
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
.values(**values))
async def insert(self) -> None:
q = (
"INSERT INTO message (mxid, mx_room, tgid, tg_space, edit_index, redacted) "
"VALUES ($1, $2, $3, $4, $5, $6)"
)
await self.db.execute(
q, self.mxid, self.mx_room, self.tgid, self.tg_space, self.edit_index, self.redacted
)
async def delete(self) -> None:
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
await self.db.execute(q, self.mxid, self.mx_room, self.tg_space)
async def mark_redacted(self) -> None:
self.redacted = True
q = "UPDATE message SET redacted=true WHERE mxid=$1 AND mx_room=$2"
await self.db.execute(q, self.mxid, self.mx_room)
+91 -29
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,54 +13,116 @@
#
# 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, Iterable
from __future__ import annotations
from sqlalchemy import Column, BigInteger, String, Boolean, Text, func, sql
from typing import ClassVar, Any, TYPE_CHECKING
import json
from asyncpg import Record
from attr import dataclass
import attr
from mautrix.types import RoomID, ContentURI
from mautrix.util.db import Base
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
class Portal(Base):
__tablename__ = "portal"
@dataclass
class Portal:
db: ClassVar[Database] = fake_db
# Telegram chat information
tgid: TelegramID = Column(BigInteger, primary_key=True)
tg_receiver: TelegramID = Column(BigInteger, primary_key=True)
peer_type: str = Column(String, nullable=False)
megagroup: bool = Column(Boolean)
tgid: TelegramID
tg_receiver: TelegramID
peer_type: str
megagroup: bool
# Matrix portal information
mxid: Optional[RoomID] = Column(String, unique=True, nullable=True)
avatar_url: Optional[ContentURI] = Column(String, nullable=True)
encrypted: bool = Column(Boolean, nullable=False, server_default=sql.expression.false())
config: str = Column(Text, nullable=True)
mxid: RoomID | None
avatar_url: ContentURI | None
encrypted: bool
# Telegram chat metadata
username: str = Column(String, nullable=True)
title: str = Column(String, nullable=True)
about: str = Column(String, nullable=True)
photo_id: str = Column(String, nullable=True)
username: str | None
title: str | None
about: str | None
photo_id: str | None
local_config: dict[str, Any] = attr.ib(factory=lambda: {})
@classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver)
def _from_row(cls, row: Record | None) -> Portal | None:
if row is None:
return None
data = {**row}
data["local_config"] = json.loads(data.pop("config", None) or "{}")
return cls(**data)
columns: ClassVar[str] = (
"tgid, tg_receiver, peer_type, megagroup, mxid, avatar_url, encrypted, config, "
"username, title, about, photo_id"
)
@classmethod
def find_private_chats(cls, tg_receiver: TelegramID) -> Iterable['Portal']:
yield from cls._select_all(cls.c.tg_receiver == tg_receiver, cls.c.peer_type == "user")
async def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Portal | None:
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND tg_receiver=$2"
return cls._from_row(await cls.db.fetchrow(q, tgid, tg_receiver))
@classmethod
def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.mxid == mxid)
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
q = f"SELECT {cls.columns} FROM portal WHERE mxid=$1"
return cls._from_row(await cls.db.fetchrow(q, mxid))
@classmethod
def get_by_username(cls, username: str) -> Optional['Portal']:
return cls._select_one_or_none(func.lower(cls.c.username) == username)
async def find_by_username(cls, username: str) -> Portal | None:
q = f"SELECT {cls.columns} FROM portal WHERE lower(username)=$1"
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@classmethod
def all(cls) -> Iterable['Portal']:
yield from cls._select_all()
async def find_private_chats(cls, tg_receiver: TelegramID) -> list[Portal]:
q = f"SELECT {cls.columns} FROM portal WHERE tg_receiver=$1 AND peer_type='user'"
return [cls._from_row(row) for row in await cls.db.fetch(q, tg_receiver)]
@classmethod
async def all(cls) -> list[Portal]:
rows = await cls.db.fetch(f"SELECT {cls.columns} FROM portal")
return [cls._from_row(row) for row in rows]
@property
def _values(self):
return (self.tgid, self.tg_receiver, self.peer_type, self.mxid, self.avatar_url,
self.encrypted, self.username, self.title, self.about, self.photo_id,
self.megagroup, json.dumps(self.local_config) if self.local_config else None)
async def save(self) -> None:
q = (
"UPDATE portal SET mxid=$4, avatar_url=$5, encrypted=$6, username=$7, title=$8,"
" about=$9, photo_id=$10, megagroup=$11, config=$12 "
"WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true)"
)
await self.db.execute(q, *self._values)
async def update_id(self, id: TelegramID, peer_type: str) -> None:
q = (
"UPDATE portal SET tgid=$1, tg_receiver=$1, peer_type=$2 "
"WHERE tgid=$3 AND tg_receiver=$3"
)
await self.db.execute(q, id, peer_type, self.tgid)
self.tgid = id
self.tg_receiver = id
self.peer_type = peer_type
async def insert(self) -> None:
q = (
"INSERT INTO portal (tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted,"
" username, title, about, photo_id, megagroup, config) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"
)
await self.db.execute(q, *self._values)
async def delete(self) -> None:
q = "DELETE FROM portal WHERE tgid=$1 AND tg_receiver=$2"
await self.db.execute(q, self.tgid, self.tg_receiver)
+86 -31
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,51 +13,106 @@
#
# 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, Iterable
from __future__ import annotations
from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean
from sqlalchemy.sql import expression, func
from typing import ClassVar, TYPE_CHECKING
from asyncpg import Record
from attr import dataclass
from yarl import URL
from mautrix.types import UserID, SyncToken
from mautrix.util.db import Base
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
class Puppet(Base):
__tablename__ = "puppet"
id: TelegramID = Column(BigInteger, primary_key=True)
custom_mxid: UserID = Column(String, nullable=True)
access_token: str = Column(String, nullable=True)
next_batch: SyncToken = Column(String, nullable=True)
base_url: str = Column(Text, nullable=True)
displayname: str = Column(String, nullable=True)
displayname_source: TelegramID = Column(BigInteger, nullable=True)
displayname_contact: bool = Column(Boolean, nullable=False, server_default=expression.true())
displayname_quality: int = Column(Integer, nullable=False, server_default="0")
username: str = Column(String, nullable=True)
photo_id: str = Column(String, nullable=True)
is_bot: bool = Column(Boolean, nullable=True)
matrix_registered: bool = Column(Boolean, nullable=False, server_default=expression.false())
disable_updates: bool = Column(Boolean, nullable=False, server_default=expression.false())
@dataclass
class Puppet:
db: ClassVar[Database] = fake_db
id: TelegramID
is_registered: bool
displayname: str | None
displayname_source: TelegramID | None
displayname_contact: bool
displayname_quality: int
disable_updates: bool
username: str | None
photo_id: str | None
is_bot: bool | None
custom_mxid: UserID | None
access_token: str | None
next_batch: SyncToken | None
base_url: URL | None
@classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
yield from cls._select_all(cls.c.custom_mxid != None)
def _from_row(cls, row: Record | None) -> Puppet | None:
if row is None:
return None
data = {**row}
base_url = data.pop("base_url", None)
return cls(**data, base_url=URL(base_url) if base_url else None)
columns: ClassVar[str] = (
"id, is_registered, displayname, displayname_source, displayname_contact, "
"displayname_quality, disable_updates, username, photo_id, is_bot, "
"custom_mxid, access_token, next_batch, base_url"
)
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.id == tgid)
async def all_with_custom_mxid(cls) -> list[Puppet]:
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid<>''"
return [cls._from_row(row) for row in await cls.db.fetch(q)]
@classmethod
def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.custom_mxid == mxid)
async def get_by_tgid(cls, tgid: TelegramID) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE id=$1"
return cls._from_row(await cls.db.fetchrow(q, tgid))
@classmethod
def get_by_username(cls, username: str) -> Optional['Puppet']:
return cls._select_one_or_none(func.lower(cls.c.username) == username)
async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid=$1"
return cls._from_row(await cls.db.fetchrow(q, mxid))
@classmethod
def get_by_displayname(cls, displayname: str) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.displayname == displayname)
async def find_by_username(cls, username: str) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE lower(username)=$1"
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@classmethod
async def find_by_displayname(cls, displayname: str) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE displayname=$1"
return cls._from_row(await cls.db.fetchrow(q, displayname))
@property
def _values(self):
return (self.id, self.is_registered, self.displayname, self.displayname_source,
self.displayname_contact, self.displayname_quality, self.disable_updates,
self.username, self.photo_id, self.is_bot, self.custom_mxid, self.access_token,
self.next_batch, str(self.base_url) if self.base_url else None)
async def save(self) -> None:
q = (
"UPDATE puppet "
"SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,"
" displayname_quality=$6, disable_updates=$7, username=$8, photo_id=$9, is_bot=$10,"
" custom_mxid=$11, access_token=$12, next_batch=$13, base_url=$14 "
"WHERE id=$1"
)
await self.db.execute(q, *self._values)
async def insert(self) -> None:
q = (
"INSERT INTO puppet ("
" id, is_registered, displayname, displayname_source, displayname_contact,"
" displayname_quality, disable_updates, username, photo_id, is_bot,"
" custom_mxid, access_token, next_batch, base_url"
") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)"
)
await self.db.execute(q, *self._values)
+50 -57
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,69 +13,62 @@
#
# 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, cast, Dict, Any, TYPE_CHECKING
from __future__ import annotations
from sqlalchemy import (Column, ForeignKey, Integer, BigInteger, String, Boolean, Text,
TypeDecorator)
from typing import ClassVar, TYPE_CHECKING
from attr import dataclass
from mautrix.types import ContentURI, EncryptedFile
from mautrix.util.db import Base
from mautrix.util.async_db import Database
if TYPE_CHECKING:
from sqlalchemy.engine.result import RowProxy
fake_db = Database.create("") if TYPE_CHECKING else None
class DBEncryptedFile(TypeDecorator):
impl = Text
@dataclass
class TelegramFile:
db: ClassVar[Database] = fake_db
@property
def python_type(self):
return EncryptedFile
def process_bind_param(self, value: EncryptedFile, dialect) -> Optional[str]:
if value is not None:
return value.json()
return None
def process_result_value(self, value: str, dialect) -> Optional[EncryptedFile]:
if value is not None:
return EncryptedFile.parse_json(value)
return None
def process_literal_param(self, value, dialect):
return value
class TelegramFile(Base):
__tablename__ = "telegram_file"
id: str = Column(String, primary_key=True)
mxc: ContentURI = Column(String)
mime_type: str = Column(String)
was_converted: bool = Column(Boolean)
timestamp: int = Column(BigInteger)
size: Optional[int] = Column(Integer, nullable=True)
width: Optional[int] = Column(Integer, nullable=True)
height: Optional[int] = Column(Integer, nullable=True)
decryption_info: Optional[Dict[str, Any]] = Column(DBEncryptedFile, nullable=True)
thumbnail_id: str = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
thumbnail: Optional['TelegramFile'] = None
id: str
mxc: ContentURI
mime_type: str
was_converted: bool
timestamp: int
size: int | None
width: int | None
height: int | None
decryption_info: EncryptedFile | None
thumbnail: TelegramFile | None = None
@classmethod
def scan(cls, row: 'RowProxy') -> 'TelegramFile':
telegram_file = cast(TelegramFile, super().scan(row))
if isinstance(telegram_file.thumbnail, str):
telegram_file.thumbnail = cls.get(telegram_file.thumbnail)
return telegram_file
async def get(cls, loc_id: str, *, _thumbnail: bool = False) -> TelegramFile | None:
q = (
"SELECT id, mxc, mime_type, was_converted, timestamp, size, width, height, thumbnail,"
" decryption_info "
"FROM telegram_file WHERE id=$1"
)
row = await cls.db.fetchrow(q, loc_id)
if row is None:
return None
data = {**row}
thumbnail_id = data.pop("thumbnail", None)
if _thumbnail:
# Don't allow more than one level of recursion
thumbnail_id = None
decryption_info = data.pop("decryption_info", None)
return cls(
**data,
thumbnail=(await cls.get(thumbnail_id, _thumbnail=True)) if thumbnail_id else None,
decryption_info=EncryptedFile.parse_json(decryption_info) if decryption_info else None,
)
@classmethod
def get(cls, loc_id: str) -> Optional['TelegramFile']:
return cls._select_one_or_none(cls.c.id == loc_id)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
id=self.id, mxc=self.mxc, mime_type=self.mime_type,
was_converted=self.was_converted, timestamp=self.timestamp, size=self.size,
width=self.width, height=self.height, decryption_info=self.decryption_info,
thumbnail=self.thumbnail.id if self.thumbnail else self.thumbnail_id))
async def insert(self) -> None:
q = (
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, size, width, height, "
" thumbnail, decryption_info) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
)
await self.db.execute(q, self.id, self.mxc, self.mime_type, self.was_converted, self.size,
self.width, self.height,
self.thumbnail.id if self.thumbnail else None,
self.decryption_info.json() if self.decryption_info else None)
+204
View File
@@ -0,0 +1,204 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 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 __future__ import annotations
from typing import ClassVar, TYPE_CHECKING
import datetime
import asyncio
from telethon.sessions import MemorySession
from telethon.tl.types import updates, PeerUser, PeerChat, PeerChannel
from telethon.crypto import AuthKey
from telethon import utils
from mautrix.util.async_db import Database
fake_db = Database.create("") if TYPE_CHECKING else None
class PgSession(MemorySession):
db: ClassVar[Database] = fake_db
session_id: str
_dc_id: int
_server_address: str | None
_port: int | None
_auth_key: AuthKey | None
_takeout_id: int | None
_process_entities_lock: asyncio.Lock
def __init__(
self,
session_id: str,
dc_id: int = 0,
server_address: str | None = None,
port: int | None = None,
auth_key: AuthKey | None = None,
takeout_id: int | None = None,
) -> None:
super().__init__()
self.session_id = session_id
self._dc_id = dc_id
self._server_address = server_address
self._port = port
self._auth_key = auth_key
self._takeout_id = takeout_id
self._process_entities_lock = asyncio.Lock()
def clone(self, to_instance=None) -> MemorySession:
# We don't want to store data of clones
# (which are used for temporarily connecting to different DCs)
return super().clone(MemorySession())
@property
def auth_key_bytes(self) -> bytes | None:
return self._auth_key.key if self._auth_key else None
@classmethod
async def get(cls, session_id: str) -> PgSession:
q = (
"SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions "
"WHERE session_id=$1"
)
row = await cls.db.fetchrow(q, session_id)
if row is None:
return cls(session_id)
data = {**row}
auth_key = AuthKey(data.pop("auth_key", None))
return cls(**data, auth_key=auth_key)
@classmethod
async def has(cls, session_id: str) -> bool:
q = "SELECT COUNT(*) FROM telethon_sessions WHERE session_id=$1"
count = await cls.db.fetchval(q, session_id)
return count > 0
async def save(self) -> None:
q = (
"INSERT INTO telethon_sessions (session_id, dc_id, server_address, port, auth_key) "
"VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_id) "
"DO UPDATE SET dc_id=$2, server_address=$3, port=$4, auth_key=$5"
)
await self.db.execute(
q, self.session_id, self.dc_id, self.server_address, self.port, self.auth_key_bytes
)
_tables: ClassVar[tuple[str, ...]] = (
"telethon_sessions", "telethon_entities", "telethon_sent_files", "telethon_update_state"
)
async def delete(self) -> None:
async with self.db.acquire() as conn, conn.transaction():
for table in self._tables:
await conn.execute(f"DELETE FROM {table} WHERE session_id=$1", self.session_id)
async def close(self) -> None:
# Nothing to do here, DB connection is global
pass
async def get_update_state(self, entity_id: int) -> updates.State | None:
q = (
"SELECT pts, qts, date, seq, unread_count FROM telethon_update_state "
"WHERE session_id=$1 AND entity_id=$2"
)
row = await self.db.fetchrow(q, self.session_id, entity_id)
if row is None:
return None
date = datetime.datetime.utcfromtimestamp(row["date"])
return updates.State(row["pts"], row["qts"], date, row["seq"], row["unread_count"])
async def set_update_state(self, entity_id: int, row: updates.State) -> None:
q = (
"INSERT INTO telethon_update_state"
" (session_id, entity_id, pts, qts, date, seq, unread_count) "
"VALUES ($1, $2, $3, $4, $5, $6, $7)"
"ON CONFLICT (session_id, entity_id) DO UPDATE"
" SET pts=$3, qts=$4, date=$5, seq=$6, unread_count=$7"
)
ts = row.date.timestamp()
await self.db.execute(
q, self.session_id, entity_id, row.pts, row.qts, ts, row.seq, row.unread_count
)
def _entity_values_to_row(
self, id: int, hash: int, username: str | None, phone: str | int | None, name: str | None
) -> tuple[str, int, int, str | None, str | None, str | None]:
return self.session_id, id, hash, username, str(phone) if phone else None, name
async def process_entities(self, tlo) -> None:
# Postgres likes to deadlock on simultaneous upserts, so just lock the whole thing here
# TODO: make sure postgres doesn't deadlock on upserts when session_id is different
async with self._process_entities_lock:
await self._locked_process_entities(tlo)
async def _locked_process_entities(self, tlo) -> None:
rows: list[
tuple[str, int, int, str | None, str | None, str | None]
] = self._entities_to_rows(tlo)
if not rows:
return
if self.db.scheme == "postgres":
q = (
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
"VALUES ($1, unnest($2::bigint[]), unnest($3::bigint[]), "
" unnest($4::text[]), unnest($5::text[]), unnest($6::text[])) "
"ON CONFLICT (session_id, id) DO UPDATE"
" SET hash=excluded.hash, username=excluded.username,"
" phone=excluded.phone, name=excluded.name"
)
_, ids, hashes, usernames, phones, names = zip(*rows)
await self.db.execute(q, self.session_id, ids, hashes, usernames, phones, names)
else:
q = (
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
"VALUES ($1, $2, $3, $4, $5, $6) "
"ON CONFLICT (session_id, id) DO UPDATE "
" SET hash=$3, username=$4, phone=$5, name=$6"
)
await self.db.executemany(q, rows)
async def _select_entity(
self, constraint: str, *args: str | int | tuple[int, ...]
) -> tuple[int, int] | None:
row = await self.db.fetchrow(
f"SELECT id, hash FROM telethon_entities WHERE {constraint}", *args
)
if row is None:
return None
return row["id"], row["hash"]
async def get_entity_rows_by_phone(self, key: str | int) -> tuple[int, int] | None:
return await self._select_entity("phone=$1", str(key))
async def get_entity_rows_by_username(self, key: str) -> tuple[int, int] | None:
return await self._select_entity("username=$1", key)
async def get_entity_rows_by_name(self, key: str) -> tuple[int, int] | None:
return await self._select_entity("name=$1", key)
async def get_entity_rows_by_id(self, key: int, exact: bool = True) -> tuple[int, int] | None:
if exact:
return await self._select_entity("id=$1", key)
ids = (
utils.get_peer_id(PeerUser(key)),
utils.get_peer_id(PeerChat(key)),
utils.get_peer_id(PeerChannel(key))
)
if self.db.scheme == "postgres":
return await self._select_entity("id=ANY($1)", ids)
else:
return await self._select_entity(f"id IN ($1, $2, $3)", *ids)
+5
View File
@@ -0,0 +1,5 @@
from mautrix.util.async_db import UpgradeTable
upgrade_table = UpgradeTable()
from . import v01_initial_revision
@@ -0,0 +1,300 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 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 asyncpg import Connection
from . import upgrade_table
legacy_version_query = "SELECT version_num FROM alembic_version"
last_legacy_version = "bfc0a39bfe02"
def table_exists(scheme: str, name: str) -> str:
if scheme == "sqlite":
return f"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='{name}')"
elif scheme == "postgres":
return f"SELECT EXISTS(SELECT FROM information_schema.tables WHERE table_name='{name}')"
raise RuntimeError("unsupported database scheme")
@upgrade_table.register(description="Initial asyncpg revision")
async def upgrade_v1(conn: Connection, scheme: str) -> None:
is_legacy = await conn.fetchval(table_exists(scheme, "alembic_version"))
if is_legacy:
await migrate_legacy_to_v1(conn, scheme)
else:
await create_v1_tables(conn)
async def migrate_legacy_to_v1(conn: Connection, scheme: str) -> None:
legacy_version = await conn.fetchval(legacy_version_query)
if legacy_version != last_legacy_version:
raise RuntimeError("Legacy database is not on last version. Please upgrade the old "
"database with alembic or drop it completely first.")
if scheme != "sqlite":
await conn.execute(
"""
ALTER TABLE contact
DROP CONSTRAINT contact_user_fkey,
DROP CONSTRAINT contact_contact_fkey,
ADD CONSTRAINT contact_user_fkey FOREIGN KEY (contact) REFERENCES puppet(id)
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT contact_contact_fkey FOREIGN KEY ("user") REFERENCES "user"(tgid)
ON DELETE CASCADE ON UPDATE CASCADE
"""
)
await conn.execute(
"""
ALTER TABLE telethon_sessions
DROP CONSTRAINT telethon_sessions_pkey,
ADD CONSTRAINT telethon_sessions_pkey PRIMARY KEY (session_id)
"""
)
await conn.execute(
"""
ALTER TABLE telegram_file
DROP CONSTRAINT fk_file_thumbnail,
ADD CONSTRAINT fk_file_thumbnail
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
ON UPDATE CASCADE ON DELETE SET NULL
"""
)
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP DEFAULT")
await conn.execute("DROP SEQUENCE puppet_id_seq")
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP DEFAULT")
await conn.execute("DROP SEQUENCE bot_chat_id_seq")
await conn.execute("ALTER TABLE portal ALTER COLUMN config TYPE jsonb USING config::jsonb")
await conn.execute(
"ALTER TABLE telegram_file ALTER COLUMN decryption_info TYPE jsonb "
"USING decryption_info::jsonb"
)
await varchar_to_text(conn)
else:
await conn.execute(
"""CREATE TABLE telethon_sessions_new (
session_id TEXT PRIMARY KEY,
dc_id INTEGER,
server_address TEXT,
port INTEGER,
auth_key bytea
)"""
)
await conn.execute(
"""
INSERT INTO telethon_sessions_new (session_id, dc_id, server_address, port, auth_key)
SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions
"""
)
await conn.execute("DROP TABLE telethon_sessions")
await conn.execute("ALTER TABLE telethon_sessions_new RENAME TO telethon_sessions")
await update_state_store(conn, scheme)
await conn.execute('ALTER TABLE "user" ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false')
await conn.execute("ALTER TABLE puppet RENAME COLUMN matrix_registered TO is_registered")
await conn.execute("DROP TABLE telethon_version")
await conn.execute("DROP TABLE alembic_version")
async def update_state_store(conn: Connection, scheme: str) -> None:
# The Matrix state store already has more or less the correct schema, so set the version
await conn.execute("CREATE TABLE mx_version (version INTEGER PRIMARY KEY)")
await conn.execute("INSERT INTO mx_version (version) VALUES (2)")
if scheme != "sqlite":
# Also add the membership type on postgres
await conn.execute(
"CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock')"
)
await conn.execute(
"ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE membership "
"USING LOWER(membership)::membership"
)
else:
# On SQLite there's no custom type, but we still want to lowercase everything
await conn.execute("UPDATE mx_user_profile SET membership=LOWER(membership)")
async def varchar_to_text(conn: Connection) -> None:
columns_to_adjust = {
"user": ("mxid", "tg_username", "tg_phone"),
"portal": (
"peer_type", "mxid", "username", "title", "about", "photo_id", "avatar_url", "config"
),
"message": ("mxid", "mx_room"),
"puppet": (
"displayname", "username", "photo_id",
) + (
"access_token", "custom_mxid", "next_batch", "base_url"
),
"bot_chat": ("type",),
"telegram_file": ("id", "mxc", "mime_type", "thumbnail"),
# Phone is a bigint in the old schema, which is safe, but we don't do math on it,
# so let's change it to a string
"telethon_entities": ("session_id", "username", "name", "phone"),
"telethon_sent_files": ("session_id",),
"telethon_sessions": ("session_id", "server_address"),
"telethon_update_state": ("session_id",),
"mx_room_state": ("room_id",),
"mx_user_profile": ("room_id", "user_id", "displayname", "avatar_url"),
}
for table, columns in columns_to_adjust.items():
for column in columns:
await conn.execute(f'ALTER TABLE "{table}" ALTER COLUMN {column} TYPE TEXT')
async def create_v1_tables(conn: Connection) -> None:
await conn.execute(
"""CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
tgid BIGINT UNIQUE,
tg_username TEXT,
tg_phone TEXT,
is_bot BOOLEAN NOT NULL DEFAULT false,
saved_contacts INTEGER NOT NULL DEFAULT 0
)"""
)
await conn.execute(
"""CREATE TABLE portal (
tgid BIGINT,
tg_receiver BIGINT,
peer_type TEXT NOT NULL,
mxid TEXT UNIQUE,
avatar_url TEXT,
encrypted BOOLEAN NOT NULL DEFAULT false,
username TEXT,
title TEXT,
about TEXT,
photo_id TEXT,
megagroup BOOLEAN,
config jsonb,
PRIMARY KEY (tgid, tg_receiver)
)"""
)
await conn.execute(
"""CREATE TABLE message (
mxid TEXT,
mx_room TEXT,
tgid BIGINT NOT NULL,
tg_space BIGINT NOT NULL,
edit_index INTEGER NOT NULL,
redacted BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (tgid, tg_space, edit_index),
UNIQUE (mxid, mx_room, tg_space)
)"""
)
await conn.execute(
"""CREATE TABLE puppet (
id BIGINT PRIMARY KEY,
is_registered BOOLEAN NOT NULL DEFAULT false,
displayname TEXT,
displayname_source BIGINT,
displayname_contact BOOLEAN NOT NULL DEFAULT true,
displayname_quality INTEGER NOT NULL DEFAULT 0,
disable_updates BOOLEAN NOT NULL DEFAULT false,
username TEXT,
photo_id TEXT,
is_bot BOOLEAN,
access_token TEXT,
custom_mxid TEXT,
next_batch TEXT,
base_url TEXT
)"""
)
await conn.execute(
"""CREATE TABLE telegram_file (
id TEXT PRIMARY KEY,
mxc TEXT NOT NULL,
mime_type TEXT,
was_converted BOOLEAN NOT NULL DEFAULT false,
timestamp BIGINT NOT NULL DEFAULT 0,
size BIGINT,
width INTEGER,
height INTEGER,
thumbnail TEXT,
decryption_info jsonb,
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
ON UPDATE CASCADE ON DELETE SET NULL
)"""
)
await conn.execute(
"""CREATE TABLE bot_chat (
id BIGINT PRIMARY KEY,
type TEXT NOT NULL
)"""
)
await conn.execute(
"""CREATE TABLE user_portal (
"user" BIGINT,
portal BIGINT,
portal_receiver BIGINT,
PRIMARY KEY ("user", portal, portal_receiver),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal, portal_receiver) REFERENCES portal(tgid, tg_receiver)
ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE contact (
"user" BIGINT,
contact BIGINT,
PRIMARY KEY ("user", contact),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (contact) REFERENCES puppet(id) ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sessions (
session_id TEXT PRIMARY KEY,
dc_id INTEGER,
server_address TEXT,
port INTEGER,
auth_key bytea
)"""
)
await conn.execute(
"""CREATE TABLE telethon_entities (
session_id TEXT,
id BIGINT,
hash BIGINT NOT NULL,
username TEXT,
phone TEXT,
name TEXT,
PRIMARY KEY (session_id, id)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sent_files (
session_id TEXT,
md5_digest bytea,
file_size INTEGER,
type INTEGER,
id BIGINT,
hash BIGINT,
PRIMARY KEY (session_id, md5_digest, file_size, type)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_update_state (
session_id TEXT,
entity_id BIGINT,
pts BIGINT,
qts BIGINT,
date BIGINT,
seq BIGINT,
unread_count INTEGER,
PRIMARY KEY (session_id, entity_id)
)"""
)
+91 -68
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,96 +13,119 @@
#
# 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, Iterable, Tuple
from __future__ import annotations
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, BigInteger, Integer, String, func
from typing import Iterable, ClassVar, TYPE_CHECKING
from asyncpg import Record
from attr import dataclass
from mautrix.types import UserID
from mautrix.util.db import Base
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
class User(Base):
__tablename__ = "user"
mxid: UserID = Column(String, primary_key=True)
tgid: Optional[TelegramID] = Column(BigInteger, nullable=True, unique=True)
tg_username: str = Column(String, nullable=True)
tg_phone: str = Column(String, nullable=True)
saved_contacts: int = Column(Integer, default=0, nullable=False)
@dataclass
class User:
db: ClassVar[Database] = fake_db
mxid: UserID
tgid: TelegramID | None
tg_username: str | None
tg_phone: str | None
is_bot: bool
saved_contacts: int
@classmethod
def all_with_tgid(cls) -> Iterable['User']:
return cls._select_all(cls.c.tgid != None)
def _from_row(cls, row: Record | None) -> User | None:
if row is None:
return None
return cls(**row)
columns: ClassVar[str] = "mxid, tgid, tg_username, tg_phone, is_bot, saved_contacts"
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
return cls._select_one_or_none(cls.c.tgid == tgid)
async def get_by_tgid(cls, tgid: TelegramID) -> User | None:
q = f'SELECT {cls.columns} FROM "user" WHERE tgid=$1'
return cls._from_row(await cls.db.fetchrow(q, tgid))
@classmethod
def get_by_mxid(cls, mxid: UserID) -> Optional['User']:
return cls._select_one_or_none(cls.c.mxid == mxid)
async def get_by_mxid(cls, mxid: UserID) -> User | None:
q = f'SELECT {cls.columns} FROM "user" WHERE mxid=$1'
return cls._from_row(await cls.db.fetchrow(q, mxid))
@classmethod
def get_by_username(cls, username: str) -> Optional['User']:
return cls._select_one_or_none(func.lower(cls.c.tg_username) == username)
async def find_by_username(cls, username: str) -> User | None:
q = f'SELECT {cls.columns} FROM "user" WHERE lower(tg_username)=$1'
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@classmethod
async def all_with_tgid(cls) -> list[User]:
q = f'SELECT {cls.columns} FROM "user" WHERE tgid IS NOT NULL'
return [cls._from_row(row) for row in await cls.db.fetch(q)]
async def delete(self) -> None:
await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid)
@property
def contacts(self) -> Iterable[TelegramID]:
rows = self.db.execute(Contact.t.select().where(Contact.c.user == self.tgid))
for row in rows:
user, contact = row
yield contact
def _values(self):
return (
self.mxid, self.tgid, self.tg_username, self.tg_phone, self.is_bot, self.saved_contacts
)
@contacts.setter
def contacts(self, puppets: Iterable[TelegramID]) -> None:
with self.db.begin() as conn:
conn.execute(Contact.t.delete().where(Contact.c.user == self.tgid))
insert_puppets = [{"user": self.tgid, "contact": tgid} for tgid in puppets]
if insert_puppets:
conn.execute(Contact.t.insert(), insert_puppets)
async def save(self) -> None:
q = (
'UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, saved_contacts=$6 '
'WHERE mxid=$1'
)
await self.db.execute(q, *self._values)
@property
def portals(self) -> Iterable[Tuple[TelegramID, TelegramID]]:
rows = self.db.execute(UserPortal.t.select().where(UserPortal.c.user == self.tgid))
for row in rows:
user, portal, portal_receiver = row
yield (portal, portal_receiver)
async def insert(self) -> None:
q = (
'INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, saved_contacts) '
'VALUES ($1, $2, $3, $4, $5, $6)'
)
await self.db.execute(q, *self._values)
@portals.setter
def portals(self, portals: Iterable[Tuple[TelegramID, TelegramID]]) -> None:
with self.db.begin() as conn:
conn.execute(UserPortal.t.delete().where(UserPortal.c.user == self.tgid))
insert_portals = [{
"user": self.tgid,
"portal": tgid,
"portal_receiver": tg_receiver
} for tgid, tg_receiver in portals]
if insert_portals:
conn.execute(UserPortal.t.insert(), insert_portals)
async def get_contacts(self) -> list[TelegramID]:
rows = await self.db.fetch('SELECT contact FROM contact WHERE "user"=$1', self.tgid)
return [TelegramID(row["contact"]) for row in rows]
def delete(self) -> None:
super().delete()
self.portals = []
self.contacts = []
async def set_contacts(self, puppets: Iterable[TelegramID]) -> None:
columns = ["user", "contact"]
records = [(self.tgid, puppet_id) for puppet_id in puppets]
async with self.db.acquire() as conn, conn.transaction():
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
if self.db.scheme == "postgres":
await conn.copy_records_to_table("contact", records=records, columns=columns)
else:
q = 'INSERT INTO contact ("user", contact) VALUES ($1, $2)'
await conn.executemany(q, records)
async def get_portals(self) -> list[tuple[TelegramID, TelegramID]]:
q = 'SELECT portal, portal_receiver FROM user_portal WHERE "user"=$1'
rows = await self.db.fetch(q, self.tgid)
return [(TelegramID(row["portal"]), TelegramID(row["portal_receiver"])) for row in rows]
class UserPortal(Base):
__tablename__ = "user_portal"
async def set_portals(self, portals: Iterable[tuple[TelegramID, TelegramID]]) -> None:
columns = ["user", "portal", "portal_receiver"]
records = [(self.tgid, tgid, tg_receiver) for tgid, tg_receiver in portals]
async with self.db.acquire() as conn, conn.transaction():
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
if self.db.scheme == "postgres":
await conn.copy_records_to_table("user_portal", records=records, columns=columns)
else:
q = 'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3)'
await conn.executemany(q, records)
user: TelegramID = Column(BigInteger, ForeignKey("user.tgid", onupdate="CASCADE",
ondelete="CASCADE"), primary_key=True)
portal: TelegramID = Column(BigInteger, primary_key=True)
portal_receiver: TelegramID = Column(BigInteger, primary_key=True)
async def register_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
q = ('INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3) '
'ON CONFLICT ("user", portal, portal_receiver) DO NOTHING')
await self.db.execute(q, self.tgid, tgid, tg_receiver)
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
onupdate="CASCADE", ondelete="CASCADE"),)
class Contact(Base):
__tablename__ = "contact"
user: TelegramID = Column(BigInteger, ForeignKey("user.tgid"), primary_key=True)
contact: TelegramID = Column(BigInteger, ForeignKey("puppet.id"), primary_key=True)
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
q = 'DELETE FROM user_portal WHERE "user"=$1 AND portal=$2 AND portal_receiver=$3'
await self.db.execute(q, self.tgid, tgid, tg_receiver)
+4
View File
@@ -277,6 +277,10 @@ bridge:
archive_tag: null
# Whether or not mute status and tags should only be bridged when the portal room is created.
tag_only_on_create: true
# Should leaving the room on Matrix make the user leave on Telegram?
bridge_matrix_leave: true
# Should the user be kicked out of all portals when logging out of the bridge?
kick_on_logout: true
# Settings for backfilling messages from Telegram.
backfill:
# Whether or not the Telegram ghosts of logged in Matrix users should be
+1 -6
View File
@@ -1,7 +1,2 @@
from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram, init_mx
from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram
from .from_telegram import telegram_reply_to_matrix, telegram_to_matrix
from .. import context as c
def init(context: c.Context) -> None:
init_mx(context)
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,39 +13,77 @@
#
# 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, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING
import re
import logging
from __future__ import annotations
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
TypeMessageEntity, InputMessageEntityMentionName)
import re
from telethon.tl.types import MessageEntityItalic, TypeMessageEntity
from telethon.helpers import add_surrogate, del_surrogate
from telethon import TelegramClient
from mautrix.types import RoomID, MessageEventContent
from mautrix.util.logging import TraceLogger
from ... import puppet as pu
from ...types import TelegramID
from ...db import Message as DBMessage
from .parser import ParsedMessage, parse_html
from .parser import MatrixParser
if TYPE_CHECKING:
from ...context import Context
log: TraceLogger = logging.getLogger("mau.fmt.mx")
should_bridge_plaintext_highlights: bool = False
command_regex: Pattern = re.compile(r"^!([A-Za-z0-9@]+)")
not_command_regex: Pattern = re.compile(r"^\\(![A-Za-z0-9@]+)")
plain_mention_regex: Optional[Pattern] = None
command_regex = re.compile(r"^!([A-Za-z0-9@]+)")
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)")
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:
class FormatError(Exception):
pass
async def matrix_reply_to_telegram(
content: MessageEventContent, tg_space: TelegramID, room_id: RoomID | None = None
) -> TelegramID | None:
event_id = content.get_reply_to()
if not event_id:
return
content.trim_reply_fallback()
message = await DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
return message.tgid
return None
async def matrix_to_telegram(
client: TelegramClient, *, text: str | None = None, html: str | None = None
) -> tuple[str, list[TypeMessageEntity]]:
if html is not None:
return await _matrix_html_to_telegram(client, html)
elif text is not None:
return _matrix_text_to_telegram(text), []
else:
raise ValueError("text or html must be provided to convert formatting")
async def _matrix_html_to_telegram(
client: TelegramClient, html: str
) -> tuple[str, list[TypeMessageEntity]]:
try:
html = command_regex.sub(r"<command>\1</command>", html)
html = html.replace("\t", " " * 4)
html = not_command_regex.sub(r"\1", html)
parsed = await MatrixParser(client).parse(add_surrogate(html))
text = del_surrogate(parsed.text.strip())
text, entities = _cut_long_message(text, parsed.telegram_entities)
return text, entities
except Exception as e:
raise FormatError(f"Failed to convert Matrix format: {html}") from e
def _cut_long_message(
message: str, entities: list[TypeMessageEntity]
) -> tuple[str, list[TypeMessageEntity]]:
if len(message) > MAX_LENGTH:
message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
new_entities = []
@@ -60,112 +98,8 @@ def _cut_long_message(message: str, entities: List[TypeMessageEntity]) -> Parsed
return message, entities
class FormatError(Exception):
pass
def matrix_reply_to_telegram(content: MessageEventContent, tg_space: TelegramID,
room_id: Optional[RoomID] = None) -> Optional[TelegramID]:
event_id = content.get_reply_to()
if not event_id:
return
content.trim_reply_fallback()
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
return message.tgid
return None
async def matrix_to_telegram(client: TelegramClient, *, text: Optional[str] = None,
html: Optional[str] = None) -> ParsedMessage:
if html is not None:
text, entities = _matrix_html_to_telegram(html)
elif text is not None:
text, entities = _matrix_text_to_telegram(text)
else:
raise ValueError("text or html must be provided to convert formatting")
await _fix_name_mentions(client, entities)
return text, entities
def _matrix_html_to_telegram(html: str) -> ParsedMessage:
try:
html = command_regex.sub(r"<command>\1</command>", html)
html = html.replace("\t", " " * 4)
html = not_command_regex.sub(r"\1", html)
if should_bridge_plaintext_highlights:
html = plain_mention_regex.sub(_plain_mention_to_html, html)
text, entities = parse_html(add_surrogate(html))
text = del_surrogate(text.strip())
text, entities = _cut_long_message(text, entities)
return text, entities
except Exception as e:
raise FormatError(f"Failed to convert Matrix format: {html}") from e
def _matrix_text_to_telegram(text: str) -> ParsedMessage:
def _matrix_text_to_telegram(text: str) -> str:
text = command_regex.sub(r"/\1", text)
text = text.replace("\t", " " * 4)
text = not_command_regex.sub(r"\1", text)
if should_bridge_plaintext_highlights:
entities, pmr_replacer = _plain_mention_to_text()
text = plain_mention_regex.sub(pmr_replacer, text)
else:
entities = []
return text, entities
async def _fix_name_mentions(client: TelegramClient, entities: List[TypeMessageEntity]) -> None:
for index in reversed(range(len(entities))):
entity = entities[index]
if isinstance(entity, (MessageEntityMentionName, InputMessageEntityMentionName)):
try:
user = await client.get_input_entity(entity.user_id)
except (ValueError, TypeError) as e:
log.trace(f"Dropping mention of {entity.user_id}: {e}")
del entities[index]
else:
entities[index] = InputMessageEntityMentionName(entity.offset, entity.length, user)
def _plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[Match], str]]:
entities = []
def replacer(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
offset = match.start()
length = match.end() - offset
if puppet.username:
entity = MessageEntityMention(offset, length)
text = f"@{puppet.username}"
else:
entity = MessageEntityMentionName(offset, length, user_id=puppet.tgid)
text = puppet.displayname
entities.append(entity)
return text
return "".join(match.groups())
return entities, replacer
def _plain_mention_to_html(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
return (f"{match.group(1)}"
f"<a href='https://matrix.to/#/{puppet.mxid}'>"
f"{puppet.displayname}"
"</a>")
return "".join(match.groups())
def init_mx(context: "Context") -> None:
global plain_mention_regex, should_bridge_plaintext_highlights
config = context.config
dn_template = config["bridge.displayname_template"]
dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
plain_mention_regex = re.compile(f"^({dn_template})")
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"]
return text
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,77 +13,80 @@
#
# 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, Tuple, Optional
from __future__ import annotations
from telethon.tl.types import TypeMessageEntity
import logging
from telethon import TelegramClient
from mautrix.types import UserID, RoomID
from mautrix.util.formatter import MatrixParser as BaseMatrixParser, RecursionContext
from mautrix.util.formatter.html_reader_htmlparser import read_html, HTMLNode
from mautrix.util.logging import TraceLogger
from ... import user as u, puppet as pu, portal as po
from .telegram_message import TelegramMessage, TelegramEntityType
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
def parse_html(input_html: str) -> ParsedMessage:
msg = MatrixParser.parse(input_html)
return msg.text, msg.telegram_entities
log: TraceLogger = logging.getLogger("mau.fmt.mx")
class MatrixParser(BaseMatrixParser[TelegramMessage]):
e = TelegramEntityType
fs = TelegramMessage
read_html = read_html
client: TelegramClient
@classmethod
def custom_node_to_fstring(cls, node: HTMLNode, ctx: RecursionContext
) -> Optional[TelegramMessage]:
msg = cls.tag_aware_parse_node(node, ctx)
def __init__(self, client: TelegramClient) -> None:
self.client = client
self.read_html = read_html
async def custom_node_to_fstring(
self, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage | None:
msg = await self.tag_aware_parse_node(node, ctx)
if node.tag == "command":
msg.format(TelegramEntityType.COMMAND)
return None
@classmethod
def user_pill_to_fstring(cls, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
user = (pu.Puppet.deprecated_sync_get_by_mxid(user_id)
or u.User.get_by_mxid(user_id, create=False))
async def user_pill_to_fstring(self, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
user = (await pu.Puppet.get_by_mxid(user_id)
or await 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)
if user.tg_username:
return TelegramMessage(f"@{user.tg_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)
msg = TelegramMessage(displayname)
try:
input_entity = self.client.get_input_entity(user.tgid)
except (ValueError, TypeError) as e:
log.trace(f"Dropping mention of {user.tgid}: {e}")
else:
msg = msg.format(TelegramEntityType.MENTION_NAME, user_id=input_entity)
return msg
@classmethod
def url_to_fstring(cls, msg: TelegramMessage, url: str) -> TelegramMessage:
async def url_to_fstring(self, msg: TelegramMessage, url: str) -> TelegramMessage:
if url == msg.text:
return msg.format(cls.e.URL)
return msg.format(self.e.URL)
else:
return msg.format(cls.e.INLINE_URL, url=url)
return msg.format(self.e.INLINE_URL, url=url)
@classmethod
def room_pill_to_fstring(cls, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage:
async def room_pill_to_fstring(self, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage:
username = po.Portal.get_username_from_mx_alias(room_id)
portal = po.Portal.find_by_username(username)
portal = await 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)
async def header_to_fstring(self, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
children = await self.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)
async def blockquote_to_fstring(
self, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage:
msg = await self.tag_aware_parse_node(node, ctx)
children = msg.trim().split("\n")
children = [child.prepend("> ") for child in children]
return TelegramMessage.join(children, "\n")
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,7 +13,9 @@
#
# 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, Union, Any, List, Type, Dict
from __future__ import annotations
from typing import Any, Type
from enum import Enum
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
@@ -41,7 +43,7 @@ class TelegramEntityType(Enum):
INLINE_CODE = Code
BLOCKQUOTE = Blockquote
MENTION = Mention
MENTION_NAME = MentionName
MENTION_NAME = InputMentionName
COMMAND = Command
USER_MENTION = 1
@@ -52,15 +54,15 @@ class TelegramEntityType(Enum):
class TelegramEntity(SemiAbstractEntity):
internal: TypeMessageEntity
def __init__(self, type: Union[TelegramEntityType, Type[TypeMessageEntity]],
offset: int, length: int, extra_info: Dict[str, Any]) -> None:
def __init__(self, type: 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']:
def copy(self) -> TelegramEntity:
extra_info = {}
if isinstance(self.internal, Pre):
extra_info["language"] = self.internal.language
@@ -95,5 +97,5 @@ class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]):
entity_class = TelegramEntity
@property
def telegram_entities(self) -> List[TypeMessageEntity]:
def telegram_entities(self) -> list[TypeMessageEntity]:
return [entity.internal for entity in self.entities]
+42 -44
View File
@@ -13,7 +13,8 @@
#
# 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, TYPE_CHECKING
from __future__ import annotations
from html import escape
import logging
import re
@@ -29,47 +30,45 @@ from telethon.tl.custom import Message
from telethon.errors import RPCError
from telethon.helpers import add_surrogate, del_surrogate
from mautrix.errors import MatrixRequestError
from mautrix.appservice import IntentAPI
from mautrix.types import (TextMessageEventContent, RelatesTo, RelationType, Format, MessageType,
MessageEvent, EventType)
EventType)
from .. import user as u, puppet as pu, portal as po
from .. import user as u, puppet as pu, portal as po, abstract_user as au
from ..types import TelegramID
from ..db import Message as DBMessage
if TYPE_CHECKING:
from ..abstract_user import AbstractUser
log: logging.Logger = logging.getLogger("mau.fmt.tg")
def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Optional[RelatesTo]:
async def telegram_reply_to_matrix(evt: Message, source: au.AbstractUser) -> RelatesTo | None:
if evt.reply_to:
space = (evt.peer_id.channel_id
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
msg = await DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
if msg:
return RelatesTo(rel_type=RelationType.REPLY, event_id=msg.mxid)
return None
async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventContent,
async def _add_forward_header(source: au.AbstractUser, content: TextMessageEventContent,
fwd_from: MessageFwdHeader) -> None:
if not content.formatted_body or content.format != Format.HTML:
content.format = Format.HTML
content.formatted_body = escape(content.body)
fwd_from_html, fwd_from_text = None, None
if isinstance(fwd_from.from_id, PeerUser):
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id))
user = await u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id))
if user:
fwd_from_text = user.displayname or user.mxid
fwd_from_html = (f"<a href='https://matrix.to/#/{user.mxid}'>"
f"{escape(fwd_from_text)}</a>")
if not fwd_from_text:
puppet = pu.Puppet.get(TelegramID(fwd_from.from_id.user_id), create=False)
puppet = await pu.Puppet.get_by_tgid(
TelegramID(fwd_from.from_id.user_id), create=False
)
if puppet and puppet.displayname:
fwd_from_text = puppet.displayname or puppet.mxid
fwd_from_html = (f"<a href='https://matrix.to/#/{puppet.mxid}'>"
@@ -86,7 +85,7 @@ async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventC
elif isinstance(fwd_from.from_id, (PeerChannel, PeerChat)):
from_id = (fwd_from.from_id.chat_id if isinstance(fwd_from.from_id, PeerChat)
else fwd_from.from_id.channel_id)
portal = po.Portal.get_by_tgid(TelegramID(from_id))
portal = await po.Portal.get_by_tgid(TelegramID(from_id))
if portal and portal.title:
fwd_from_text = portal.title
if portal.alias:
@@ -116,13 +115,13 @@ async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventC
f"<tg-forward><blockquote>{content.formatted_body}</blockquote></tg-forward>")
async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventContent, evt: Message,
main_intent: IntentAPI):
async def _add_reply_header(source: au.AbstractUser, content: TextMessageEventContent,
evt: Message, main_intent: IntentAPI) -> None:
space = (evt.peer_id.channel_id
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
msg = await DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
if not msg:
return
@@ -140,11 +139,11 @@ async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventCon
log.exception("Failed to get event to add reply fallback")
async def telegram_to_matrix(evt: Message, source: "AbstractUser",
main_intent: Optional[IntentAPI] = None,
prefix_text: Optional[str] = None, prefix_html: Optional[str] = None,
async def telegram_to_matrix(evt: Message, source: au.AbstractUser,
main_intent: IntentAPI | None = None,
prefix_text: str | None = None, prefix_html: str | None = None,
override_text: str = None,
override_entities: List[TypeMessageEntity] = None,
override_entities: list[TypeMessageEntity] = None,
no_reply_fallback: bool = False) -> TextMessageEventContent:
content = TextMessageEventContent(
msgtype=MessageType.TEXT,
@@ -153,7 +152,7 @@ async def telegram_to_matrix(evt: Message, source: "AbstractUser",
entities = override_entities or evt.entities
if entities:
content.format = Format.HTML
content.formatted_body = _telegram_entities_to_matrix_catch(content.body, entities)
content.formatted_body = await _telegram_entities_to_matrix_catch(content.body, entities)
if prefix_html:
if not content.formatted_body:
@@ -183,9 +182,9 @@ async def telegram_to_matrix(evt: Message, source: "AbstractUser",
return content
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
async def _telegram_entities_to_matrix_catch(text: str, entities: list[TypeMessageEntity]) -> str:
try:
return _telegram_entities_to_matrix(text, entities)
return await _telegram_entities_to_matrix(text, entities)
except Exception:
log.exception("Failed to convert Telegram format:\n"
"message=%s\n"
@@ -194,8 +193,8 @@ def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEnti
return "[failed conversion in _telegram_entities_to_matrix]"
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity],
offset: int = 0, length: int = None) -> str:
async def _telegram_entities_to_matrix(text: str, entities: list[TypeMessageEntity],
offset: int = 0, length: int = None) -> str:
if not entities:
return escape(text)
if length is None:
@@ -212,7 +211,7 @@ def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity],
continue
skip_entity = False
entity_text = _telegram_entities_to_matrix(
entity_text = await _telegram_entities_to_matrix(
text=text[relative_offset:relative_offset + entity.length],
entities=entities[i + 1:], offset=entity.offset, length=entity.length)
entity_type = type(entity)
@@ -234,16 +233,17 @@ def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity],
elif entity_type == MessageEntityPre:
skip_entity = _parse_pre(html, entity_text, entity.language)
elif entity_type == MessageEntityMention:
skip_entity = _parse_mention(html, entity_text)
skip_entity = await _parse_mention(html, entity_text)
elif entity_type == MessageEntityMentionName:
skip_entity = _parse_name_mention(html, entity_text, TelegramID(entity.user_id))
skip_entity = await _parse_name_mention(html, entity_text, TelegramID(entity.user_id))
elif entity_type == MessageEntityEmail:
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
elif entity_type in (MessageEntityTextUrl, MessageEntityUrl):
skip_entity = _parse_url(html, entity_text,
entity.url if entity_type == MessageEntityTextUrl else None)
skip_entity = await _parse_url(
html, entity_text, entity.url if entity_type == MessageEntityTextUrl else None
)
elif entity_type == MessageEntityBotCommand:
html.append(f"<font color='blue'>!{entity_text[1:]}</font>")
html.append(f"<font color='blue'>{entity_text}</font>")
elif entity_type in (MessageEntityHashtag, MessageEntityCashtag, MessageEntityPhone):
html.append(f"<font color='blue'>{entity_text}</font>")
else:
@@ -254,24 +254,22 @@ def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity],
return "".join(html)
def _parse_pre(html: List[str], entity_text: str, language: str) -> bool:
def _parse_pre(html: list[str], entity_text: str, language: str) -> bool:
if language:
html.append("<pre>"
f"<code class='language-{language}'>{entity_text}</code>"
"</pre>")
html.append(f"<pre><code class='language-{language}'>{entity_text}</code></pre>")
else:
html.append(f"<pre><code>{entity_text}</code></pre>")
return False
def _parse_mention(html: List[str], entity_text: str) -> bool:
async def _parse_mention(html: list[str], entity_text: str) -> bool:
username = entity_text[1:]
user = u.User.find_by_username(username) or pu.Puppet.find_by_username(username)
user = await u.User.find_by_username(username) or await pu.Puppet.find_by_username(username)
if user:
mxid = user.mxid
else:
portal = po.Portal.find_by_username(username)
portal = await po.Portal.find_by_username(username)
mxid = portal.alias or portal.mxid if portal else None
if mxid:
@@ -281,12 +279,12 @@ def _parse_mention(html: List[str], entity_text: str) -> bool:
return False
def _parse_name_mention(html: List[str], entity_text: str, user_id: TelegramID) -> bool:
user = u.User.get_by_tgid(user_id)
async def _parse_name_mention(html: list[str], entity_text: str, user_id: TelegramID) -> bool:
user = await u.User.get_by_tgid(user_id)
if user:
mxid = user.mxid
else:
puppet = pu.Puppet.get(user_id, create=False)
puppet = await pu.Puppet.get_by_tgid(user_id, create=False)
mxid = puppet.mxid if puppet else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
@@ -299,7 +297,7 @@ message_link_regex = re.compile(r"https?://t(?:elegram)?\.(?:me|dog)/"
r"([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
async def _parse_url(html: list[str], entity_text: str, url: str) -> bool:
url = escape(url) if url else entity_text
if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
url = "http://" + url
@@ -309,9 +307,9 @@ def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
group, msgid_str = message_link_match.groups()
msgid = int(msgid_str)
portal = po.Portal.find_by_username(group)
portal = await po.Portal.find_by_username(group)
if portal:
message = DBMessage.get_one_by_tgid(TelegramID(msgid), portal.tgid)
message = await DBMessage.get_one_by_tgid(TelegramID(msgid), portal.tgid)
if message:
url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}"
+68 -67
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,13 +13,17 @@
#
# 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, Set, Tuple, Union, Iterable, TYPE_CHECKING
from __future__ import annotations
from typing import Iterable, TYPE_CHECKING
from mautrix.bridge import BaseMatrixHandler
from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType,
ReceiptEventContent, PresenceEvent, PresenceState, TypingEvent,
StateEvent, RedactionEvent, RoomNameStateEventContent,
RoomAvatarStateEventContent, RoomTopicStateEventContent,
StateEvent, RedactionEvent,
RoomNameStateEventContent as NameContent,
RoomAvatarStateEventContent as AvatarContent,
RoomTopicStateEventContent as TopicContent,
MemberStateEventContent, TextMessageEventContent,
MessageType)
from mautrix.errors import MatrixError
@@ -27,28 +31,22 @@ from mautrix.errors import MatrixError
from . import user as u, portal as po, puppet as pu, commands as com
if TYPE_CHECKING:
from .context import Context
from .bot import Bot
RoomMetaStateEventContent = Union[RoomNameStateEventContent, RoomAvatarStateEventContent,
RoomTopicStateEventContent]
from .__main__ import TelegramBridge
class MatrixHandler(BaseMatrixHandler):
bot: 'Bot'
commands: 'com.CommandProcessor'
previously_typing: Dict[RoomID, Set[UserID]]
commands: com.CommandProcessor
_previously_typing: dict[RoomID, set[UserID]]
def __init__(self, context: 'Context') -> None:
prefix, suffix = context.config["bridge.username_template"].format(userid=":").split(":")
homeserver = context.config["homeserver.domain"]
def __init__(self, bridge: 'TelegramBridge') -> None:
prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":")
homeserver = bridge.config["homeserver.domain"]
self.user_id_prefix = f"@{prefix}"
self.user_id_suffix = f"{suffix}:{homeserver}"
super().__init__(command_processor=com.CommandProcessor(context), bridge=context.bridge)
super().__init__(command_processor=com.CommandProcessor(bridge), bridge=bridge)
self.bot = context.bot
self.previously_typing = {}
self._previously_typing = {}
async def handle_puppet_invite(self, room_id: RoomID, puppet: pu.Puppet, inviter: u.User,
event_id: EventID) -> None:
@@ -58,7 +56,7 @@ class MatrixHandler(BaseMatrixHandler):
await intent.error_and_leave(
room_id, text="Please log in before inviting Telegram puppets.")
return
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if portal:
if portal.peer_type == "user":
await intent.error_and_leave(
@@ -81,7 +79,9 @@ class MatrixHandler(BaseMatrixHandler):
return
await intent.join_room(room_id)
portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
portal = await po.Portal.get_by_tgid(
puppet.tgid, tg_receiver=inviter.tgid, peer_type="user"
)
if portal.mxid:
try:
await portal.invite_to_matrix(inviter.mxid)
@@ -115,21 +115,21 @@ class MatrixHandler(BaseMatrixHandler):
await intent.send_notice(room_id, "This puppet will remain inactive until a "
"Telegram chat is created for this room.")
async def handle_invite(self, room_id: RoomID, user_id: UserID, inviter: 'u.User',
async def handle_invite(self, room_id: RoomID, user_id: UserID, inviter: u.User,
event_id: EventID) -> None:
user = u.User.get_by_mxid(user_id, create=False)
user = await u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if user and await user.has_full_access(allow_bot=True):
if portal and portal.allow_bridging:
await portal.invite_telegram(inviter, user)
async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started()
user = await u.User.get_and_start_by_mxid(user_id)
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.allow_bridging:
return
@@ -147,16 +147,13 @@ class MatrixHandler(BaseMatrixHandler):
if await user.is_logged_in() or portal.has_bot:
await portal.join_matrix(user, event_id)
async def get_leave_handle_info(self) -> Tuple[po.Portal, u.User]:
pass
async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
self.log.debug(f"{user_id} left {room_id}")
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.allow_bridging:
return
user = u.User.get_by_mxid(user_id, create=False)
user = await u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
@@ -166,7 +163,7 @@ class MatrixHandler(BaseMatrixHandler):
reason: str, event_id: EventID) -> None:
action = "banned" if ban else "kicked"
self.log.debug(f"{user_id} was {action} from {room_id} by {sender} for {reason}")
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.allow_bridging:
return
@@ -176,7 +173,7 @@ class MatrixHandler(BaseMatrixHandler):
await portal.unbridge()
return
sender = u.User.get_by_mxid(sender, create=False)
sender = await u.User.get_by_mxid(sender, create=False)
if not sender:
return
await sender.ensure_started()
@@ -189,7 +186,7 @@ class MatrixHandler(BaseMatrixHandler):
await portal.kick_matrix(puppet, sender)
return
user = u.User.get_by_mxid(user_id, create=False)
user = await u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
@@ -211,25 +208,23 @@ class MatrixHandler(BaseMatrixHandler):
event_id: EventID) -> None:
await self.handle_kick_ban(True, room_id, user_id, banned_by, reason, event_id)
@staticmethod
async def allow_message(user: 'u.User') -> bool:
async def allow_message(self, user: u.User) -> bool:
return user.relaybot_whitelisted
@staticmethod
async def allow_command(user: 'u.User') -> bool:
async def allow_command(self, user: u.User) -> bool:
return user.whitelisted
@staticmethod
async def allow_bridging_message(user: 'u.User', portal: 'po.Portal') -> bool:
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()
sender = await u.User.get_and_start_by_mxid(evt.sender)
if not sender.relaybot_whitelisted:
return
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal or not portal.allow_bridging:
return
@@ -237,23 +232,28 @@ class MatrixHandler(BaseMatrixHandler):
@staticmethod
async def handle_power_levels(evt: StateEvent) -> None:
portal = po.Portal.get_by_mxid(evt.room_id)
sender = await u.User.get_by_mxid(evt.sender).ensure_started()
portal = await po.Portal.get_by_mxid(evt.room_id)
sender = await u.User.get_and_start_by_mxid(evt.sender)
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
await portal.handle_matrix_power_levels(sender, evt.content.users,
evt.unsigned.prev_content.users,
evt.event_id)
@staticmethod
async def handle_room_meta(evt_type: EventType, room_id: RoomID, sender_mxid: UserID,
content: RoomMetaStateEventContent, event_id: EventID) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
async def handle_room_meta(
evt_type: EventType,
room_id: RoomID,
sender_mxid: UserID,
content: NameContent | AvatarContent | TopicContent,
event_id: EventID
) -> None:
portal = await po.Portal.get_by_mxid(room_id)
sender = await u.User.get_and_start_by_mxid(sender_mxid)
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
handler, content_type, content_key = {
EventType.ROOM_NAME: (portal.handle_matrix_title, RoomNameStateEventContent, "name"),
EventType.ROOM_TOPIC: (portal.handle_matrix_about, RoomTopicStateEventContent, "topic"),
EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, RoomAvatarStateEventContent, "url"),
EventType.ROOM_NAME: (portal.handle_matrix_title, NameContent, "name"),
EventType.ROOM_TOPIC: (portal.handle_matrix_about, TopicContent, "topic"),
EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, AvatarContent, "url"),
}[evt_type]
if not isinstance(content, content_type):
return
@@ -261,10 +261,10 @@ class MatrixHandler(BaseMatrixHandler):
@staticmethod
async def handle_room_pin(room_id: RoomID, sender_mxid: UserID,
new_events: Set[str], old_events: Set[str],
new_events: set[str], old_events: set[str],
event_id: EventID) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
portal = await po.Portal.get_by_mxid(room_id)
sender = await u.User.get_and_start_by_mxid(sender_mxid)
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
if not new_events:
await portal.handle_matrix_unpin_all(sender, event_id)
@@ -276,7 +276,7 @@ class MatrixHandler(BaseMatrixHandler):
@staticmethod
async def handle_room_upgrade(room_id: RoomID, sender: UserID, new_room_id: RoomID,
event_id: EventID) -> None:
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if portal and portal.allow_bridging:
await portal.handle_matrix_upgrade(sender, new_room_id, event_id)
@@ -287,45 +287,45 @@ class MatrixHandler(BaseMatrixHandler):
if profile.displayname == prev_profile.displayname:
return
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.has_bot or not portal.allow_bridging:
return
user = await u.User.get_by_mxid(user_id).ensure_started()
user = await u.User.get_and_start_by_mxid(user_id)
if await user.needs_relaybot(portal):
await portal.name_change_matrix(user, profile.displayname, prev_profile.displayname,
event_id)
@staticmethod
def parse_read_receipts(content: ReceiptEventContent) -> Iterable[Tuple[UserID, EventID]]:
def parse_read_receipts(content: ReceiptEventContent) -> Iterable[tuple[UserID, EventID]]:
return ((user_id, event_id)
for event_id, receipts in content.items()
for user_id in receipts.get(ReceiptType.READ, {}))
@staticmethod
async def handle_read_receipts(room_id: RoomID, receipts: Iterable[Tuple[UserID, EventID]]
async def handle_read_receipts(room_id: RoomID, receipts: Iterable[tuple[UserID, EventID]]
) -> None:
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.allow_bridging:
return
for user_id, event_id in receipts:
user = u.User.get_by_mxid(user_id, check_db=False, create=False)
user = await u.User.get_by_mxid(user_id, check_db=False, create=False)
if user and await user.is_logged_in():
await portal.mark_read(user, event_id)
@staticmethod
async def handle_presence(user_id: UserID, presence: PresenceState) -> None:
user = u.User.get_by_mxid(user_id, check_db=False, create=False)
user = await u.User.get_by_mxid(user_id, check_db=False, create=False)
if user and await user.is_logged_in():
await user.set_presence(presence == PresenceState.ONLINE)
async def handle_typing(self, room_id: RoomID, now_typing: Set[UserID]) -> None:
portal = po.Portal.get_by_mxid(room_id)
async def handle_typing(self, room_id: RoomID, now_typing: set[UserID]) -> None:
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.allow_bridging:
return
previously_typing = self.previously_typing.get(room_id, set())
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
@@ -333,14 +333,15 @@ class MatrixHandler(BaseMatrixHandler):
if is_typing and was_typing:
continue
user = u.User.get_by_mxid(user_id, check_db=False, create=False)
user = await u.User.get_by_mxid(user_id, check_db=False, create=False)
if user and await user.is_logged_in():
await portal.set_typing(user, is_typing)
self.previously_typing[room_id] = now_typing
self._previously_typing[room_id] = now_typing
async def handle_ephemeral_event(self, evt: Union[ReceiptEvent, PresenceEvent, TypingEvent]
) -> None:
async def handle_ephemeral_event(
self, evt: ReceiptEvent | PresenceEvent | TypingEvent
) -> None:
if evt.type == EventType.RECEIPT:
await self.handle_read_receipts(evt.room_id, self.parse_read_receipts(evt.content))
elif evt.type == EventType.PRESENCE:
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
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
@@ -1,15 +0,0 @@
from typing import Union
from .base import BasePortal
from .matrix import PortalMatrix
from .metadata import PortalMetadata
from .telegram import PortalTelegram
from ..context import Context
Portal = Union[BasePortal, PortalMatrix, PortalMetadata, PortalTelegram]
def init(context: Context) -> None:
pass
__all__ = ["Portal", "init"]
-551
View File
@@ -1,551 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2020 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, Set, Iterable, TYPE_CHECKING
from abc import ABC, abstractmethod
from datetime import datetime
import asyncio
import logging
import json
from telethon.tl.functions.messages import ExportChatInviteRequest
from telethon.tl.types import (Channel, ChannelFull, Chat, ChatFull, 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, PhotoSizeProgressive, PhotoSizeEmpty)
from mautrix.errors import MatrixRequestError, IntentError
from mautrix.appservice import AppService, IntentAPI
from mautrix.types import (RoomID, RoomAlias, UserID, EventID, EventType,
PowerLevelStateEventContent, ContentURI)
from mautrix.util.simple_template import SimpleTemplate
from mautrix.util.simple_lock import SimpleLock
from mautrix.util.logging import TraceLogger
from mautrix.bridge import BasePortal as MautrixBasePortal
from ..types import TelegramID
from ..context import Context
from ..db import Portal as DBPortal, Message as DBMessage
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 ..matrix import MatrixHandler
from . import Portal
TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
TypeChatPhoto = Union[ChatPhoto, ChatPhotoEmpty, Photo, PhotoEmpty]
InviteList = Union[UserID, List[UserID]]
config: Optional['Config'] = None
class BasePortal(MautrixBasePortal, ABC):
base_log: TraceLogger = logging.getLogger("mau.portal")
az: AppService = None
bot: 'Bot' = None
loop: asyncio.AbstractEventLoop = None
matrix: 'MatrixHandler' = None
# Config cache
filter_mode: str = None
filter_list: List[int] = None
max_initial_member_sync: int = -1
sync_channel_members: bool = True
sync_matrix_state: bool = True
public_portals: bool = False
private_chat_portal_meta: 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]
avatar_url: Optional[ContentURI]
encrypted: bool
deleted: bool
backfill_lock: SimpleLock
backfill_method_lock: asyncio.Lock
backfill_leave: Optional[Set[IntentAPI]]
log: TraceLogger
alias: Optional[RoomAlias]
dedup: PortalDedup
send_lock: PortalSendLock
_pin_lock: asyncio.Lock
_db_instance: DBPortal
_main_intent: Optional[IntentAPI]
_room_create_lock: asyncio.Lock
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, avatar_url: Optional[ContentURI] = None,
encrypted: Optional[bool] = False, 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.avatar_url = avatar_url
self.encrypted = encrypted
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.backfill_lock = SimpleLock("Waiting for backfilling to finish before handling %s",
log=self.log)
self.backfill_method_lock = asyncio.Lock()
self.backfill_leave = None
self.dedup = PortalDedup(self)
self.send_lock = PortalSendLock()
self._pin_lock = asyncio.Lock()
if tgid:
self.by_tgid[self.tgid_full] = self
if mxid:
self.by_mxid[mxid] = self
# region Properties
@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 name(self) -> str:
return self.title
@property
def alias(self) -> Optional[RoomAlias]:
if not self.username:
return None
return RoomAlias(f"#{self.alias_localpart}:{self.hs_domain}")
@property
def alias_localpart(self) -> Optional[str]:
if not self.username:
return None
return self.alias_template.format(self.username)
@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 is_direct(self) -> bool:
return self.peer_type == "user"
@property
def has_bot(self) -> bool:
return (bool(self.bot)
and (self.bot.is_in_chat(self.tgid)
or (self.peer_type == "user" and self.tg_receiver == self.bot.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 _photo_size_key(photo: TypePhotoSize) -> int:
if isinstance(photo, PhotoSize):
return photo.size
elif isinstance(photo, PhotoSizeProgressive):
return max(photo.sizes)
elif isinstance(photo, PhotoSizeEmpty):
return 0
else:
return len(photo.bytes)
@classmethod
def _get_largest_photo_size(cls, photo: Union[Photo, Document]
) -> Tuple[Optional[InputPhotoFileLocation],
Optional[TypePhotoSize]]:
if not photo or isinstance(photo, PhotoEmpty) or (isinstance(photo, Document)
and not photo.thumbs):
return None, None
largest = max(photo.thumbs if isinstance(photo, Document) else photo.sizes,
key=cls._photo_size_key)
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}", t_class=EventType.Class.STATE)
return await self.main_intent.state_store.has_power_level(self.mxid, user.mxid, 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', uses: Optional[int] = None,
expire: Optional[datetime] = None) -> 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),
expire_date=expire, usage_limit=uses))
return link.link
# endregion
# region Matrix room cleanup
async def get_authenticated_matrix_users(self) -> List[UserID]:
try:
members = await self.main_intent.get_room_members(self.mxid)
except MatrixRequestError:
return []
authenticated: List[UserID] = []
has_bot = self.has_bot
for member in members:
if p.Puppet.get_id_from_mxid(member) or member == self.az.bot_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.mxid)
return authenticated
async def cleanup_portal(self, message: str, puppets_only: bool = False, delete: bool = True
) -> None:
if self.username:
try:
await self.main_intent.remove_room_alias(self.alias_localpart)
except (MatrixRequestError, IntentError):
self.log.warning("Failed to remove alias when cleaning up room", exc_info=True)
await self.cleanup_room(self.main_intent, self.mxid, message, puppets_only)
if delete:
await 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), avatar_url=self.avatar_url,
encrypted=self.encrypted)
async 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, megagroup=self.megagroup,
config=json.dumps(self.local_config), avatar_url=self.avatar_url,
encrypted=self.encrypted)
async def delete(self) -> None:
self.delete_sync()
def delete_sync(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()
DBMessage.delete_all(self.mxid)
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,
avatar_url=db_portal.avatar_url, encrypted=db_portal.encrypted,
db_instance=db_portal)
# endregion
# region Class instance lookup
@classmethod
def all(cls) -> Iterable['Portal']:
for db_portal in DBPortal.all():
try:
yield cls.by_tgid[(db_portal.tgid, db_portal.tg_receiver)]
except KeyError:
yield cls.from_db(db_portal)
@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
username = username.lower()
for _, portal in cls.by_tgid.items():
if portal.username and portal.username.lower() == username:
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']:
if peer_type == "user" and tg_receiver is None:
raise ValueError("tg_receiver is required when peer_type is \"user\"")
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:
cls.log.info(f"Creating portal for {peer_type} {tgid} (receiver {tg_receiver})")
# TODO enable this for non-release builds
# (or add better wrong peer type error handling)
# if peer_type == "chat":
# import traceback
# cls.log.info("Chat portal stack trace:\n" + "".join(traceback.format_stack()))
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) -> None:
pass
@abstractmethod
async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None,
invites: InviteList = None, update_if_exists: bool = True
) -> Optional[RoomID]:
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, sender: Optional['p.Puppet'] = None,
save: bool = False) -> bool:
pass
@abstractmethod
async def _update_avatar(self, user: 'AbstractUser', photo: Union[TypeChatPhoto],
sender: Optional['p.Puppet'] = None, save: bool = False) -> bool:
pass
@abstractmethod
def _migrate_and_save_telegram(self, new_id: TelegramID) -> None:
pass
@abstractmethod
async def update_bridge_info(self) -> None:
pass
@abstractmethod
def handle_matrix_power_levels(self, sender: 'u.User', new_levels: Dict[UserID, int],
old_levels: Dict[UserID, int], event_id: Optional[EventID]
) -> Awaitable[None]:
pass
@abstractmethod
def backfill(self, source: 'AbstractUser', is_initial: bool = False,
limit: Optional[int] = None, last_id: Optional[int] = None) -> Awaitable[None]:
pass
@abstractmethod
async def _send_delivery_receipt(self, event_id: EventID, room_id: Optional[RoomID] = None
) -> None:
pass
# endregion
def init(context: Context) -> None:
global config
BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core
BasePortal.matrix = context.mx
MautrixBasePortal.bridge = context.bridge
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.private_chat_portal_meta = config["bridge.private_chat_portal_meta"]
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}")
-680
View File
@@ -1,680 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2020 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, Optional, Union, Any, TYPE_CHECKING
from html import escape as escape_html
from string import Template
from abc import ABC
import magic
from telethon.tl.functions.messages import (EditChatPhotoRequest, EditChatTitleRequest,
UpdatePinnedMessageRequest, SetTypingRequest,
EditChatAboutRequest, UnpinAllMessagesRequest)
from telethon.tl.functions.channels import EditPhotoRequest, EditTitleRequest, JoinChannelRequest
from telethon.errors import (ChatNotModifiedError, PhotoExtInvalidError, MessageIdInvalidError,
PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, RPCError)
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (DocumentAttributeFilename, DocumentAttributeImageSize, GeoPoint,
InputChatUploadedPhoto, MessageActionChatEditPhoto, MessageMediaGeo,
SendMessageCancelAction, SendMessageTypingAction, TypeInputPeer,
UpdateNewMessage, InputMediaUploadedDocument,
InputMediaUploadedPhoto)
from mautrix.types import (EventID, EventType, RoomID, UserID, ContentURI, MessageType,
MessageEventContent, TextMessageEventContent, MediaMessageEventContent,
Format, LocationMessageEventContent, ImageInfo, VideoInfo)
from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus
from ..types import TelegramID
from ..db import Message as DBMessage
from ..util import sane_mimetypes, parallel_transfer_to_telegram
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
try:
from mautrix.crypto.attachments import decrypt_attachment
except ImportError:
decrypt_attachment = None
TypeMessage = Union[Message, MessageService]
config: Optional['Config'] = None
class PortalMatrix(BasePortal, ABC):
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
elif self.peer_type == "user" and not config["bridge.relaybot.private_chat.state_changes"]:
return
async with self.send_lock(self.bot.tgid):
message = await self._get_state_change_message(event, user, **kwargs)
if not message:
return
message, entities = await formatter.matrix_to_telegram(self.bot.client, html=message)
response = await self.bot.client.send_message(self.peer, message,
formatting_entities=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:
message = DBMessage.find_last(self.mxid, space)
if not message:
self.log.debug(f"Dropping Matrix read receipt from {user.mxid}: "
f"target message {event_id} not known and last message"
" in chat not found")
return
else:
self.log.debug(f"Matrix read receipt target {event_id} not known, marking "
f"messages up to most recent ({message.mxid}/{message.tgid}) "
f"as read by {user.mxid}/{user.tgid}")
else:
self.log.debug("Handling Matrix read receipt: marking messages up to "
f"{message.mxid}/{message.tgid} as read by {user.mxid}/{user.tgid}")
await user.client.send_read_acknowledge(self.peer, max_id=message.tgid,
clear_mentions=True)
async def _preproc_kick_ban(self, user: Union['u.User', 'p.Puppet'], source: 'u.User'
) -> Optional['AbstractUser']:
if user.tgid == source.tgid:
return None
if self.peer_type == "user" and user.tgid == self.tgid:
await self.delete()
return None
if isinstance(user, u.User) and await user.needs_relaybot(self):
if not self.bot:
return None
# TODO kick message
return None
if await source.needs_relaybot(self):
if not self.has_bot:
return None
return self.bot
return source
async def kick_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User') -> None:
source = await self._preproc_kick_ban(user, source)
if source is not None:
await source.client.kick_participant(self.peer, user.peer)
async def ban_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User'):
source = await self._preproc_kick_ban(user, source)
if source is not None:
await source.client.edit_permissions(self.peer, user.peer, view_messages=False)
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)
await 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 not isinstance(content, TextMessageEventContent) or 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),
message=content.formatted_body,
body=content.body, formatted_body=content.formatted_body)
content.formatted_body = Template(tpl).safe_substitute(tpl_args)
async def _apply_emote_format(self, sender: 'u.User',
content: TextMessageEventContent) -> None:
if content.format != Format.HTML:
content.format = Format.HTML
content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
tpl = self.get_config("emote_format")
puppet = p.Puppet.get(sender.tgid)
content.formatted_body = Template(tpl).safe_substitute(
dict(sender_mxid=sender.mxid,
sender_username=sender.mxid_localpart,
sender_displayname=escape_html(await self.get_displayname(sender)),
mention=f"<a href='https://matrix.to/#/{puppet.mxid}'>{puppet.displayname}</a>",
username=sender.username,
displayname=puppet.displayname,
body=content.body,
formatted_body=content.formatted_body))
content.msgtype = MessageType.TEXT
async def _pre_process_matrix_message(self, sender: 'u.User', use_relaybot: bool,
content: MessageEventContent) -> None:
if use_relaybot:
await self._apply_msg_format(sender, content)
elif content.msgtype == MessageType.EMOTE:
await self._apply_emote_format(sender, content)
async def _handle_matrix_text(self, sender: 'u.User', logged_in: bool, event_id: EventID,
space: TelegramID, client: 'MautrixTelegramClient',
content: TextMessageEventContent, reply_to: Optional[TelegramID]
) -> None:
message, entities = await formatter.matrix_to_telegram(client, text=content.body,
html=content.formatted(Format.HTML))
sender_id = sender.tgid if logged_in else self.bot.tgid
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, message,
formatting_entities=entities,
link_preview=lp)
self._add_telegram_message_to_db(event_id, space, -1, response)
return
try:
response = await client.send_message(self.peer, message, reply_to=reply_to,
formatting_entities=entities,
link_preview=lp)
except Exception:
raise
else:
sender.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
event_id,
self.mxid,
EventType.ROOM_MESSAGE,
message_type=content.msgtype,
)
self._add_telegram_message_to_db(event_id, space, 0, response)
await self._send_delivery_receipt(event_id)
async def _handle_matrix_file(self, sender: 'u.User', logged_in: bool, event_id: EventID,
space: TelegramID, client: 'MautrixTelegramClient',
content: MediaMessageEventContent, reply_to: TelegramID,
caption: TextMessageEventContent = None) -> None:
sender_id = sender.tgid if logged_in else self.bot.tgid
mime = content.info.mimetype
if isinstance(content.info, (ImageInfo, VideoInfo)):
w, h = content.info.width, content.info.height
else:
w = h = None
file_name = content["net.maunium.telegram.internal.filename"]
max_image_size = config["bridge.image_as_file_size"] * 1000 ** 2
if config["bridge.parallel_file_transfer"] and content.url:
file_handle, file_size = await parallel_transfer_to_telegram(client, self.main_intent,
content.url, sender_id)
else:
if content.file:
if not decrypt_attachment:
raise Exception(f"Can't bridge encrypted media event {event_id}: "
"encryption dependencies not installed")
file = await self.main_intent.download_media(content.file.url)
file = decrypt_attachment(file, content.file.key.key,
content.file.hashes.get("sha256"), content.file.iv)
else:
file = await self.main_intent.download_media(content.url)
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
file_name = "sticker.gif"
file_handle = await client.upload_file(file)
file_size = len(file)
file_handle.name = file_name
attributes = [DocumentAttributeFilename(file_name=file_name)]
if w and h:
attributes.append(DocumentAttributeImageSize(w, h))
if (mime == "image/png" or mime == "image/jpeg") and file_size < max_image_size:
media = InputMediaUploadedPhoto(file_handle)
else:
media = InputMediaUploadedDocument(file=file_handle, attributes=attributes,
mime_type=mime or "application/octet-stream")
capt, entities = (await formatter.matrix_to_telegram(client, text=caption.body,
html=caption.formatted(Format.HTML))
if caption else (None, None))
async with self.send_lock(sender_id):
if await self._matrix_document_edit(client, content, space, capt, media, event_id):
return
try:
try:
response = await client.send_media(self.peer, media, reply_to=reply_to,
caption=capt, entities=entities)
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=capt, entities=entities)
except Exception:
raise
else:
sender.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
event_id,
self.mxid,
EventType.ROOM_MESSAGE,
message_type=content.msgtype,
)
self._add_telegram_message_to_db(event_id, space, 0, response)
await self._send_delivery_receipt(event_id)
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)
await self._send_delivery_receipt(event_id)
return True
return False
async def _handle_matrix_location(self, sender: 'u.User', logged_in: bool, event_id: EventID,
space: TelegramID, client: 'MautrixTelegramClient',
content: LocationMessageEventContent, reply_to: TelegramID
) -> None:
sender_id = sender.tgid if logged_in else self.bot.tgid
try:
lat, long = content.geo_uri[len("geo:"):].split(";")[0].split(",")
lat, long = float(lat), float(long)
except (KeyError, ValueError):
self.log.exception("Failed to parse location")
return None
caption, entities = await formatter.matrix_to_telegram(client, text=content.body)
media = MessageMediaGeo(geo=GeoPoint(lat=lat, long=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
try:
response = await client.send_media(self.peer, media, reply_to=reply_to,
caption=caption, entities=entities)
except Exception:
raise
else:
self._add_telegram_message_to_db(event_id, space, 0, response)
sender.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
event_id,
self.mxid,
EventType.ROOM_MESSAGE,
message_type=content.msgtype,
)
await self._send_delivery_receipt(event_id)
def _add_telegram_message_to_db(self, event_id: EventID, space: TelegramID,
edit_index: int, response: TypeMessage) -> None:
self.log.trace("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 _send_bridge_error(self, sender: 'u.User', err: Exception, event_id: EventID,
event_type: EventType,
message_type: Optional[MessageType] = None,
msg: Optional[str] = None, confirmed: bool = False) -> None:
sender.send_remote_checkpoint(
MessageSendCheckpointStatus.PERM_FAILURE,
event_id,
self.mxid,
event_type,
message_type=message_type,
error=err,
)
if config["bridge.delivery_error_reports"]:
await self._send_message(self.main_intent,
TextMessageEventContent(msgtype=MessageType.NOTICE, body=msg))
async def handle_matrix_message(self, sender: 'u.User', content: MessageEventContent,
event_id: EventID) -> None:
try:
await self._handle_matrix_message(sender, content, event_id)
except RPCError as e:
self.log.exception(f"RPCError while bridging {event_id}: {e}")
await self._send_bridge_error(
sender,
e,
event_id,
EventType.ROOM_MESSAGE,
message_type=content.msgtype,
msg=f"\u26a0 Your message may not have been bridged: {e}",
)
raise
except Exception as e:
self.log.exception(f"Failed to bridge {event_id}: {e}")
await self._send_bridge_error(
sender,
e,
event_id,
EventType.ROOM_MESSAGE,
message_type=content.msgtype,
)
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
logged_in = not await sender.needs_relaybot(self)
client = sender.client if logged_in else self.bot.client
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)
media = (MessageType.STICKER, MessageType.IMAGE, MessageType.FILE, MessageType.AUDIO,
MessageType.VIDEO)
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:
raise Exception("Notices are not configured to be bridged.")
if content.msgtype in (MessageType.TEXT, MessageType.EMOTE, MessageType.NOTICE):
await self._pre_process_matrix_message(sender, not logged_in, content)
await self._handle_matrix_text(sender, logged_in, event_id, space, client, content,
reply_to)
elif content.msgtype == MessageType.LOCATION:
await self._pre_process_matrix_message(sender, not logged_in, content)
await self._handle_matrix_location(sender, logged_in, event_id, space, client, content,
reply_to)
elif content.msgtype in media:
content["net.maunium.telegram.internal.filename"] = content.body
try:
caption_content: MessageEventContent = sender.command_status["caption"]
reply_to = reply_to or formatter.matrix_reply_to_telegram(caption_content, space,
room_id=self.mxid)
sender.command_status = None
except (KeyError, TypeError):
caption_content = None if logged_in else TextMessageEventContent(body=content.body)
if caption_content:
caption_content.msgtype = content.msgtype
await self._pre_process_matrix_message(sender, not logged_in, caption_content)
await self._handle_matrix_file(sender, logged_in, event_id, space, client, content,
reply_to, caption_content)
else:
self.log.debug(f"Didn't handle Matrix event {event_id} due to unknown msgtype {content.msgtype}")
self.log.trace("Unhandled Matrix event content: %s", content)
raise Exception(f"Unhandled msgtype {content.msgtype}")
async def handle_matrix_unpin_all(self, sender: 'u.User', pin_event_id: EventID) -> None:
await sender.client(UnpinAllMessagesRequest(peer=self.peer))
await self._send_delivery_receipt(pin_event_id)
async def handle_matrix_pin(self, sender: 'u.User', changes: Dict[EventID, bool],
pin_event_id: EventID) -> None:
tg_space = self.tgid if self.peer_type == "channel" else sender.tgid
ids = {msg.mxid: msg.tgid
for msg in DBMessage.get_by_mxids(list(changes.keys()),
mx_room=self.mxid, tg_space=tg_space)}
for event_id, pinned in changes.items():
try:
await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=ids[event_id],
unpin=not pinned))
except (ChatNotModifiedError, MessageIdInvalidError, KeyError):
pass
await self._send_delivery_receipt(pin_event_id)
async def handle_matrix_deletion(self, deleter: 'u.User', event_id: EventID,
redaction_event_id: EventID) -> None:
try:
await self._handle_matrix_deletion(deleter, event_id)
except Exception as e:
self.log.debug(str(e))
await self._send_bridge_error(deleter, e, redaction_event_id, EventType.ROOM_REDACTION)
else:
deleter.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
redaction_event_id,
self.mxid,
EventType.ROOM_REDACTION,
)
await self._send_delivery_receipt(redaction_event_id)
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:
raise Exception(f"Ignoring Matrix redaction of unknown event {event_id}")
elif message.redacted:
raise Exception("Ignoring Matrix redaction of already redacted event "
f"{message.mxid} in {message.mx_room}")
elif message.edit_index != 0:
message.edit(redacted=True)
raise Exception("Ignoring Matrix redaction of edit event "
f"{message.mxid} in {message.mx_room}")
else:
message.edit(redacted=True)
await real_deleter.client.delete_messages(self.peer, [message.tgid])
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], event_id: Optional[EventID]
) -> 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, event_id: EventID) -> 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
await self.save()
await self._send_delivery_receipt(event_id)
async def handle_matrix_title(self, sender: 'u.User', title: str, event_id: EventID) -> 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
await self.save()
await self._send_delivery_receipt(event_id)
await self.update_bridge_info()
async def handle_matrix_avatar(self, sender: 'u.User', url: ContentURI, event_id: EventID
) -> None:
if self.peer_type not in ("chat", "channel"):
# Invalid peer type
return
elif self.avatar_url == url:
return
self.avatar_url = url
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}"
await self.save()
break
await self._send_delivery_receipt(event_id)
await self.update_bridge_info()
async def handle_matrix_upgrade(self, sender: UserID, new_room: RoomID, event_id: EventID
) -> None:
_, server = self.main_intent.parse_user_id(sender)
old_room = self.mxid
self.migrate_and_save_matrix(new_room)
await self.main_intent.join_room(new_room, servers=[server])
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"{sender} upgraded room from {old_room} to {self.mxid}")
await self._send_delivery_receipt(event_id, room_id=old_room)
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
async def enable_dm_encryption(self) -> bool:
ok = await super().enable_dm_encryption()
if ok:
try:
puppet = p.Puppet.get(self.tgid)
await self.main_intent.set_room_name(self.mxid, puppet.displayname)
except Exception:
self.log.warning(f"Failed to set room name", exc_info=True)
return ok
def init(context: Context) -> None:
global config
config = context.config
-875
View File
@@ -1,875 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2020 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, Iterable, Union, Dict, Any, Tuple, 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, UserProfilePhoto, UserProfilePhotoEmpty,
InputPeerUser, ChannelParticipantBanned)
from mautrix.errors import MForbidden
from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership,
PowerLevelStateEventContent, RoomTopicStateEventContent,
RoomNameStateEventContent, RoomAvatarStateEventContent,
StateEventContent, EventID, JoinRule)
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY
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
StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
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, source: 'u.User'
) -> Tuple[List[InputPeerUser], List[UserID]]:
user_tgids = {}
user_mxids = await self.main_intent.get_room_members(self.mxid, (Membership.JOIN,
Membership.INVITE))
for mxid in user_mxids:
if mxid == self.az.bot_mxid:
continue
mx_user = u.User.get_by_mxid(mxid, create=False)
if mx_user and mx_user.tgid:
user_tgids[mx_user.tgid] = mxid
puppet_id = p.Puppet.get_id_from_mxid(mxid)
if puppet_id:
user_tgids[puppet_id] = mxid
input_users = []
errors = []
for tgid, mxid in user_tgids.items():
try:
input_users.append(await source.client.get_input_entity(tgid))
except ValueError as e:
source.log.debug(f"Failed to find the input entity for {tgid} ({mxid}) for "
f"creating a group: {e}")
errors.append(mxid)
return input_users, errors
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_sync()
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):
await self.save()
async def create_telegram_chat(self, source: 'u.User', invites: List[InputUser],
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.")
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, {}, None)
await self.update_bridge_info()
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]))
# We don't care if there are invites for private chat portals with the relaybot.
elif not self.bot or self.tg_receiver != self.bot.tgid:
raise ValueError("Invalid peer type for Telegram user invite")
# endregion
# region Telegram -> Matrix
def _get_invite_content(self, double_puppet: Optional['p.Puppet']) -> Dict[str, Any]:
invite_content = {}
if double_puppet:
invite_content["fi.mau.will_auto_accept"] = True
if self.is_direct:
invite_content["is_direct"] = True
return invite_content
async def invite_to_matrix(self, users: InviteList) -> None:
if isinstance(users, list):
for user in users:
await self.invite_to_matrix(user)
else:
puppet = await p.Puppet.get_by_custom_mxid(users)
await self.main_intent.invite_user(self.mxid, users, check_cache=True,
extra_content=self._get_invite_content(puppet))
if puppet:
try:
await puppet.intent.ensure_joined(self.mxid)
except Exception:
self.log.exception("Failed to ensure %s is joined to portal", users)
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) -> None:
if direct is None:
direct = self.peer_type == "user"
try:
await self._update_matrix_room(user, entity, direct, puppet, levels, users)
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) -> None:
if not direct:
await self.update_info(user, entity)
if not users:
users = await self._get_users(user, entity)
await self._sync_telegram_users(user, users)
await self.update_power_levels(users, 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.encrypted or self.private_chat_portal_meta:
# The bridge bot needs to join for e2ee, but that messes up the default name
# generation. If/when canonical DMs happen, this might not be necessary anymore.
changed = await self._update_title(puppet.displayname)
changed = await self._update_avatar(user, entity.photo) or changed
if changed:
await self.save()
await self.update_bridge_info()
puppet = await p.Puppet.get_by_custom_mxid(user.mxid)
if puppet:
try:
did_join = await puppet.intent.ensure_joined(self.mxid)
if isinstance(user, u.User) and did_join and self.peer_type == "user":
await user.update_direct_chats({self.main_intent.mxid: [self.mxid]})
except Exception:
self.log.exception("Failed to ensure %s is joined to portal", user.mxid)
if self.sync_matrix_state:
await self.main_intent.get_joined_members(self.mxid)
async def create_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User] = None,
invites: InviteList = None, update_if_exists: bool = True
) -> Optional[RoomID]:
if self.mxid:
if update_if_exists:
if not entity:
try:
entity = await self.get_entity(user)
except Exception:
self.log.exception(f"Failed to get entity through {user.tgid} for update")
return self.mxid
update = self.update_matrix_room(user, entity, self.peer_type == "user")
self.loop.create_task(update)
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")
@property
def bridge_info_state_key(self) -> str:
return f"net.maunium.telegram://telegram/{self.tgid}"
@property
def bridge_info(self) -> Dict[str, Any]:
info = {
"bridgebot": self.az.bot_mxid,
"creator": self.main_intent.mxid,
"protocol": {
"id": "telegram",
"displayname": "Telegram",
"avatar_url": config["appservice.bot_avatar"],
"external_url": "https://telegram.org",
},
"channel": {
"id": str(self.tgid),
"displayname": self.title,
"avatar_url": self.avatar_url,
}
}
if self.username:
info["channel"]["external_url"] = f"https://t.me/{self.username}"
elif self.peer_type == "user":
puppet = p.Puppet.get(self.tgid)
if puppet and puppet.username:
info["channel"]["external_url"] = f"https://t.me/{puppet.username}"
return info
async def update_bridge_info(self) -> None:
if not self.mxid:
self.log.debug("Not updating bridge info: no Matrix room created")
return
try:
self.log.debug("Updating bridge info...")
await self.main_intent.send_state_event(self.mxid, StateBridge,
self.bridge_info, self.bridge_info_state_key)
# TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
await self.main_intent.send_state_event(self.mxid, StateHalfShotBridge,
self.bridge_info, self.bridge_info_state_key)
except Exception:
self.log.warning("Failed to update bridge info", exc_info=True)
async def _create_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
invites: InviteList) -> Optional[RoomID]:
if self.mxid:
return self.mxid
elif not self.allow_bridging:
return None
direct = self.peer_type == "user"
invites = invites or []
if not entity:
entity = await self.get_entity(user)
self.log.trace("Fetched data: %s", 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
if puppet:
await puppet.update_info(user, entity)
self._main_intent = puppet.intent_for(self) if direct else self.az.intent
if self.peer_type == "channel":
self.megagroup = entity.megagroup
preset = RoomCreatePreset.PRIVATE
if self.peer_type == "channel" and entity.username:
if self.public_portals:
preset = RoomCreatePreset.PUBLIC
self.username = entity.username
alias = self.alias_localpart
else:
# 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 = None
if not direct:
users = await self._get_users(user, entity)
if self.has_bot:
extra_invites = config["bridge.relaybot.group_chat_invite"]
invites += extra_invites
for invite in extra_invites:
power_levels.users.setdefault(invite, 100)
await self._participants_to_power_levels(users, power_levels)
elif self.bot and self.tg_receiver == self.bot.tgid:
invites = config["bridge.relaybot.private_chat.invite"]
for invite in invites:
power_levels.users.setdefault(invite, 100)
self.title = puppet.displayname
initial_state = [{
"type": EventType.ROOM_POWER_LEVELS.serialize(),
"content": power_levels.serialize(),
}, {
"type": str(StateBridge),
"state_key": self.bridge_info_state_key,
"content": self.bridge_info,
}, {
# TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
"type": str(StateHalfShotBridge),
"state_key": self.bridge_info_state_key,
"content": self.bridge_info,
}]
create_invites = []
if config["bridge.encryption.default"] and self.matrix.e2ee:
self.encrypted = True
initial_state.append({
"type": "m.room.encryption",
"content": {"algorithm": "m.megolm.v1.aes-sha2"},
})
if direct:
create_invites.append(self.az.bot_mxid)
if direct and (self.encrypted or self.private_chat_portal_meta):
self.title = puppet.displayname
if config["appservice.community_id"]:
initial_state.append({
"type": "m.room.related_groups",
"content": {"groups": [config["appservice.community_id"]]},
})
creation_content = {}
if not config["bridge.federate_rooms"]:
creation_content["m.federate"] = False
with self.backfill_lock:
room_id = await self.main_intent.create_room(alias_localpart=alias, preset=preset,
is_direct=direct, invitees=create_invites,
name=self.title, topic=self.about,
initial_state=initial_state,
creation_content=creation_content)
if not room_id:
raise Exception(f"Failed to create room")
if self.encrypted and self.matrix.e2ee and direct:
try:
await self.az.intent.ensure_joined(room_id)
except Exception:
self.log.warning(f"Failed to add bridge bot to new private chat {room_id}")
self.mxid = room_id
self.by_mxid[self.mxid] = self
await self.save()
await self.az.state_store.set_power_levels(self.mxid, power_levels)
await user.register_portal(self)
await self.invite_to_matrix(invites)
update_room = self.loop.create_task(self.update_matrix_room(
user, entity, direct, puppet,
levels=power_levels, users=users))
if config["bridge.backfill.initial_limit"] > 0:
self.log.debug("Initial backfill is enabled. Waiting for room members to sync "
"and then starting backfill")
await update_room
try:
await self.backfill(user, is_initial=True)
except Exception:
self.log.exception("Failed to backfill new portal")
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":
overrides = config["bridge.initial_power_level_overrides.user"]
levels.ban = overrides.get("ban", 100)
levels.kick = overrides.get("kick", 100)
levels.invite = overrides.get("invite", 100)
levels.redact = overrides.get("redact", 0)
levels.events[EventType.ROOM_NAME] = 0
levels.events[EventType.ROOM_AVATAR] = 0
levels.events[EventType.ROOM_TOPIC] = 0
levels.state_default = overrides.get("state_default", 0)
levels.users_default = overrides.get("users_default", 0)
levels.events_default = overrides.get("events_default", 0)
else:
overrides = config["bridge.initial_power_level_overrides.group"]
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 = overrides.get("ban", 50)
levels.kick = overrides.get("kick", 50)
levels.redact = overrides.get("redact", 50)
levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0)
levels.events[EventType.ROOM_ENCRYPTION] = 50 if self.matrix.e2ee else 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.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default
levels.state_default = overrides.get("state_default", 50)
levels.users_default = overrides.get("users_default", 0)
levels.events_default = (
overrides.get("events_default",
50 if (self.peer_type == "channel" and not entity.megagroup
or entity.default_banned_rights.send_messages)
else 0))
for evt_type, value in overrides.get("events", {}).items():
levels.events[EventType.find(evt_type)] = value
levels.users = overrides.get("users", {})
if self.main_intent.mxid not in levels.users:
levels.users[self.main_intent.mxid] = 100
return levels
@classmethod
def _get_level_from_participant(cls, participant: TypeParticipant,
levels: PowerLevelStateEventContent) -> int:
# TODO use the power level requirements to get better precision in channels
if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
return levels.state_default or 50
elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
return levels.get_user_level(cls.az.bot_mxid) - 5
return levels.users_default or 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
async def _participants_to_power_levels(self, users: List[Union[TypeUser, 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.get_event_level(EventType.ROOM_POWER_LEVELS) != admin_power_level:
changed = True
levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
for user in users:
# The User objects we get from TelegramClient.get_participants have a custom
# participant property
participant = getattr(user, "participant", user)
puppet = p.Puppet.get(TelegramID(participant.user_id))
user = u.User.get_by_tgid(TelegramID(participant.user_id))
new_level = self._get_level_from_participant(participant, levels)
if user:
await 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_power_levels(self, users: List[Union[TypeUser, TypeParticipant]],
levels: PowerLevelStateEventContent = None) -> None:
if not levels:
levels = await self.main_intent.get_power_levels(self.mxid)
if await self._participants_to_power_levels(users, levels):
await self.main_intent.set_power_levels(self.mxid, levels)
async 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:
await 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:
puppet = p.Puppet.get(TelegramID(entity.id))
if entity.bot:
await self._add_bot_chat(entity)
allowed_tgids.add(entity.id)
await puppet.update_info(source, entity)
if skip_deleted and entity.deleted:
continue
await puppet.intent_for(self).ensure_joined(self.mxid)
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
if self.max_initial_member_sync < 0
else len(allowed_tgids) < self.max_initial_member_sync - 10)
and (self.megagroup or self.peer_type != "channel"))
if not trust_member_list:
return
for user_mxid in await self.main_intent.get_room_members(self.mxid):
if user_mxid == self.az.bot_mxid:
continue
puppet_id = p.Puppet.get_id_from_mxid(user_mxid)
if puppet_id:
if puppet_id in allowed_tgids:
continue
if self.bot and puppet_id == self.bot.tgid:
self.bot.remove_chat(self.tgid)
try:
await self.main_intent.kick_user(self.mxid, user_mxid,
"User had left this Telegram chat.")
except MForbidden:
pass
continue
mx_user = u.User.get_by_mxid(user_mxid, create=False)
if mx_user:
if mx_user.tgid in allowed_tgids:
continue
if mx_user.is_bot:
await mx_user.unregister_portal(*self.tgid_full)
if not self.has_bot:
try:
await self.main_intent.kick_user(self.mxid, mx_user.mxid,
"You had left this Telegram chat.")
except MForbidden:
pass
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:
await 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:
await user.unregister_portal(*self.tgid_full)
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
changed = False
self.log.debug("Updating info")
try:
if not entity:
entity = await self.get_entity(user)
self.log.trace("Fetched data: %s", entity)
if self.peer_type == "channel":
changed = self.megagroup != entity.megagroup or changed
self.megagroup = entity.megagroup
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
except Exception:
self.log.exception(f"Failed to update info from source {user.tgid}")
if changed:
await self.save()
await self.update_bridge_info()
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.alias_localpart)
self.username = username or None
if self.username:
await self.main_intent.add_room_alias(self.mxid, self.alias_localpart, override=True)
if self.public_portals:
await self.main_intent.set_join_rule(self.mxid, JoinRule.PUBLIC)
else:
await self.main_intent.set_join_rule(self.mxid, JoinRule.INVITE)
if save:
await self.save()
return True
async def _try_set_state(self, sender: Optional['p.Puppet'], evt_type: EventType,
content: StateEventContent) -> None:
if sender:
try:
intent = sender.intent_for(self)
if sender.is_real_user:
content[DOUBLE_PUPPET_SOURCE_KEY] = self.bridge.name
await intent.send_state_event(self.mxid, evt_type, content)
except MForbidden:
await self.main_intent.send_state_event(self.mxid, evt_type, content)
else:
await self.main_intent.send_state_event(self.mxid, evt_type, content)
async def _update_about(self, about: str, sender: Optional['p.Puppet'] = None,
save: bool = False) -> bool:
if self.about == about:
return False
self.about = about
await self._try_set_state(sender, EventType.ROOM_TOPIC,
RoomTopicStateEventContent(topic=self.about))
if save:
await self.save()
return True
async def _update_title(self, title: str, sender: Optional['p.Puppet'] = None,
save: bool = False) -> bool:
if self.title == title:
return False
self.title = title
await self._try_set_state(sender, EventType.ROOM_NAME,
RoomNameStateEventContent(name=self.title))
if save:
await self.save()
return True
async def _update_avatar(self, user: 'AbstractUser', photo: TypeChatPhoto,
sender: Optional['p.Puppet'] = None, save: bool = False) -> bool:
if isinstance(photo, (ChatPhoto, UserProfilePhoto)):
loc = InputPeerPhotoFileLocation(
peer=await self.get_input_entity(user),
photo_id=photo.photo_id,
big=True
)
photo_id = str(photo.photo_id)
elif isinstance(photo, Photo):
loc, _ = self._get_largest_photo_size(photo)
photo_id = str(loc.id)
elif isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty, PhotoEmpty, type(None))):
photo_id = ""
loc = None
else:
raise ValueError(f"Unknown photo type {type(photo)}")
if self.peer_type == "user" and not photo_id and not config["bridge.allow_avatar_remove"]:
return False
if self.photo_id != photo_id:
if not photo_id:
await self._try_set_state(sender, EventType.ROOM_AVATAR,
RoomAvatarStateEventContent(url=None))
self.photo_id = ""
self.avatar_url = None
if save:
await self.save()
return True
file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc)
if file:
await self._try_set_state(sender, EventType.ROOM_AVATAR,
RoomAvatarStateEventContent(url=file.mxc))
self.photo_id = photo_id
self.avatar_url = file.mxc
if save:
await self.save()
return True
return False
@staticmethod
def _filter_participants(users: List[TypeUser], participants: List[TypeParticipant]
) -> Iterable[TypeUser]:
participant_map = {part.user_id: part for part in participants
if not isinstance(part, ChannelParticipantBanned)}
for user in users:
try:
user.participant = participant_map[user.id]
except KeyError:
pass
else:
yield user
async def _get_channel_users(self, user: 'AbstractUser', entity: InputChannel, limit: int
) -> List[TypeUser]:
if 0 < limit <= 200:
response = await user.client(GetParticipantsRequest(
entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0))
return list(self._filter_participants(response.users, response.participants))
elif limit > 200 or limit == -1:
users: List[TypeUser] = []
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, 200), hash=0))
if not response.users:
break
users += self._filter_participants(response.users, response.participants)
offset += len(response.participants)
remaining_quota -= len(response.participants)
return users
async def _get_users(self, user: 'AbstractUser',
entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser, InputChannel]
) -> List[TypeUser]:
limit = self.max_initial_member_sync
if self.peer_type == "chat":
chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
return list(
self._filter_participants(chat.users, chat.full_chat.participants.participants)
)[:limit]
elif self.peer_type == "channel":
if not self.megagroup and not self.sync_channel_members:
return []
if limit == 0:
return []
try:
return await self._get_channel_users(user, entity, limit)
except ChatAdminRequiredError:
return []
elif self.peer_type == "user":
return [entity]
else:
raise RuntimeError(f"Unexpected peer type {self.peer_type}")
# endregion
async def _send_delivery_receipt(self, event_id: EventID, room_id: Optional[RoomID] = None
) -> None:
# TODO maybe check if the bot is in the room rather than assuming based on self.encrypted
if event_id and config["bridge.delivery_receipts"] and (self.encrypted
or self.peer_type != "user"):
try:
await self.az.intent.mark_read(room_id or self.mxid, event_id)
except Exception:
self.log.exception("Failed to send delivery receipt for %s", event_id)
def init(context: Context) -> None:
global config
config = context.config
-808
View File
@@ -1,808 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2020 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 abc import ABC
import random
import mimetypes
import codecs
import unicodedata
import base64
import asyncio
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, MessageActionGameScore, MessageMediaDocument, MessageMediaGeo,
MessageMediaPhoto, MessageMediaDice, MessageMediaGame, MessageMediaUnsupported, PeerUser,
PhotoCachedSize, TypeChannelParticipant, TypeChatParticipant, TypeDocumentAttribute,
TypeMessageAction, TypePhotoSize, PhotoSize, UpdateChatUserTyping, UpdateUserTyping,
MessageEntityPre, ChatPhotoEmpty, DocumentAttributeImageSize, DocumentAttributeAnimated,
UpdateChannelUserTyping, SendMessageTypingAction)
from mautrix.appservice import IntentAPI
from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType,
EventType, MediaMessageEventContent, TextMessageEventContent,
LocationMessageEventContent, Format)
from mautrix.bridge import NotificationDisabler
from ..types import TelegramID
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
from ..util import sane_mimetypes
from ..context import Context
from ..tgclient import TelegramClient
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]
UpdateTyping = Union[UpdateUserTyping, UpdateChatUserTyping, UpdateChannelUserTyping]
DocAttrs = NamedTuple("DocAttrs", name=Optional[str], mime_type=Optional[str], is_sticker=bool,
sticker_alt=Optional[str], width=int, height=int, is_gif=bool)
config: Optional['Config'] = None
class PortalTelegram(BasePortal, ABC):
async def handle_telegram_typing(self, user: p.Puppet, update: UpdateTyping) -> None:
if user.is_real_user:
# Ignore typing notifications from double puppeted users to avoid echoing
return
is_typing = isinstance(update.action, SendMessageTypingAction)
await user.default_mxid_intent.set_typing(self.mxid, is_typing=is_typing)
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 _expire_telegram_photo(self, intent: IntentAPI, event_id: EventID, ttl: int) -> None:
try:
content = TextMessageEventContent(msgtype=MessageType.NOTICE, body="Photo has expired")
content.set_edit(event_id)
await asyncio.sleep(ttl)
await self._send_message(intent, content)
except Exception:
self.log.warning("Failed to expire Telegram photo %s", event_id, exc_info=True)
async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: RelatesTo = None) -> Optional[EventID]:
media: MessageMediaPhoto = evt.media
if media.photo is None and media.ttl_seconds:
return await self._send_message(intent, TextMessageEventContent(
msgtype=MessageType.NOTICE, body="Photo has expired"))
loc, largest_size = self._get_largest_photo_size(media.photo)
if loc is None:
content = TextMessageEventContent(msgtype=MessageType.TEXT,
body="Failed to bridge image",
external_url=self._get_external_url(evt))
return await self._send_message(intent, content, timestamp=evt.date)
file = await util.transfer_file_to_matrix(source.client, intent, loc,
encrypt=self.encrypted)
if not file:
return None
if self.get_config("inline_images") and (evt.message or evt.fwd_from or evt.reply_to):
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 self._send_message(intent, content, timestamp=evt.date)
info = ImageInfo(
height=largest_size.h, width=largest_size.w, orientation=0, mimetype=file.mime_type,
size=self._photo_size_key(largest_size))
ext = sane_mimetypes.guess_extension(file.mime_type)
name = f"disappearing_image{ext}" if media.ttl_seconds else f"image{ext}"
await intent.set_typing(self.mxid, is_typing=False)
content = MediaMessageEventContent(msgtype=MessageType.IMAGE, info=info,
body=name, relates_to=relates_to,
external_url=self._get_external_url(evt))
if file.decryption_info:
content.file = file.decryption_info
else:
content.url = file.mxc
result = await self._send_message(intent, content, timestamp=evt.date)
if media.ttl_seconds:
self.loop.create_task(self._expire_telegram_photo(intent, result,
media.ttl_seconds))
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 self._send_message(intent, 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
is_gif = False
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, DocumentAttributeAnimated):
is_gif = True
elif isinstance(attr, DocumentAttributeVideo):
width, height = attr.w, attr.h
elif isinstance(attr, DocumentAttributeImageSize):
width, height = attr.w, attr.h
return DocAttrs(name, mime_type, is_sticker, sticker_alt, width, height, is_gif)
@staticmethod
def _parse_telegram_document_meta(evt: Message, file: DBTelegramFile, attrs: DocAttrs,
thumb_size: TypePhotoSize) -> Tuple[ImageInfo, str]:
document = evt.media.document
name = 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
elif file.mime_type == 'application/ogg':
mime_type = 'audio/ogg'
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:
if file.thumbnail.decryption_info:
info.thumbnail_file = file.thumbnail.decryption_info
else:
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)
elif attrs.is_sticker:
# This is a hack for bad clients like Element iOS that require a thumbnail
info.thumbnail_info = ImageInfo.deserialize(info.serialize())
if file.decryption_info:
info.thumbnail_file = file.decryption_info
else:
info.thumbnail_url = file.mxc
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 ""
# TODO encrypt
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
parallel_id = source.tgid if config["bridge.parallel_file_transfer"] else None
file = await util.transfer_file_to_matrix(source.client, intent, document, thumb_loc,
is_sticker=attrs.is_sticker,
tgs_convert=config["bridge.animated_sticker"],
filename=attrs.name, parallel_id=parallel_id,
encrypt=self.encrypted)
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.ROOM_MESSAGE
# Elements only support images as stickers, so send animated webm stickers as m.video
if attrs.is_sticker and file.mime_type.startswith("image/"):
event_type = EventType.STICKER
# Tell clients to render the stickers as 256x256 if they're bigger
if info.width > 256 or info.height > 256:
if info.width > info.height:
info.height = int(info.height / (info.width / 256))
info.width = 256
else:
info.width = int(info.width / (info.height / 256))
info.height = 256
if info.thumbnail_info:
info.thumbnail_info.width = info.width
info.thumbnail_info.height = info.height
if attrs.is_gif or (attrs.is_sticker and info.mimetype == "video/webm"):
if attrs.is_gif:
info["fi.mau.telegram.gif"] = True
else:
info["fi.mau.telegram.animated_sticker"] = True
info["fi.mau.loop"] = True
info["fi.mau.autoplay"] = True
info["fi.mau.hide_controls"] = True
info["fi.mau.no_audio"] = True
if not name:
ext = sane_mimetypes.guess_extension(file.mime_type)
name = "unnamed_file" + ext
content = MediaMessageEventContent(
body=name, info=info, 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))
if file.decryption_info:
content.file = file.decryption_info
else:
content.url = file.mxc
res = await self._send_message(intent, content, event_type=event_type, 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
res = await self._send_message(intent, caption_content, timestamp=evt.date)
return res
def handle_telegram_location(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: RelatesTo = 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"
geo = f"{round(lat, 6)},{round(long, 6)}"
body = f"{round(abs(lat), 4)}° {lat_char}, {round(abs(long), 4)}° {long_char}"
url = f"https://maps.google.com/?q={geo}"
content = LocationMessageEventContent(
msgtype=MessageType.LOCATION, geo_uri=f"geo:{geo}",
body=f"Location: {body}\n{url}",
relates_to=relates_to, external_url=self._get_external_url(evt))
content["format"] = str(Format.HTML)
content["formatted_body"] = f"Location: <a href='{url}'>{body}</a>"
return self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool,
evt: Message) -> EventID:
self.log.trace(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 self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI,
evt: Message, relates_to: RelatesTo = None) -> EventID:
override_text = ("This message is not supported on your version of Mautrix-Telegram. "
"Please check https://github.com/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 self._send_message(intent, 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 self._send_message(intent, content, timestamp=evt.date)
@staticmethod
def _format_dice(roll: MessageMediaDice) -> str:
if roll.emoticon == "\U0001F3B0":
emojis = {
0: "\U0001F36B", # "🍫",
1: "\U0001F352", # "🍒",
2: "\U0001F34B", # "🍋",
3: "7\ufe0f\u20e3" # "7️⃣",
}
res = roll.value - 1
slot1, slot2, slot3 = emojis[res % 4], emojis[res // 4 % 4], emojis[res // 16]
return f"{slot1} {slot2} {slot3} ({roll.value})"
elif roll.emoticon == "\u26BD":
results = {
1: "miss",
2: "hit the woodwork",
3: "goal", # seems to go in through the center
4: "goal",
5: "goal 🎉", # seems to go in through the top right corner, includes confetti
}
elif roll.emoticon == "\U0001F3B3":
results = {
1: "miss",
2: "1 pin down",
3: "3 pins down, split",
4: "4 pins down, split",
5: "5 pins down",
6: "strike 🎉",
}
# elif roll.emoticon == "\U0001F3C0":
# results = {
# 2: "rolled off",
# 3: "stuck",
# }
# elif roll.emoticon == "\U0001F3AF":
# results = {
# 1: "bounced off",
# 2: "outer rim",
#
# 6: "bullseye",
# }
else:
return str(roll.value)
return f"{results[roll.value]} ({roll.value})"
async def handle_telegram_dice(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: RelatesTo) -> EventID:
emoji_text = {
"\U0001F3AF": " Dart throw",
"\U0001F3B2": " Dice roll",
"\U0001F3C0": " Basketball throw",
"\U0001F3B0": " Slot machine",
"\U0001F3B3": " Bowling",
"\u26BD": " Football kick"
}
roll: MessageMediaDice = evt.media
text = f"{roll.emoticon}{emoji_text.get(roll.emoticon, '')} result: {self._format_dice(roll)}"
content = TextMessageEventContent(msgtype=MessageType.TEXT, format=Format.HTML, body=text,
formatted_body=f"<h4>{text}</h4>", relates_to=relates_to,
external_url=self._get_external_url(evt))
content["net.maunium.telegram.dice"] = {"emoticon": roll.emoticon, "value": roll.value}
await intent.set_typing(self.mxid, is_typing=False)
return await self._send_message(intent, content, timestamp=evt.date)
@staticmethod
def _int_to_bytes(i: int) -> bytes:
hex_value = f"{i:010x}".encode("utf-8")
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 self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, evt: Message
) -> None:
if not self.mxid:
self.log.trace("Ignoring edit to %d as chat has no Matrix room", evt.id)
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)
intent = sender.intent_for(self) if sender else self.main_intent
await intent.set_typing(self.mxid, is_typing=False)
event_id = await self._send_message(intent, 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)
@property
def _takeout_options(self) -> Dict[str, Union[bool, int]]:
return {
"files": True,
"megagroups": self.megagroup,
"chats": self.peer_type == "chat",
"users": self.peer_type == "user",
"channels": (self.peer_type == "channel" and not self.megagroup),
"max_file_size": min(config["bridge.max_document_size"], 2000) * 1024 * 1024
}
async def backfill(self, source: 'u.User', is_initial: bool = False,
limit: Optional[int] = None, last_id: Optional[int] = None) -> None:
async with self.backfill_method_lock:
await self._locked_backfill(source, is_initial, limit, last_id)
async def _locked_backfill(self, source: 'u.User', is_initial: bool = False,
limit: Optional[int] = None, last_id: Optional[int] = None) -> None:
limit = limit or (config["bridge.backfill.initial_limit"] if is_initial
else config["bridge.backfill.missed_limit"])
if limit == 0:
return
if not config["bridge.backfill.normal_groups"] and self.peer_type == "chat":
return
last = DBMessage.find_last(self.mxid, (source.tgid if self.peer_type != "channel"
else self.tgid))
min_id = last.tgid if last else 0
if last_id is None:
messages = await source.client.get_messages(self.peer, limit=1)
if not messages:
# The chat seems empty
return
last_id = messages[0].id
if last_id <= min_id:
# Nothing to backfill
return
if limit < 0:
limit = last_id - min_id
self.log.debug(f"Backfilling approximately {last_id - min_id} messages "
f"through {source.mxid}")
elif self.peer_type == "channel":
# This is a channel or supergroup, so we'll backfill messages based on the ID.
# There are some cases, such as deleted messages, where this may backfill less
# messages than the limit.
min_id = max(last_id - limit, min_id)
self.log.debug(f"Backfilling messages after ID {min_id} (last message: {last_id}) "
f"through {source.mxid}")
else:
# Private chats and normal groups don't have their own message ID namespace,
# which means we'll have to fetch messages a different way.
# The _backfill_messages method will detect min_id=None and not use reverse=True
min_id = None
self.log.debug(f"Backfilling up to {limit} messages through {source.mxid}")
with self.backfill_lock:
await self._backfill(source, min_id, limit)
async def _backfill(self, source: 'u.User', min_id: Optional[int], limit: int) -> None:
self.backfill_leave = set()
if ((self.peer_type == "user" and self.tgid != source.tgid
and config["bridge.backfill.invite_own_puppet"])):
self.log.debug("Adding %s's default puppet to room for backfilling", source.mxid)
sender = p.Puppet.get(source.tgid)
await self.main_intent.invite_user(self.mxid, sender.default_mxid)
await sender.default_mxid_intent.join_room_by_id(self.mxid)
self.backfill_leave.add(sender.default_mxid_intent)
client = source.client
async with NotificationDisabler(self.mxid, source):
if limit > config["bridge.backfill.takeout_limit"]:
self.log.debug(f"Opening takeout client for {source.tgid}")
async with client.takeout(**self._takeout_options) as takeout:
count = await self._backfill_messages(source, min_id, limit, takeout)
else:
count = await self._backfill_messages(source, min_id, limit, client)
for intent in self.backfill_leave:
self.log.trace("Leaving room with %s post-backfill", intent.mxid)
await intent.leave_room(self.mxid)
self.backfill_leave = None
self.log.info("Backfilled %d messages through %s", count, source.mxid)
async def _backfill_messages(self, source: 'AbstractUser', min_id: Optional[int], limit: int,
client: TelegramClient) -> int:
count = 0
entity = await self.get_input_entity(source)
if min_id is not None:
self.log.debug(f"Iterating all messages starting with {min_id} (approx: {limit})")
messages = client.iter_messages(entity, reverse=True, min_id=min_id)
async for message in messages:
sender = (p.Puppet.get(message.from_id.user_id)
if isinstance(message.from_id, PeerUser) else None)
# TODO handle service messages?
await self.handle_telegram_message(source, sender, message)
count += 1
else:
self.log.debug(f"Fetching up to {limit} most recent messages")
messages = await client.get_messages(entity, limit=limit)
for message in reversed(messages):
sender = (p.Puppet.get(TelegramID(message.from_id.user_id))
if isinstance(message.from_id, PeerUser) else None)
await self.handle_telegram_message(source, sender, message)
count += 1
return count
async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet,
evt: Message) -> None:
if not self.mxid:
self.log.trace("Got telegram message %d, but no room exists, creating...", evt.id)
await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
if (self.peer_type == "user" and sender and sender.tgid == self.tg_receiver
and not sender.is_real_user and not await 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")
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)}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.backfill_lock.locked or (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
self.log.trace("Handling Telegram message %s", evt)
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)
if not sender.displayname:
self.log.debug(f"Telegram user {sender.tgid} doesn't have a displayname even after"
f" updating with data {entity!s}")
allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo,
MessageMediaGame, MessageMediaDice, MessageMediaPoll,
MessageMediaUnsupported)
media = evt.media if hasattr(evt, "media") and isinstance(evt.media,
allowed_media) else None
if sender:
intent = sender.intent_for(self)
if ((self.backfill_lock.locked and intent != sender.default_mxid_intent
and config["bridge.backfill.invite_own_puppet"])):
intent = sender.default_mxid_intent
self.backfill_leave.add(intent)
else:
intent = 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,
MessageMediaDice: self.handle_telegram_dice,
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 %d", evt.id)
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 %d -> %s", evt.id, event_id)
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)
await self._send_delivery_receipt(event_id)
async def _create_room_on_action(self, source: 'AbstractUser',
action: TypeMessageAction) -> bool:
if source.is_relaybot and config["bridge.ignore_unbridged_group_chat"]:
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, sender=sender, save=True)
await self.update_bridge_info()
elif isinstance(action, MessageActionChatEditPhoto):
await self._update_avatar(source, action.photo, sender=sender, save=True)
await self.update_bridge_info()
elif isinstance(action, MessageActionChatDeletePhoto):
await self._update_avatar(source, ChatPhotoEmpty(), sender=sender, save=True)
await self.update_bridge_info()
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))
# TODO encrypt
await sender.intent_for(self).send_emote(self.mxid,
"upgraded this group to a supergroup.")
await self.update_bridge_info()
elif isinstance(action, MessageActionGameScore):
# TODO handle game score
pass
else:
self.log.trace("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_ids(self, msg_ids: List[TelegramID], receiver: TelegramID,
remove: bool) -> None:
async with self._pin_lock:
tg_space = receiver if self.peer_type != "channel" else self.tgid
previously_pinned = await self.main_intent.get_pinned_messages(self.mxid)
currently_pinned_dict = {event_id: True for event_id in previously_pinned}
for message in DBMessage.get_first_by_tgids(msg_ids, tg_space):
if remove:
currently_pinned_dict.pop(message.mxid, None)
else:
currently_pinned_dict[message.mxid] = True
currently_pinned = list(currently_pinned_dict.keys())
if currently_pinned != previously_pinned:
await self.main_intent.set_pinned_messages(self.mxid, currently_pinned)
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
NotificationDisabler.puppet_cls = p.Puppet
NotificationDisabler.config_enabled = config["bridge.backfill.disable_notifications"]
+143 -204
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2020 Tulir Asokan
# Copyright (C) 2021 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
@@ -13,111 +13,79 @@
#
# 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, Any, Dict, Iterable, Optional, Union, Tuple, TYPE_CHECKING
from __future__ import annotations
from typing import Awaitable, AsyncGenerator, AsyncIterable, TYPE_CHECKING, cast
from difflib import SequenceMatcher
import unicodedata
import asyncio
import logging
from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer,
InputPeerPhotoFileLocation, UserProfilePhotoEmpty, TypeInputUser)
from yarl import URL
from mautrix.appservice import AppService, IntentAPI
from mautrix.errors import MatrixRequestError, MatrixError
from mautrix.bridge import BasePuppet
from mautrix.appservice import IntentAPI
from mautrix.errors import MatrixError
from mautrix.bridge import BasePuppet, async_getter_lock
from mautrix.types import UserID, SyncToken, RoomID, ContentURI
from mautrix.util.simple_template import SimpleTemplate
from mautrix.util.logging import TraceLogger
from .config import Config
from .types import TelegramID
from .db import Puppet as DBPuppet
from . import util, portal as p
from . import util, portal as p, abstract_user as au
if TYPE_CHECKING:
from .matrix import MatrixHandler
from .config import Config
from .context import Context
from .abstract_user import AbstractUser
config: Optional['Config'] = None
from .__main__ import TelegramBridge
class Puppet(BasePuppet):
log: TraceLogger = logging.getLogger("mau.puppet")
az: AppService
mx: 'MatrixHandler'
loop: asyncio.AbstractEventLoop
class Puppet(DBPuppet, BasePuppet):
config: Config
hs_domain: str
mxid_template: SimpleTemplate[TelegramID]
displayname_template: SimpleTemplate[str]
cache: Dict[TelegramID, 'Puppet'] = {}
by_custom_mxid: Dict[UserID, 'Puppet'] = {}
by_tgid: dict[TelegramID, Puppet] = {}
by_custom_mxid: dict[UserID, Puppet] = {}
id: TelegramID
access_token: Optional[str]
custom_mxid: Optional[UserID]
_next_batch: Optional[SyncToken]
base_url: Optional[URL]
default_mxid: UserID
def __init__(
self,
id: TelegramID,
is_registered: bool = False,
displayname: str | None = None,
displayname_source: TelegramID | None = None,
displayname_contact: bool = True,
displayname_quality: int = 0,
disable_updates: bool = False,
username: str | None = None,
photo_id: str | None = None,
is_bot: bool = False,
custom_mxid: UserID | None = None,
access_token: str | None = None,
next_batch: SyncToken | None = None,
base_url: str | None = None
) -> None:
super().__init__(
id=id,
is_registered=is_registered,
displayname=displayname,
displayname_source=displayname_source,
displayname_contact=displayname_contact,
displayname_quality=displayname_quality,
disable_updates=disable_updates,
username=username,
photo_id=photo_id,
is_bot=is_bot,
custom_mxid=custom_mxid,
access_token=access_token,
next_batch=next_batch,
base_url=base_url,
)
username: Optional[str]
displayname: Optional[str]
displayname_source: Optional[TelegramID]
displayname_contact: bool
displayname_quality: int
photo_id: Optional[str]
is_bot: bool
is_registered: bool
disable_updates: bool
default_mxid_intent: IntentAPI
intent: IntentAPI
sync_task: Optional[asyncio.Future]
_db_instance: Optional[DBPuppet]
def __init__(self,
id: TelegramID,
access_token: Optional[str] = None,
custom_mxid: Optional[UserID] = None,
next_batch: Optional[SyncToken] = None,
base_url: Optional[str] = None,
username: Optional[str] = None,
displayname: Optional[str] = None,
displayname_source: Optional[TelegramID] = None,
displayname_contact: bool = True,
displayname_quality: int = 0,
photo_id: Optional[str] = None,
is_bot: bool = False,
is_registered: bool = False,
disable_updates: bool = False,
db_instance: Optional[DBPuppet] = None) -> None:
self.id = id
self.access_token = access_token
self.custom_mxid = custom_mxid
self._next_batch = next_batch
self.base_url = URL(base_url) if base_url else None
self.default_mxid = self.get_mxid_from_id(self.id)
self.username = username
self.displayname = displayname
self.displayname_source = displayname_source
self.displayname_contact = displayname_contact
self.displayname_quality = displayname_quality
self.photo_id = photo_id
self.is_bot = is_bot
self.is_registered = is_registered
self.disable_updates = disable_updates
self._db_instance = db_instance
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
self.intent = self._fresh_intent()
self.sync_task = None
self.cache[id] = self
self.by_tgid[id] = self
if self.custom_mxid:
self.by_custom_mxid[self.custom_mxid] = self
@@ -128,76 +96,59 @@ class Puppet(BasePuppet):
return self.id
@property
def peer(self) -> PeerUser:
return PeerUser(user_id=self.tgid)
def tg_username(self) -> str | None:
return self.username
@property
def next_batch(self) -> SyncToken:
return self._next_batch
@next_batch.setter
def next_batch(self, value: SyncToken) -> None:
self._next_batch = value
self.db_instance.edit(next_batch=self._next_batch)
@staticmethod
async def is_logged_in() -> bool:
""" Is True if the puppet is logged in. """
return True
def peer(self) -> PeerUser:
return PeerUser(user_id=self.tgid)
@property
def plain_displayname(self) -> str:
return self.displayname_template.parse(self.displayname) or self.displayname
def get_input_entity(self, user: 'AbstractUser'
) -> Awaitable[Union[TypeInputPeer, TypeInputUser]]:
def get_input_entity(self, user: au.AbstractUser) -> Awaitable[TypeInputPeer | TypeInputUser]:
return user.client.get_input_entity(self.peer)
def intent_for(self, portal: 'p.Portal') -> IntentAPI:
def intent_for(self, portal: p.Portal) -> IntentAPI:
if portal.tgid == self.tgid:
return self.default_mxid_intent
return self.intent
# region DB conversion
@property
def db_instance(self) -> DBPuppet:
if not self._db_instance:
self._db_instance = self.new_db_instance()
return self._db_instance
@property
def _fields(self) -> Dict[str, Any]:
return dict(access_token=self.access_token, next_batch=self._next_batch,
custom_mxid=self.custom_mxid, username=self.username, is_bot=self.is_bot,
displayname=self.displayname, displayname_source=self.displayname_source,
displayname_contact=self.displayname_contact,
displayname_quality=self.displayname_quality, photo_id=self.photo_id,
matrix_registered=self.is_registered, disable_updates=self.disable_updates,
base_url=str(self.base_url) if self.base_url else None)
def new_db_instance(self) -> DBPuppet:
return DBPuppet(id=self.id, **self._fields)
async def save(self) -> None:
self.db_instance.edit(**self._fields)
@classmethod
def from_db(cls, db_puppet: DBPuppet) -> 'Puppet':
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid,
db_puppet.next_batch, db_puppet.base_url, db_puppet.username,
db_puppet.displayname, db_puppet.displayname_source,
db_puppet.displayname_contact, db_puppet.displayname_quality,
db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered,
db_puppet.disable_updates, db_instance=db_puppet)
def init_cls(cls, bridge: 'TelegramBridge') -> AsyncIterable[Awaitable[None]]:
cls.config = bridge.config
cls.loop = bridge.loop
cls.mx = bridge.matrix
cls.az = bridge.az
cls.hs_domain = cls.config["homeserver.domain"]
mxid_tpl = SimpleTemplate(
cls.config["bridge.username_template"],
"userid",
prefix="@",
suffix=f":{Puppet.hs_domain}",
type=int,
)
cls.mxid_template = cast(SimpleTemplate[TelegramID], mxid_tpl)
cls.displayname_template = SimpleTemplate(
cls.config["bridge.displayname_template"], "displayname"
)
cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"]
cls.homeserver_url_map = {server: URL(url) for server, url
in cls.config["bridge.double_puppet_server_map"].items()}
cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"]
cls.login_shared_secret_map = {server: secret.encode("utf-8") for server, secret
in cls.config["bridge.login_shared_secret_map"].items()}
cls.login_device_name = "Telegram Bridge"
return (puppet.try_start() async for puppet in cls.all_with_custom_mxid())
# endregion
# region Info updating
def similarity(self, query: str) -> int:
username_similarity = (SequenceMatcher(None, self.username, query).ratio()
if self.username else 0)
displayname_similarity = (SequenceMatcher(None, self.displayname, query).ratio()
displayname_similarity = (SequenceMatcher(None, self.plain_displayname, query).ratio()
if self.displayname else 0)
similarity = max(username_similarity, displayname_similarity)
return int(round(similarity * 100))
@@ -211,11 +162,11 @@ class Puppet(BasePuppet):
"\u200c\u200d\u200e\u200f\ufe0f")
allowed_other_format = ("\u200d", "\u200c")
name = "".join(c for c in name.strip(whitespace) if unicodedata.category(c) != 'Cf'
or c in allowed_other_format)
or c in allowed_other_format)
return name
@classmethod
def get_displayname(cls, info: User, enable_format: bool = True) -> Tuple[str, int]:
def get_displayname(cls, info: User, enable_format: bool = True) -> tuple[str, int]:
fn = cls._filter_name(info.first_name)
ln = cls._filter_name(info.last_name)
data = {
@@ -226,7 +177,7 @@ class Puppet(BasePuppet):
"first name": fn,
"last name": ln,
}
preferences = config["bridge.displayname_preference"]
preferences = cls.config["bridge.displayname_preference"]
name = None
quality = 99
for preference in preferences:
@@ -244,13 +195,13 @@ class Puppet(BasePuppet):
return (cls.displayname_template.format_full(name) if enable_format else name), quality
async def try_update_info(self, source: 'AbstractUser', info: User) -> None:
async def try_update_info(self, source: au.AbstractUser, info: User) -> None:
try:
await self.update_info(source, info)
except Exception:
source.log.exception(f"Failed to update info of {self.tgid}")
async def update_info(self, source: 'AbstractUser', info: User) -> None:
async def update_info(self, source: au.AbstractUser, info: User) -> None:
changed = False
if self.username != info.username:
self.username = info.username
@@ -268,7 +219,7 @@ class Puppet(BasePuppet):
if changed:
await self.save()
async def update_displayname(self, source: 'AbstractUser', info: Union[User, UpdateUserName]
async def update_displayname(self, source: au.AbstractUser, info: User | UpdateUserName
) -> bool:
if self.disable_updates:
return False
@@ -306,7 +257,7 @@ class Puppet(BasePuppet):
self.displayname_quality = quality
try:
await self.default_mxid_intent.set_displayname(
displayname[:config["bridge.displayname_max_length"]])
displayname[:self.config["bridge.displayname_max_length"]])
except MatrixError:
self.log.exception("Failed to set displayname")
self.displayname = ""
@@ -318,8 +269,8 @@ class Puppet(BasePuppet):
return True
return False
async def update_avatar(self, source: 'AbstractUser',
photo: Union[UserProfilePhoto, UserProfilePhotoEmpty]) -> bool:
async def update_avatar(self, source: au.AbstractUser,
photo: UserProfilePhoto | UserProfilePhotoEmpty) -> bool:
if self.disable_updates:
return False
@@ -330,7 +281,7 @@ class Puppet(BasePuppet):
else:
self.log.warning(f"Unknown user profile photo type: {type(photo)}")
return False
if not photo_id and not config["bridge.allow_avatar_remove"]:
if not photo_id and not self.config["bridge.allow_avatar_remove"]:
return False
if self.photo_id != photo_id:
if not photo_id:
@@ -359,72 +310,73 @@ class Puppet(BasePuppet):
return False
async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
portal: p.Portal = p.Portal.get_by_mxid(room_id)
portal: p.Portal = await p.Portal.get_by_mxid(room_id)
return portal and not portal.backfill_lock.locked and portal.peer_type != "user"
# endregion
# region Getters
def _add_to_cache(self) -> None:
self.by_tgid[self.id] = self
if self.custom_mxid:
self.by_custom_mxid[self.custom_mxid] = self
@classmethod
def get(cls, tgid: TelegramID, create: bool = True) -> Optional['Puppet']:
@async_getter_lock
async def get_by_tgid(cls, tgid: TelegramID, *, create: bool = True) -> Puppet | None:
if tgid is None:
return None
try:
return cls.cache[tgid]
return cls.by_tgid[tgid]
except KeyError:
pass
puppet = DBPuppet.get_by_tgid(tgid)
puppet = cast(cls, await super().get_by_tgid(tgid))
if puppet:
return cls.from_db(puppet)
puppet._add_to_cache()
return puppet
if create:
puppet = cls(tgid)
puppet.db_instance.insert()
await puppet.insert()
puppet._add_to_cache()
return puppet
return None
@classmethod
def deprecated_sync_get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
tgid = cls.get_id_from_mxid(mxid)
if tgid:
return cls.get(tgid, create)
return None
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Awaitable[Puppet | None]:
return cls.get_by_tgid(cls.get_id_from_mxid(mxid), create=create)
@classmethod
async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
return cls.deprecated_sync_get_by_mxid(mxid, create)
@classmethod
def deprecated_sync_get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
@async_getter_lock
async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
try:
return cls.by_custom_mxid[mxid]
except KeyError:
pass
puppet = DBPuppet.get_by_custom_mxid(mxid)
puppet = cast(cls, await super().get_by_custom_mxid(mxid))
if puppet:
puppet = cls.from_db(puppet)
puppet._add_to_cache()
return puppet
return None
@classmethod
async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
return cls.deprecated_sync_get_by_custom_mxid(mxid)
async def all_with_custom_mxid(cls) -> AsyncGenerator[Puppet, None]:
puppets = await super().all_with_custom_mxid()
puppet: cls
for puppet in puppets:
try:
yield cls.by_tgid[puppet.tgid]
except KeyError:
puppet._add_to_cache()
yield puppet
@classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
return (cls.by_custom_mxid[puppet.custom_mxid]
if puppet.custom_mxid in cls.by_custom_mxid
else cls.from_db(puppet)
for puppet in DBPuppet.all_with_custom_mxid())
@classmethod
def get_id_from_mxid(cls, mxid: UserID) -> Optional[TelegramID]:
def get_id_from_mxid(cls, mxid: UserID) -> TelegramID | None:
return cls.mxid_template.parse(mxid)
@classmethod
@@ -432,56 +384,43 @@ class Puppet(BasePuppet):
return UserID(cls.mxid_template.format_full(tgid))
@classmethod
def find_by_username(cls, username: str) -> Optional['Puppet']:
async def find_by_username(cls, username: str) -> Puppet | None:
if not username:
return None
username = username.lower()
for _, puppet in cls.cache.items():
for _, puppet in cls.by_tgid.items():
if puppet.username and puppet.username.lower() == username:
return puppet
dbpuppet = DBPuppet.get_by_username(username)
if dbpuppet:
return cls.from_db(dbpuppet)
puppet = cast(cls, await super().find_by_username(username))
if puppet:
try:
return cls.by_tgid[puppet.tgid]
except KeyError:
puppet._add_to_cache()
return puppet
return None
@classmethod
def find_by_displayname(cls, displayname: str) -> Optional['Puppet']:
async def find_by_displayname(cls, displayname: str) -> Puppet | None:
if not displayname:
return None
for _, puppet in cls.cache.items():
for _, puppet in cls.by_tgid.items():
if puppet.displayname and puppet.displayname == displayname:
return puppet
dbpuppet = DBPuppet.get_by_displayname(displayname)
if dbpuppet:
return cls.from_db(dbpuppet)
puppet = cast(cls, await super().find_by_displayname(displayname))
if puppet:
try:
return cls.by_tgid[puppet.tgid]
except KeyError:
puppet._add_to_cache()
return puppet
return None
# endregion
def init(context: 'Context') -> Iterable[Awaitable[Any]]:
global config
Puppet.az, config, Puppet.loop, _ = context.core
Puppet.mx = context.mx
Puppet.hs_domain = config["homeserver"]["domain"]
Puppet.mxid_template = SimpleTemplate(config["bridge.username_template"], "userid",
prefix="@", suffix=f":{Puppet.hs_domain}", type=int)
Puppet.displayname_template = SimpleTemplate(config["bridge.displayname_template"],
"displayname")
Puppet.sync_with_custom_puppets = config["bridge.sync_with_custom_puppets"]
Puppet.homeserver_url_map = {server: URL(url) for server, url
in config["bridge.double_puppet_server_map"].items()}
Puppet.allow_discover_url = config["bridge.double_puppet_allow_discovery"]
Puppet.login_shared_secret_map = {server: secret.encode("utf-8") for server, secret
in config["bridge.login_shared_secret_map"].items()}
Puppet.login_device_name = "Telegram Bridge"
return (puppet.try_start() for puppet in Puppet.all_with_custom_mxid())
@@ -1,88 +0,0 @@
from typing import Union
import argparse
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declarative_base
import sqlalchemy as sql
from alchemysession import AlchemySessionContainer
parser = argparse.ArgumentParser(description="mautrix-telegram dbms migration script",
prog="python -m mautrix_telegram.scripts.dbms_migrate")
parser.add_argument("-f", "--from-url", type=str, required=True, metavar="<url>",
help="the old database path")
parser.add_argument("-t", "--to-url", type=str, required=True, metavar="<url>",
help="the new database path")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logs while migrating")
args = parser.parse_args()
verbose = args.verbose or False
def log(message, end="\n"):
if verbose:
print(message, end=end, flush=True)
def connect(to):
from mautrix.util.db import Base
from mautrix.client.state_store.sqlalchemy import RoomState, UserProfile
from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat,
TelegramFile)
db_engine = sql.create_engine(to)
db_factory = orm.sessionmaker(bind=db_engine)
db_session: Union[orm.Session, orm.scoped_session] = orm.scoped_session(db_factory)
Base.metadata.bind = db_engine
new_base = declarative_base()
new_base.metadata.bind = db_engine
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
table_base=new_base, table_prefix="telethon_",
manage_tables=False)
return db_session, {
"Version": session_container.Version,
"Session": session_container.Session,
"Entity": session_container.Entity,
"SentFile": session_container.SentFile,
"UpdateState": session_container.UpdateState,
"Portal": Portal,
"Message": Message,
"Puppet": Puppet,
"User": User,
"UserPortal": UserPortal,
"RoomState": RoomState,
"UserProfile": UserProfile,
"Contact": Contact,
"BotChat": BotChat,
"TelegramFile": TelegramFile,
}
log("Connecting to old database")
session, tables = connect(args.from_url)
data = {}
for name, table in tables.items():
log("Reading table {name}...".format(name=name), end=" ")
data[name] = session.query(table).all()
log("Done!")
log("Connecting to new database")
session, tables = connect(args.to_url)
for name, table in tables.items():
log("Writing table {name}".format(name=name), end="")
length = len(data[name])
n = 0
for row in data[name]:
session.merge(row)
n += 5
if n >= length:
log(".", end="")
n = 0
log(" Done!")
log("Committing changes to database...", end=" ")
session.commit()
log("Done!")
@@ -1,125 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
import argparse
from sqlalchemy import orm
import sqlalchemy as sql
from mautrix.util.db import Base
from mautrix_telegram.db import Portal, Message, Puppet, BotChat
from mautrix_telegram.config import Config
from .models import ChatLink, TgUser, MatrixUser, Message as TMMessage, Base as TelematrixBase
parser = argparse.ArgumentParser(
description="mautrix-telegram telematrix import script",
prog="python -m mautrix_telegram.scripts.telematrix_import")
parser.add_argument("-c", "--config", type=str, default="config.yaml",
metavar="<path>", help="the path to your mautrix-telegram config file")
parser.add_argument("-b", "--bot-id", type=int, required=True,
metavar="<id>", help="the telegram user ID of your relay bot")
parser.add_argument("-t", "--telematrix-database", type=str, default="sqlite:///database.db",
metavar="<url>", help="your telematrix database URL")
args = parser.parse_args()
config = Config(args.config, None, None)
config.load()
mxtg_db_engine = sql.create_engine(config["appservice.database"])
mxtg = orm.sessionmaker(bind=mxtg_db_engine)()
Base.metadata.bind = mxtg_db_engine
telematrix_db_engine = sql.create_engine(args.telematrix_database)
telematrix = orm.sessionmaker(bind=telematrix_db_engine)()
TelematrixBase.metadata.bind = telematrix_db_engine
chat_links = telematrix.query(ChatLink).all()
tg_users = telematrix.query(TgUser).all()
mx_users = telematrix.query(MatrixUser).all()
tm_messages = telematrix.query(TMMessage).all()
telematrix.close()
telematrix_db_engine.dispose()
portals_by_tgid: Dict[int, Portal] = {}
portals_by_mxid: Dict[str, Portal] = {}
chats: Dict[int, BotChat] = {}
messages: Dict[str, Message] = {}
puppets: Dict[int, Puppet] = {}
for chat_link in chat_links:
if type(chat_link.tg_room) is str:
print(f"Expected tg_room to be a number, got a string. Ignoring {chat_link.tg_room}")
continue
if chat_link.tg_room >= 0:
print(f"Unexpected unprefixed telegram chat ID: {chat_link.tg_room}, ignoring...")
continue
tgid = str(chat_link.tg_room)
if tgid.startswith("-100"):
tgid = int(tgid[4:])
peer_type = "channel"
megagroup = True
else:
tgid = -chat_link.tg_room
peer_type = "chat"
megagroup = False
portal = Portal(tgid=tgid, tg_receiver=tgid, peer_type=peer_type, megagroup=megagroup,
mxid=chat_link.matrix_room)
chats[tgid] = BotChat(id=tgid, type=peer_type)
if chat_link.tg_room in portals_by_tgid:
print(f"Warning: Ignoring bridge from {portal.tgid} to {portal.mxid} "
f"in favor of {portals_by_tgid[portal.tgid].mxid}")
continue
elif chat_link.matrix_room in portals_by_mxid:
print(f"Warning: Ignoring bridge from {portal.mxid} to {portal.tgid} "
f"in favor of {portals_by_mxid[portal.mxid].tgid}")
continue
portals_by_tgid[portal.tgid] = portal
portals_by_mxid[portal.mxid] = portal
for tm_msg in tm_messages:
try:
portal = portals_by_tgid[tm_msg.tg_group_id]
except KeyError:
print(f"Found message entry {tm_msg.tg_message_id} in unlinked chat {tm_msg.tg_group_id},"
" ignoring...")
continue
if tm_msg.matrix_room_id != portal.mxid:
print(f"Found message entry {tm_msg.tg_message_id} with "
f"mismatching matrix room ID {tm_msg.matrix_room_id} (expected {portal.mxid})")
continue
tg_space = portal.tgid if portal.peer_type == "channel" else args.bot_id
message = Message(mxid=tm_msg.matrix_event_id, mx_room=tm_msg.matrix_room_id,
tgid=tm_msg.tg_message_id, tg_space=tg_space)
messages[tm_msg.matrix_event_id] = message
for user in tg_users:
puppets[user.tg_id] = Puppet(id=user.tg_id, displayname=user.name,
displayname_source=args.bot_id)
for k, v in portals_by_tgid.items():
mxtg.add(v)
for k, v in chats.items():
mxtg.add(v)
for k, v in messages.items():
mxtg.add(v)
for k, v in puppets.items():
mxtg.add(v)
mxtg.commit()
@@ -1,44 +0,0 @@
import sqlalchemy as sa
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class ChatLink(Base):
__tablename__ = "chat_link"
id = sa.Column(sa.Integer, primary_key=True)
matrix_room = sa.Column(sa.String)
tg_room = sa.Column(sa.BigInteger)
active = sa.Column(sa.Boolean)
class TgUser(Base):
__tablename__ = "tg_user"
id = sa.Column(sa.Integer, primary_key=True)
tg_id = sa.Column(sa.BigInteger)
name = sa.Column(sa.String)
profile_pic_id = sa.Column(sa.String, nullable=True)
class MatrixUser(Base):
__tablename__ = "matrix_user"
id = sa.Column(sa.Integer, primary_key=True)
matrix_id = sa.Column(sa.String)
name = sa.Column(sa.String)
class Message(Base):
"""Describes a message in a room bridged between Telegram and Matrix"""
__tablename__ = "message"
id = sa.Column(sa.Integer, primary_key=True)
tg_group_id = sa.Column(sa.BigInteger)
tg_message_id = sa.Column(sa.BigInteger)
matrix_room_id = sa.Column(sa.String)
matrix_event_id = sa.Column(sa.String)
displayname = sa.Column(sa.String)
+210 -216
View File
@@ -13,10 +13,10 @@
#
# 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, Iterable, NamedTuple, Optional, Tuple, Any, cast,
TYPE_CHECKING)
from __future__ import annotations
from typing import Awaitable, AsyncIterable, NamedTuple, AsyncGenerator, TYPE_CHECKING, cast
from datetime import datetime, timezone
import logging
import asyncio
from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage,
@@ -35,21 +35,17 @@ from mautrix.client import Client
from mautrix.errors import MatrixRequestError, MNotFound
from mautrix.types import UserID, RoomID, PushRuleScope, PushRuleKind, PushActionType, RoomTagInfo
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY
from mautrix.bridge import BaseUser
from mautrix.bridge import BaseUser, async_getter_lock
from mautrix.util.bridge_state import BridgeState, BridgeStateEvent
from mautrix.util.logging import TraceLogger
from mautrix.util.opt_prometheus import Gauge
from .types import TelegramID
from .db import User as DBUser, Portal as DBPortal, Message as DBMessage
from .db import User as DBUser, Message as DBMessage, PgSession
from .abstract_user import AbstractUser
from . import portal as po, puppet as pu
if TYPE_CHECKING:
from .config import Config
from .context import Context
config: Optional['Config'] = None
from .__main__ import TelegramBridge
SearchResult = NamedTuple('SearchResult', puppet='pu.Puppet', similarity=int)
@@ -64,54 +60,46 @@ BridgeState.human_readable_errors.update({
})
class User(AbstractUser, BaseUser):
log: TraceLogger = logging.getLogger("mau.user")
by_mxid: Dict[str, 'User'] = {}
by_tgid: Dict[int, 'User'] = {}
class User(DBUser, AbstractUser, BaseUser):
by_mxid: dict[str, User] = {}
by_tgid: dict[int, User] = {}
phone: Optional[str]
contacts: List['pu.Puppet']
saved_contacts: int
portals: Dict[Tuple[TelegramID, TelegramID], 'po.Portal']
command_status: Optional[Dict[str, Any]]
_portals_cache: dict[tuple[TelegramID, TelegramID], po.Portal] | None
_db_instance: Optional[DBUser]
_ensure_started_lock: asyncio.Lock
_track_connection_task: Optional[asyncio.Task]
_track_connection_task: asyncio.Task | None
_is_backfilling: bool
def __init__(self, mxid: UserID, tgid: Optional[TelegramID] = None,
username: Optional[str] = None, phone: Optional[str] = None,
db_contacts: Optional[Iterable[TelegramID]] = None,
saved_contacts: int = 0, is_bot: bool = False,
db_portals: Optional[Iterable[Tuple[TelegramID, TelegramID]]] = None,
db_instance: Optional[DBUser] = None) -> None:
def __init__(
self,
mxid: UserID,
tgid: TelegramID | None = None,
tg_username: str | None = None,
tg_phone: str | None = None,
is_bot: bool = False,
saved_contacts: int = 0,
) -> None:
super().__init__(
mxid=mxid,
tgid=tgid,
tg_username=tg_username,
tg_phone=tg_phone,
is_bot=is_bot,
saved_contacts=saved_contacts,
)
AbstractUser.__init__(self)
self.mxid = mxid
BaseUser.__init__(self)
self.tgid = tgid
self.is_bot = is_bot
self.username = username
self.phone = phone
self.contacts = []
self.saved_contacts = saved_contacts
self.db_contacts = db_contacts
self.portals = {}
self.db_portals = db_portals or []
self._db_instance = db_instance
self._ensure_started_lock = asyncio.Lock()
self._track_connection_task = None
self._is_backfilling = False
self._portals_cache = None
(self.relaybot_whitelisted,
self.whitelisted,
self.puppet_whitelisted,
self.matrix_puppet_whitelisted,
self.is_admin,
self.permissions) = config.get_permissions(self.mxid)
self.by_mxid[mxid] = self
if tgid:
self.by_tgid[tgid] = self
self.permissions) = self.config.get_permissions(self.mxid)
@property
def name(self) -> str:
@@ -124,7 +112,7 @@ class User(AbstractUser, BaseUser):
@property
def human_tg_id(self) -> str:
return f"@{self.username}" if self.username else f"+{self.phone}" or None
return f"@{self.tg_username}" if self.tg_username else f"+{self.tg_phone}" or None
# TODO replace with proper displayname getting everywhere
@property
@@ -135,65 +123,15 @@ class User(AbstractUser, BaseUser):
def plain_displayname(self) -> str:
return self.displayname
@property
def db_contacts(self) -> Iterable[TelegramID]:
return (puppet.id
for puppet in self.contacts
if puppet)
@db_contacts.setter
def db_contacts(self, contacts: Iterable[TelegramID]) -> None:
self.contacts = [pu.Puppet.get(entry) for entry in contacts] if contacts else []
@property
def db_portals(self) -> Iterable[Tuple[TelegramID, TelegramID]]:
return (portal.tgid_full
for portal in self.portals.values()
if portal and not portal.deleted)
@db_portals.setter
def db_portals(self, portals: Iterable[Tuple[TelegramID, TelegramID]]) -> None:
self.portals = {
tgid_full: po.Portal.get_by_tgid(*tgid_full)
for tgid_full in portals
} if portals else {}
# region Database conversion
@property
def db_instance(self) -> DBUser:
if not self._db_instance:
self._db_instance = self.new_db_instance()
return self._db_instance
def new_db_instance(self) -> DBUser:
return DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username,
saved_contacts=self.saved_contacts, portals=self.db_portals)
async def save(self, contacts: bool = False, portals: bool = False) -> None:
self.db_instance.edit(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
saved_contacts=self.saved_contacts)
if contacts:
self.db_instance.contacts = self.db_contacts
if portals:
self.db_instance.portals = self.db_portals
def delete(self, delete_db: bool = True) -> None:
try:
del self.by_mxid[self.mxid]
del self.by_tgid[self.tgid]
except KeyError:
pass
if delete_db and self._db_instance:
self._db_instance.delete()
@classmethod
def from_db(cls, db_user: DBUser) -> 'User':
return User(db_user.mxid, db_user.tgid, db_user.tg_username, db_user.tg_phone,
db_user.contacts, db_user.saved_contacts, False, db_user.portals,
db_instance=db_user)
def init_cls(cls, bridge: 'TelegramBridge') -> AsyncIterable[Awaitable[User]]:
cls.config = bridge.config
cls.bridge = bridge
cls.az = bridge.az
cls.loop = bridge.loop
return (user.try_ensure_started() async for user in cls.all_with_tgid())
# endregion
# region Telegram connection management
async def try_ensure_started(self) -> None:
@@ -202,19 +140,19 @@ class User(AbstractUser, BaseUser):
except Exception:
self.log.exception("Exception in ensure_started")
else:
if not self.client and not self.session_container.has_session(self.mxid):
if not self.client and not await PgSession.has(self.mxid):
self.log.warning("Didn't start user: no session stored")
if self.tgid:
await self.push_bridge_state(BridgeStateEvent.BAD_CREDENTIALS,
error="tg-no-auth")
async def ensure_started(self, even_if_no_session=False) -> 'User':
async def ensure_started(self, even_if_no_session=False) -> User:
if not self.puppet_whitelisted or self.connected:
return self
async with self._ensure_started_lock:
return cast(User, await super().ensure_started(even_if_no_session))
async def start(self, delete_unless_authenticated: bool = False) -> 'User':
async def start(self, delete_unless_authenticated: bool = False) -> User:
try:
await super().start()
except AuthKeyDuplicatedError:
@@ -222,7 +160,7 @@ class User(AbstractUser, BaseUser):
await self.push_bridge_state(BridgeStateEvent.BAD_CREDENTIALS,
error="tg-auth-key-duplicated")
await self.client.disconnect()
self.client.session.delete()
await self.client.session.delete()
self.client = None
if not delete_unless_authenticated:
# The caller wants the client to be connected, so restart the connection.
@@ -257,7 +195,7 @@ class User(AbstractUser, BaseUser):
if delete_unless_authenticated:
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting session...")
await self.client.disconnect()
self.client.session.delete()
await self.client.session.delete()
return self
@property
@@ -283,7 +221,7 @@ class User(AbstractUser, BaseUser):
state.remote_id = str(self.tgid)
state.remote_name = self.human_tg_id
async def get_bridge_states(self) -> List[BridgeState]:
async def get_bridge_states(self) -> list[BridgeState]:
if not self.tgid:
return []
if self._is_connected and await self.is_logged_in():
@@ -295,10 +233,10 @@ class User(AbstractUser, BaseUser):
ttl = 240
return [BridgeState(state_event=state_event, ttl=ttl)]
async def get_puppet(self) -> Optional['pu.Puppet']:
async def get_puppet(self) -> pu.Puppet | None:
if not self.tgid:
return None
return pu.Puppet.get(self.tgid)
return await pu.Puppet.get_by_tgid(self.tgid)
async def stop(self) -> None:
if self._track_connection_task:
@@ -308,7 +246,7 @@ class User(AbstractUser, BaseUser):
self._track_metric(METRIC_CONNECTED, False)
async def post_login(self, info: TLUser = None, first_login: bool = False) -> None:
if config["metrics.enabled"] and not self._track_connection_task:
if self.config["metrics.enabled"] and not self._track_connection_task:
self._track_connection_task = self.loop.create_task(self._track_connection())
try:
@@ -320,14 +258,14 @@ class User(AbstractUser, BaseUser):
self._track_metric(METRIC_LOGGED_IN, True)
try:
puppet = pu.Puppet.get(self.tgid)
puppet = await pu.Puppet.get_by_tgid(self.tgid)
if puppet.custom_mxid != self.mxid and puppet.can_auto_login(self.mxid):
self.log.info(f"Automatically enabling custom puppet")
await puppet.switch_mxid(access_token="auto", mxid=self.mxid)
except Exception:
self.log.exception("Failed to automatically enable custom puppet")
if not self.is_bot and config["bridge.startup_sync"]:
if not self.is_bot and self.config["bridge.startup_sync"]:
try:
self._is_backfilling = True
await self.sync_dialogs()
@@ -342,11 +280,13 @@ class User(AbstractUser, BaseUser):
return False
if isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
portal = po.Portal.get_by_entity(update.message.peer_id, receiver_id=self.tgid)
portal = await po.Portal.get_by_entity(update.message.peer_id, tg_receiver=self.tgid)
elif isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
portal = await po.Portal.get_by_tgid(
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
)
else:
return False
@@ -364,7 +304,7 @@ class User(AbstractUser, BaseUser):
if not self.is_bot:
await self.client(UpdateStatusRequest(offline=not online))
async def get_me(self) -> Optional[TLUser]:
async def get_me(self) -> TLUser | None:
try:
return (await self.client(GetUsersRequest([InputUserSelf()])))[0]
except UnauthorizedError as e:
@@ -384,11 +324,11 @@ class User(AbstractUser, BaseUser):
if self.is_bot != info.bot:
self.is_bot = info.bot
changed = True
if self.username != info.username:
self.username = info.username
if self.tg_username != info.username:
self.tg_username = info.username
changed = True
if self.phone != info.phone:
self.phone = info.phone
if self.tg_phone != info.phone:
self.tg_phone = info.phone
changed = True
if self.tgid != info.id:
self.tgid = TelegramID(info.id)
@@ -396,11 +336,11 @@ class User(AbstractUser, BaseUser):
if changed:
await self.save()
async def log_out(self) -> bool:
puppet = pu.Puppet.get(self.tgid)
if puppet.is_real_user:
await puppet.switch_mxid(None, None)
for _, portal in self.portals.items():
async def kick_from_portals(self) -> None:
if not self.config["bridge.kick_on_logout"]:
return
portals = await self.get_cached_portals()
for _, portal in portals.values():
if not portal or portal.deleted or not portal.mxid or portal.has_bot:
continue
if portal.peer_type == "user":
@@ -411,9 +351,15 @@ class User(AbstractUser, BaseUser):
"Logged out of Telegram.")
except MatrixRequestError:
pass
self.portals = {}
self.contacts = []
await self.save(portals=True, contacts=True)
async def log_out(self) -> bool:
puppet = await pu.Puppet.get_by_tgid(self.tgid)
if puppet.is_real_user:
await puppet.switch_mxid(None, None)
try:
await self.kick_from_portals()
except Exception:
self.log.exception("Failed to kick user from portals on logout")
await self.push_bridge_state(BridgeStateEvent.LOGGED_OUT)
if self.tgid:
try:
@@ -421,51 +367,54 @@ class User(AbstractUser, BaseUser):
except KeyError:
pass
self.tgid = None
await self.save()
ok = await self.client.log_out()
self.client.session.delete()
self.delete()
await self.client.session.delete()
await self.delete()
self.by_mxid.pop(self.mxid, None)
await self.stop()
self._track_metric(METRIC_LOGGED_IN, False)
return ok
def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
) -> List[SearchResult]:
results: List[SearchResult] = []
for contact in self.contacts:
async def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
) -> list[SearchResult]:
results: list[SearchResult] = []
for contact_id in await self.get_contacts():
contact = await pu.Puppet.get_by_tgid(contact_id, create=False)
if not contact:
continue
similarity = contact.similarity(query)
if similarity >= min_similarity:
results.append(SearchResult(contact, similarity))
results.sort(key=lambda tup: tup[1], reverse=True)
return results[0:max_results]
async def _search_remote(self, query: str, max_results: int = 5) -> List[SearchResult]:
async def _search_remote(self, query: str, max_results: int = 5) -> list[SearchResult]:
if len(query) < 5:
return []
server_results = await self.client(SearchRequest(q=query, limit=max_results))
results: List[SearchResult] = []
results: list[SearchResult] = []
for user in server_results.users:
puppet = pu.Puppet.get(user.id)
puppet = await pu.Puppet.get_by_tgid(user.id)
await puppet.update_info(self, user)
results.append(SearchResult(puppet, puppet.similarity(query)))
results.sort(key=lambda tup: tup[1], reverse=True)
return results[0:max_results]
async def search(self, query: str, force_remote: bool = False
) -> Tuple[List[SearchResult], bool]:
) -> tuple[list[SearchResult], bool]:
if force_remote:
return await self._search_remote(query), True
results = self._search_local(query)
results = await self._search_local(query)
if results:
return results, False
return await self._search_remote(query), True
async def get_direct_chats(self) -> Dict[UserID, List[RoomID]]:
async def get_direct_chats(self) -> dict[UserID, list[RoomID]]:
return {
pu.Puppet.get_mxid_from_id(portal.tgid): [portal.mxid]
for portal in DBPortal.find_private_chats(self.tgid)
async for portal in po.Portal.find_private_chats(self.tgid)
if portal.mxid
}
@@ -478,12 +427,14 @@ class User(AbstractUser, BaseUser):
tag_info = RoomTagInfo(order=0.5)
tag_info[DOUBLE_PUPPET_SOURCE_KEY] = self.bridge.name
await puppet.intent.set_room_tag(portal.mxid, tag, tag_info)
elif not active and tag_info and tag_info.get(DOUBLE_PUPPET_SOURCE_KEY) == self.bridge.name:
elif (
not active and tag_info
and tag_info.get(DOUBLE_PUPPET_SOURCE_KEY) == self.bridge.name
):
await puppet.intent.remove_room_tag(portal.mxid, tag)
@staticmethod
async def _mute_room(puppet: pu.Puppet, portal: po.Portal, mute_until: datetime) -> None:
if not config["bridge.mute_bridging"] or not portal or not portal.mxid:
async def _mute_room(cls, puppet: pu.Puppet, portal: po.Portal, mute_until: datetime) -> None:
if not cls.config["bridge.mute_bridging"] or not portal or not portal.mxid:
return
now = datetime.utcnow().replace(tzinfo=timezone.utc)
if mute_until is not None and mute_until > now:
@@ -497,29 +448,31 @@ class User(AbstractUser, BaseUser):
pass
async def update_folder_peers(self, update: UpdateFolderPeers) -> None:
if config["bridge.tag_only_on_create"]:
if self.config["bridge.tag_only_on_create"]:
return
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
if not puppet or not puppet.is_real_user:
return
for peer in update.folder_peers:
portal = po.Portal.get_by_entity(peer.peer, receiver_id=self.tgid, create=False)
await self._tag_room(puppet, portal, config["bridge.archive_tag"],
portal = await po.Portal.get_by_entity(peer.peer, tg_receiver=self.tgid, create=False)
await self._tag_room(puppet, portal, self.config["bridge.archive_tag"],
peer.folder_id == 1)
async def update_pinned_dialogs(self, update: UpdatePinnedDialogs) -> None:
if config["bridge.tag_only_on_create"]:
if self.config["bridge.tag_only_on_create"]:
return
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
if not puppet or not puppet.is_real_user:
return
# TODO bridge unpinning properly
for pinned in update.order:
portal = po.Portal.get_by_entity(pinned.peer, receiver_id=self.tgid, create=False)
await self._tag_room(puppet, portal, config["bridge.pinned_tag"], True)
portal = await po.Portal.get_by_entity(
pinned.peer, tg_receiver=self.tgid, create=False
)
await self._tag_room(puppet, portal, self.config["bridge.pinned_tag"], True)
async def update_notify_settings(self, update: UpdateNotifySettings) -> None:
if config["bridge.tag_only_on_create"]:
if self.config["bridge.tag_only_on_create"]:
return
elif not isinstance(update.peer, NotifyPeer):
# TODO handle global notification setting changes?
@@ -527,11 +480,13 @@ class User(AbstractUser, BaseUser):
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
if not puppet or not puppet.is_real_user:
return
portal = po.Portal.get_by_entity(update.peer.peer, receiver_id=self.tgid, create=False)
portal = await po.Portal.get_by_entity(
update.peer.peer, tg_receiver=self.tgid, create=False
)
await self._mute_room(puppet, portal, update.notify_settings.mute_until)
async def _sync_dialog(self, portal: po.Portal, dialog: Dialog, should_create: bool,
puppet: Optional[pu.Puppet]) -> None:
puppet: pu.Puppet | None) -> None:
was_created = False
if portal.mxid:
try:
@@ -553,29 +508,41 @@ class User(AbstractUser, BaseUser):
if dialog.unread_count == 0:
# This is usually more reliable than finding a specific message
# e.g. if the last read message is a service message that isn't in the message db
last_read = DBMessage.find_last(portal.mxid, tg_space)
last_read = await DBMessage.find_last(portal.mxid, tg_space)
else:
last_read = DBMessage.get_one_by_tgid(portal.tgid, tg_space,
dialog.dialog.read_inbox_max_id)
last_read = await DBMessage.get_one_by_tgid(portal.tgid, tg_space,
dialog.dialog.read_inbox_max_id)
if last_read:
await puppet.intent.mark_read(last_read.mx_room, last_read.mxid)
if was_created or not config["bridge.tag_only_on_create"]:
if was_created or not self.config["bridge.tag_only_on_create"]:
await self._mute_room(puppet, portal, dialog.dialog.notify_settings.mute_until)
await self._tag_room(puppet, portal, config["bridge.pinned_tag"], dialog.pinned)
await self._tag_room(puppet, portal, config["bridge.archive_tag"], dialog.archived)
await self._tag_room(puppet, portal, self.config["bridge.pinned_tag"],
dialog.pinned)
await self._tag_room(puppet, portal, self.config["bridge.archive_tag"],
dialog.archived)
async def get_cached_portals(self) -> dict[tuple[TelegramID, TelegramID], po.Portal]:
if self._portals_cache is None:
self._portals_cache = {
(tgid, tg_receiver): await po.Portal.get_by_tgid(tgid, tg_receiver=tg_receiver)
for tgid, tg_receiver in await self.get_portals()
}
return self._portals_cache
async def sync_dialogs(self) -> None:
if self.is_bot:
return
creators = []
update_limit = config["bridge.sync_update_limit"] or None
create_limit = config["bridge.sync_create_limit"]
update_limit = self.config["bridge.sync_update_limit"] or None
create_limit = self.config["bridge.sync_create_limit"]
index = 0
self.log.debug(f"Syncing dialogs (update_limit={update_limit}, "
f"create_limit={create_limit})")
await self.push_bridge_state(BridgeStateEvent.BACKFILLING)
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
dialog: Dialog
old_portal_cache = await self.get_cached_portals()
new_portal_cache = old_portal_cache.copy()
async for dialog in self.client.iter_dialogs(limit=update_limit, ignore_migrated=True,
archived=False):
entity = dialog.entity
@@ -585,125 +552,152 @@ class User(AbstractUser, BaseUser):
elif 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"]:
elif isinstance(entity, TLUser) and not self.config["bridge.sync_direct_chats"]:
self.log.trace(f"Ignoring user {entity.id} while syncing")
continue
portal = po.Portal.get_by_entity(entity, receiver_id=self.tgid)
self.portals[portal.tgid_full] = portal
portal = await po.Portal.get_by_entity(entity, tg_receiver=self.tgid)
new_portal_cache[portal.tgid_full] = portal
coro = self._sync_dialog(portal=portal, dialog=dialog, puppet=puppet,
should_create=not create_limit or index < create_limit)
creators.append(self.loop.create_task(coro))
index += 1
await self.save(portals=True)
if new_portal_cache.keys() != old_portal_cache.keys():
await self.set_portals(new_portal_cache.keys())
self._portals_cache = new_portal_cache
await asyncio.gather(*creators)
await self.update_direct_chats()
self.log.debug("Dialog syncing complete")
async def register_portal(self, portal: po.Portal) -> None:
self.log.trace(f"Registering portal {portal.tgid_full}")
try:
if self.portals[portal.tgid_full] == portal:
if self._portals_cache is not None:
if self._portals_cache.get(portal.tgid_full) == portal:
return
except KeyError:
pass
self.portals[portal.tgid_full] = portal
await self.save(portals=True)
self._portals_cache[portal.tgid_full] = portal
await super().register_portal(portal.tgid, portal.tg_receiver)
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
self.log.trace(f"Unregistering portal {(tgid, tg_receiver)}")
try:
del self.portals[(tgid, tg_receiver)]
await self.save(portals=True)
except KeyError:
pass
if self._portals_cache is not None:
self._portals_cache.pop((tgid, tg_receiver), None)
await super().unregister_portal(tgid, tg_receiver)
async def needs_relaybot(self, portal: po.Portal) -> bool:
return not await self.is_logged_in() or (
(portal.has_bot or self.is_bot) and portal.tgid_full not in self.portals)
(portal.has_bot or self.is_bot)
and portal.tgid_full not in await self.get_cached_portals()
)
def _hash_contacts(self) -> int:
@staticmethod
def _hash_contacts(count: int, ids: list[TelegramID]) -> int:
acc = 0
for contact in sorted([self.saved_contacts] + [contact.id for contact in self.contacts]):
for contact in sorted([count] + ids):
acc = (acc * 20261 + contact) & 0xffffffff
return acc & 0x7fffffff
async def sync_contacts(self) -> None:
response = await self.client(GetContactsRequest(hash=self._hash_contacts()))
existing_contacts = await self.get_contacts()
contact_hash = self._hash_contacts(self.saved_contacts, existing_contacts)
response = await self.client(GetContactsRequest(hash=contact_hash))
if isinstance(response, ContactsNotModified):
return
self.log.debug(f"Updating contacts of {self.name}...")
self.contacts = []
self.saved_contacts = response.saved_count
if self.saved_contacts != response.saved_count:
self.saved_contacts = response.saved_count
await self.save()
await self.set_contacts(user.id for user in response.users)
for user in response.users:
puppet = pu.Puppet.get(user.id)
puppet = await pu.Puppet.get_by_tgid(user.id)
await puppet.update_info(self, user)
self.contacts.append(puppet)
await self.save(contacts=True)
# endregion
# region Class instance lookup
@classmethod
def get_by_mxid(cls, mxid: UserID, create: bool = True, check_db: bool = True
) -> Optional['User']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
def _add_to_cache(self) -> None:
self.by_mxid[self.mxid] = self
if self.tgid:
self.by_tgid[self.tgid] = self
@classmethod
async def get_and_start_by_mxid(cls, mxid: UserID, even_if_no_session: bool = False) -> User:
user = await cls.get_by_mxid(mxid, create=True)
await user.ensure_started(even_if_no_session=even_if_no_session)
return user
@classmethod
async def all_with_tgid(cls) -> AsyncGenerator[User, None]:
users = await super().all_with_tgid()
user: cls
for user in users:
try:
yield cls.by_mxid[user.mxid]
except KeyError:
user._add_to_cache()
yield user
@classmethod
@async_getter_lock
async def get_by_mxid(
cls, mxid: UserID, *, check_db: bool = True, create: bool = True
) -> User | None:
if not mxid or pu.Puppet.get_id_from_mxid(mxid) or mxid == cls.az.bot_mxid:
return None
try:
return cls.by_mxid[mxid]
except KeyError:
pass
if check_db:
user = DBUser.get_by_mxid(mxid)
if user:
user = cls.from_db(user)
return user
if not check_db:
return None
user = cast(cls, await super().get_by_mxid(mxid))
if user is not None:
user._add_to_cache()
return user
if create:
cls.log.debug(f"Creating user instance for {mxid}")
user = cls(mxid)
user.db_instance.insert()
await user.insert()
user._add_to_cache()
return user
return None
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
@async_getter_lock
async def get_by_tgid(cls, tgid: TelegramID) -> User | None:
try:
return cls.by_tgid[tgid]
except KeyError:
pass
user = DBUser.get_by_tgid(tgid)
if user:
user = cls.from_db(user)
user = cast(cls, await super().get_by_tgid(tgid))
if user is not None:
user._add_to_cache()
return user
return None
@classmethod
def find_by_username(cls, username: str) -> Optional['User']:
async def find_by_username(cls, username: str) -> User | None:
if not username:
return None
username = username.lower()
for _, user in cls.by_tgid.items():
if user.username and user.username.lower() == username:
if user.tg_username and user.tg_username.lower() == username:
return user
puppet = DBUser.get_by_username(username)
if puppet:
return cls.from_db(puppet)
user = cast(cls, await super().find_by_username(username))
if user:
try:
return cls.by_mxid[user.mxid]
except KeyError:
user._add_to_cache()
return user
return None
# endregion
def init(context: 'Context') -> Iterable[Awaitable['User']]:
global config
config = context.config
User.bridge = context.bridge
return (User.from_db(db_user).try_ensure_started()
for db_user in DBUser.all_with_tgid())
+2
View File
@@ -2,3 +2,5 @@ from .file_transfer import transfer_file_to_matrix, convert_image
from .parallel_file_transfer import parallel_transfer_to_telegram
from .recursive_dict import recursive_del, recursive_set, recursive_get
from .color_log import ColorFormatter
from .send_lock import PortalSendLock
from .deduplication import PortalDedup
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
@@ -24,11 +24,10 @@ from telethon.tl.types import (MessageMediaContact, MessageMediaDocument, Messag
from mautrix.types import EventID
from ..context import Context
from ..types import TelegramID
if TYPE_CHECKING:
from .base import BasePortal
from ..portal import Portal
DedupMXID = Tuple[EventID, TelegramID]
@@ -40,9 +39,9 @@ class PortalDedup:
_dedup: Deque[str]
_dedup_mxid: Dict[str, DedupMXID]
_dedup_action: Deque[str]
_portal: 'BasePortal'
_portal: 'Portal'
def __init__(self, portal: 'BasePortal') -> None:
def __init__(self, portal: 'Portal') -> None:
self._dedup = deque()
self._dedup_mxid = {}
self._dedup_action = deque()
@@ -125,9 +124,3 @@ class PortalDedup:
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"]
+9 -8
View File
@@ -21,7 +21,8 @@ import asyncio
import tempfile
import magic
from sqlalchemy.exc import IntegrityError, InvalidRequestError
from asyncpg import UniqueViolationError
from sqlite3 import IntegrityError
from telethon.tl.types import (Document, InputFileLocation, InputDocumentFileLocation,
TypePhotoSize, PhotoSize, PhotoCachedSize, InputPhotoFileLocation,
@@ -123,7 +124,7 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
if custom_data:
loc_id += "-mau_custom_thumbnail"
db_file = DBTelegramFile.get(loc_id)
db_file = await DBTelegramFile.get(loc_id)
if db_file:
return db_file
@@ -154,8 +155,8 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
was_converted=False, timestamp=int(time.time()), size=len(file),
width=width, height=height, decryption_info=decryption_info)
try:
db_file.insert()
except (IntegrityError, InvalidRequestError) as e:
await db_file.insert()
except (UniqueViolationError, IntegrityError) as e:
log.exception(f"{e.__class__.__name__} while saving transferred file thumbnail data. "
"This was probably caused by two simultaneous transfers of the same file, "
"and might (but probably won't) cause problems with thumbnails or something.")
@@ -176,7 +177,7 @@ async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentA
if not location_id:
return None
db_file = DBTelegramFile.get(location_id)
db_file = await DBTelegramFile.get(location_id)
if db_file:
return db_file
@@ -197,7 +198,7 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
tgs_convert: Optional[dict], filename: Optional[str],
encrypt: bool, parallel_id: Optional[int]
) -> Optional[DBTelegramFile]:
db_file = DBTelegramFile.get(loc_id)
db_file = await DBTelegramFile.get(loc_id)
if db_file:
return db_file
@@ -263,8 +264,8 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
width=converted_anim.width, height=converted_anim.height)
try:
db_file.insert()
except (IntegrityError, InvalidRequestError) as e:
await db_file.insert()
except (UniqueViolationError, IntegrityError) as e:
log.exception(f"{e.__class__.__name__} while saving transferred file data. "
"This was probably caused by two simultaneous transfers of the same file, "
"and should not cause any problems.")
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 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
+2 -2
View File
@@ -52,7 +52,7 @@ class AuthAPI(abc.ABC):
raise NotImplementedError()
async def post_matrix_token(self, user: User, token: str) -> web.Response:
puppet = Puppet.get(user.tgid)
puppet = await Puppet.get_by_tgid(user.tgid)
if puppet.is_real_user:
return self.get_mx_login_response(state="already-logged-in", status=409,
error="You have already logged in with your Matrix "
@@ -116,7 +116,7 @@ class AuthAPI(abc.ABC):
error="Internal server error while requesting code.")
async def postprocess_login(self, user: User, user_info) -> None:
existing_user = User.get_by_tgid(user_info.id)
existing_user = await User.get_by_tgid(user_info.id)
if existing_user and existing_user != user:
await existing_user.log_out()
asyncio.ensure_future(user.post_login(user_info, first_login=True), loop=self.loop)
+24 -27
View File
@@ -21,10 +21,7 @@ import json
from aiohttp import web
from telethon.utils import get_peer_id, resolve_id
from telethon.tl.types import ChatForbidden, ChannelForbidden, TypeChat, InputUserSelf
from telethon.tl.functions.users import GetUsersRequest
from telethon.errors import (UserDeactivatedError, UserDeactivatedBanError, SessionRevokedError,
UnauthorizedError)
from telethon.tl.types import ChatForbidden, ChannelForbidden, TypeChat
from mautrix.appservice import AppService
from mautrix.errors import MatrixRequestError, IntentError
@@ -37,23 +34,23 @@ from ...commands.portal.util import user_has_power_level, get_initial_state
from ..common import AuthAPI
if TYPE_CHECKING:
from ...context import Context
from ...__main__ import TelegramBridge
class ProvisioningAPI(AuthAPI):
log: logging.Logger = logging.getLogger("mau.web.provisioning")
secret: str
az: AppService
context: 'Context'
bridge: 'TelegramBridge'
app: web.Application
def __init__(self, context: "Context") -> None:
super().__init__(context.loop)
self.secret = context.config["appservice.provisioning.shared_secret"]
self.az = context.az
self.context = context
def __init__(self, bridge: "TelegramBridge") -> None:
super().__init__(bridge.loop)
self.secret = bridge.config["appservice.provisioning.shared_secret"]
self.az = bridge.az
self.bridge = bridge
self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware])
self.app = web.Application(loop=bridge.loop, middlewares=[self.error_middleware])
portal_prefix = "/portal/{mxid:![^/]+}"
self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid)
@@ -81,7 +78,7 @@ class ProvisioningAPI(AuthAPI):
return err
mxid = request.match_info["mxid"]
portal = Portal.get_by_mxid(mxid)
portal = await Portal.get_by_mxid(mxid)
if not portal:
return self.get_error_response(404, "portal_not_found",
"Portal with given Matrix ID not found.")
@@ -97,7 +94,7 @@ class ProvisioningAPI(AuthAPI):
except ValueError:
return self.get_error_response(400, "tgid_invalid",
"Given chat ID is not valid.")
portal = Portal.get_by_tgid(tgid)
portal = await Portal.get_by_tgid(tgid)
if not portal:
return self.get_error_response(404, "portal_not_found",
"Portal to given Telegram chat not found.")
@@ -122,7 +119,7 @@ class ProvisioningAPI(AuthAPI):
return err
room_id = request.match_info["mxid"]
if Portal.get_by_mxid(room_id):
if await Portal.get_by_mxid(room_id):
return self.get_error_response(409, "room_already_bridged",
"Room is already bridged to another Telegram chat.")
@@ -145,12 +142,12 @@ class ProvisioningAPI(AuthAPI):
"You do not have the permissions to bridge that room.")
is_logged_in = user is not None and await user.is_logged_in()
acting_user = user if is_logged_in else self.context.bot
acting_user = user if is_logged_in else self.bridge.bot
if not acting_user:
return self.get_login_response(status=403, errcode="not_logged_in",
error="You are not logged in and there is no relay bot.")
portal = Portal.get_by_tgid(tgid, peer_type=peer_type)
portal = await Portal.get_by_tgid(tgid, peer_type=peer_type)
if portal.mxid == room_id:
return self.get_error_response(200, "bridge_exists",
"Telegram chat is already bridged to that Matrix room.")
@@ -204,7 +201,7 @@ class ProvisioningAPI(AuthAPI):
return self.get_error_response(400, "json_invalid", "Invalid JSON.")
room_id = request.match_info["mxid"]
if Portal.get_by_mxid(room_id):
if await Portal.get_by_mxid(room_id):
return self.get_error_response(409, "room_already_bridged",
"Room is already bridged to another Telegram chat.")
@@ -245,7 +242,7 @@ class ProvisioningAPI(AuthAPI):
}[type]
portal = Portal(tgid=TelegramID(0), mxid=room_id, title=title, about=about, peer_type=type,
encrypted=encrypted)
encrypted=encrypted, tg_receiver=TelegramID(0))
try:
await portal.create_telegram_chat(user, supergroup=supergroup)
except ValueError as e:
@@ -261,7 +258,7 @@ class ProvisioningAPI(AuthAPI):
if err is not None:
return err
portal = Portal.get_by_mxid(request.match_info["mxid"])
portal = await Portal.get_by_mxid(request.match_info["mxid"])
if not portal or not portal.tgid:
return self.get_error_response(404, "portal_not_found",
"Room is not a portal.")
@@ -302,10 +299,10 @@ class ProvisioningAPI(AuthAPI):
await user.update_info(me)
user_data = {
"id": user.tgid,
"username": user.username,
"username": user.tg_username,
"first_name": me.first_name,
"last_name": me.last_name,
"phone": me.phone,
"phone": user.tg_phone,
"is_bot": user.is_bot,
}
return web.json_response({
@@ -328,7 +325,7 @@ class ProvisioningAPI(AuthAPI):
return web.json_response([{
"id": get_peer_id(chat.peer),
"title": chat.title,
} for chat in user.portals.values() if chat.tgid])
} for chat in (await user.get_cached_portals()).values() if chat.tgid])
async def send_bot_token(self, request: web.Request) -> web.Response:
data, user, err = await self.get_user_request_info(request)
@@ -365,8 +362,8 @@ class ProvisioningAPI(AuthAPI):
async def bridge_info(self, request: web.Request) -> web.Response:
return web.json_response({
"relaybot_username": (self.context.bot.username
if self.context.bot is not None else None),
"relaybot_username": (self.bridge.bot.tg_username
if self.bridge.bot is not None else None),
}, status=200)
@staticmethod
@@ -441,14 +438,14 @@ class ProvisioningAPI(AuthAPI):
return None, self.get_login_response(error="User ID not given.",
errcode="mxid_empty", status=400)
user = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True)
user = await User.get_and_start_by_mxid(mxid, even_if_no_session=True)
if require_puppeting and not user.puppet_whitelisted:
return user, self.get_login_response(error="You are not whitelisted.",
errcode="mxid_not_whitelisted", status=403)
if expect_logged_in is not None:
logged_in = await user.is_logged_in()
if not expect_logged_in and logged_in:
return user, self.get_login_response(username=user.username, phone=user.phone,
return user, self.get_login_response(username=user.tg_username, phone=user.tg_phone,
status=409,
error="You are already logged in.",
errcode="already_logged_in")
+4 -4
View File
@@ -77,7 +77,7 @@ class PublicBridgeWebsite(AuthAPI):
mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/login")
if not mxid:
return self.get_login_response(status=401, state="invalid-token")
user = User.get_by_mxid(mxid, create=False) if mxid else None
user = await User.get_by_mxid(mxid, create=False) if mxid else None
if not user:
return self.get_login_response(mxid=mxid, state=state)
@@ -95,7 +95,7 @@ class PublicBridgeWebsite(AuthAPI):
endpoint="/matrix-login")
if not mxid:
return self.get_mx_login_response(status=401, state="invalid-token")
user = User.get_by_mxid(mxid, create=False) if mxid else None
user = await User.get_by_mxid(mxid, create=False) if mxid else None
if not user:
return self.get_mx_login_response(mxid=mxid)
@@ -107,7 +107,7 @@ class PublicBridgeWebsite(AuthAPI):
return self.get_mx_login_response(mxid=user.mxid, status=403,
error="You are not logged in to Telegram.")
puppet = Puppet.get(user.tgid)
puppet = await Puppet.get_by_tgid(user.tgid)
if puppet.is_real_user:
return self.get_mx_login_response(state="already-logged-in", status=409)
@@ -136,7 +136,7 @@ class PublicBridgeWebsite(AuthAPI):
data = await request.post()
user = await User.get_by_mxid(mxid).ensure_started()
user = await User.get_and_start_by_mxid(mxid)
if not user.puppet_whitelisted:
return self.get_mx_login_response(mxid=user.mxid, error="You are not whitelisted.",
status=403)
-7
View File
@@ -17,13 +17,6 @@ moviepy>=1,<2
#/metrics
prometheus_client>=0.6,<0.13
#/postgres
psycopg2-binary>=2,<3
asyncpg>=0.20,<0.26
#/sqlite
aiosqlite>=0.17,<0.18
#/e2be
python-olm>=3,<4
pycryptodome>=3,<4
+5 -7
View File
@@ -1,13 +1,11 @@
SQLAlchemy>=1.2,<1.4
alembic>=1,<2
ruamel.yaml>=0.15.35,<0.18
python-magic>=0.4,<0.5
commonmark>=0.8,<0.10
aiohttp>=3,<4
yarl>=1,<2
mautrix>=0.13.3,<0.14
telethon>=1.24,<1.25
telethon-session-sqlalchemy>=0.2.14,<0.3
# Temporarily always depend on aiosqlite to prevent breaking old installs
# Will be removed in v0.12 (after which you need to choose the [sqlite] optional dependency)
mautrix==0.14.0rc1
#telethon>=1.24,<1.25
# Fork to make session storage async
tulir-telethon==1.25.0a1
asyncpg>=0.20,<0.26
aiosqlite>=0.17,<0.18
+1 -1
View File
@@ -61,9 +61,9 @@ setuptools.setup(
"Framework :: AsyncIO",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
],
package_data={"mautrix_telegram": [
"web/public/*.mako", "web/public/*.png", "web/public/*.css",