Switch from SQLAlchemy to asyncpg/aiosqlite
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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} <choice number></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
@@ -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
@@ -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,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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user