Compare commits

...

20 Commits

Author SHA1 Message Date
Tulir Asokan 10de186598 Bump version to 0.10.1 2021-08-19 14:57:33 +03:00
Tulir Asokan 64107fab17 Add video flags for animated stickers 2021-08-19 14:43:57 +03:00
Tulir Asokan 52bfbddcca Add flag to invite events that will be auto-accepted 2021-08-18 20:48:11 +03:00
Tulir Asokan 5d9cc490d7 Fix public_portals setting not being respected on portal creation 2021-08-17 00:17:21 +03:00
Tulir Asokan 13cac8db9a Reset TelegramClient completely on AuthKeyDuplicatedError 2021-08-16 20:54:02 +03:00
Tulir Asokan 3ab5e4d8cc Move remaining manhole stuff to mautrix-python 2021-08-14 16:22:31 +03:00
Tulir Asokan 7e728dd5af Fix incorrect states being sent when stopping client 2021-08-13 16:48:19 +03:00
Tulir Asokan 597d2e3282 Bump asyncpg version 2021-08-13 13:00:07 +03:00
Tulir Asokan 57611a3f30 Catch AuthKeyDuplicatedError in start() 2021-08-13 12:59:24 +03:00
Tulir Asokan ec64c83cb0 Merge pull request #652 from mautrix/new-bridge-state
Upgrade mautrix to 0.10.2 and use new BridgeStateEvents
2021-08-10 19:53:59 +03:00
Tulir Asokan ecdaaea3b9 Don't send connected state when sync is in progress 2021-08-10 18:14:32 +03:00
Tulir Asokan bda41417aa Update repo path 2021-08-06 17:46:24 +03:00
Sumner Evans 5a76b5bcdc Upgrade mautrix to 0.10.2 and use new BridgeStateEvents 2021-08-04 16:49:56 -06:00
Tulir Asokan 4edd8eaa7b Ignore everything after ; in Matrix location events 2021-08-02 12:52:21 +03:00
Tulir Asokan 742a925040 Change some things 2021-08-02 12:52:05 +03:00
Tulir Asokan c02f67e0d1 Send warning if bridge bot doesn't have redaction permissions 2021-06-23 18:44:32 +03:00
Tulir Asokan 31650aac96 Update Docker image to Alpine 3.14 2021-06-23 17:50:48 +03:00
Tulir Asokan 730f6bab6f Update to Telethon 1.22 2021-06-22 19:42:36 +03:00
Tulir Asokan f923552f86 Update mautrix-python (ref #623) 2021-06-22 19:25:27 +03:00
Tulir Asokan eca1032d16 Ignore typing notifications from double puppeted users. Fixes #631 2021-06-19 22:48:43 +03:00
25 changed files with 155 additions and 224 deletions
+4 -2
View File
@@ -1,4 +1,4 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.13 FROM dock.mau.dev/tulir/lottieconverter:alpine-3.14
ARG TARGETARCH=amd64 ARG TARGETARCH=amd64
@@ -14,6 +14,7 @@ RUN apk add --no-cache \
py3-psycopg2 \ py3-psycopg2 \
py3-ruamel.yaml \ py3-ruamel.yaml \
py3-commonmark \ py3-commonmark \
py3-prometheus-client \
# Indirect dependencies # Indirect dependencies
py3-idna \ py3-idna \
#moviepy #moviepy
@@ -25,6 +26,7 @@ RUN apk add --no-cache \
#py3-telethon \ (outdated) #py3-telethon \ (outdated)
# Optional for socks proxies # Optional for socks proxies
py3-pysocks \ py3-pysocks \
py3-pyaes \
# cryptg # cryptg
py3-cffi \ py3-cffi \
py3-qrcode \ py3-qrcode \
@@ -35,7 +37,7 @@ RUN apk add --no-cache \
su-exec \ su-exec \
netcat-openbsd \ netcat-openbsd \
# encryption # encryption
olm-dev \ py3-olm \
py3-pycryptodome \ py3-pycryptodome \
py3-unpaddedbase64 \ py3-unpaddedbase64 \
py3-future \ py3-future \
+5 -6
View File
@@ -1,9 +1,8 @@
# mautrix-telegram # mautrix-telegram
![Languages](https://img.shields.io/github/languages/top/tulir/mautrix-telegram.svg) ![Languages](https://img.shields.io/github/languages/top/mautrix/telegram.svg)
[![License](https://img.shields.io/github/license/tulir/mautrix-telegram.svg)](LICENSE) [![License](https://img.shields.io/github/license/mautrix/telegram.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/tulir/mautrix-telegram/all.svg)](https://github.com/tulir/mautrix-telegram/releases) [![Release](https://img.shields.io/github/release/mautrix/telegram/all.svg)](https://github.com/mautrix/telegram/releases)
[![GitLab CI](https://mau.dev/tulir/mautrix-telegram/badges/master/pipeline.svg)](https://mau.dev/tulir/mautrix-telegram/container_registry) [![GitLab CI](https://mau.dev/mautrix/telegram/badges/master/pipeline.svg)](https://mau.dev/mautrix/telegram/container_registry)
[![Maintainability](https://img.shields.io/codeclimate/maintainability/tulir/mautrix-telegram.svg)](https://codeclimate.com/github/tulir/mautrix-telegram)
A Matrix-Telegram hybrid puppeting/relaybot bridge. A Matrix-Telegram hybrid puppeting/relaybot bridge.
@@ -22,7 +21,7 @@ Some quick links:
[Relaybot setup](https://docs.mau.fi/bridges/python/telegram/relay-bot.html) [Relaybot setup](https://docs.mau.fi/bridges/python/telegram/relay-bot.html)
### Features & Roadmap ### Features & Roadmap
[ROADMAP.md](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md) [ROADMAP.md](https://github.com/mautrix/telegram/blob/master/ROADMAP.md)
contains a general overview of what is supported by the bridge. contains a general overview of what is supported by the bridge.
## Discussion ## Discussion
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.10.0" __version__ = "0.10.1"
__author__ = "Tulir Asokan <tulir@maunium.net>" __author__ = "Tulir Asokan <tulir@maunium.net>"
+18 -8
View File
@@ -13,8 +13,9 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional from typing import Dict, Any
from telethon import __version__ as __telethon_version__
from alchemysession import AlchemySessionContainer from alchemysession import AlchemySessionContainer
from mautrix.types import UserID, RoomID from mautrix.types import UserID, RoomID
@@ -23,7 +24,6 @@ from mautrix.util.db import Base
from .web.provisioning import ProvisioningAPI from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite from .web.public import PublicBridgeWebsite
from .commands.manhole import ManholeState
from .abstract_user import init as init_abstract_user from .abstract_user import init as init_abstract_user
from .bot import Bot, init as init_bot from .bot import Bot, init as init_bot
from .config import Config from .config import Config
@@ -47,7 +47,7 @@ class TelegramBridge(Bridge):
name = "mautrix-telegram" name = "mautrix-telegram"
command = "python -m mautrix-telegram" command = "python -m mautrix-telegram"
description = "A Matrix-Telegram puppeting bridge." description = "A Matrix-Telegram puppeting bridge."
repo_url = "https://github.com/tulir/mautrix-telegram" repo_url = "https://github.com/mautrix/telegram"
real_user_content_key = "net.maunium.telegram.puppet" real_user_content_key = "net.maunium.telegram.puppet"
version = version version = version
markdown_version = linkified_version markdown_version = linkified_version
@@ -57,7 +57,6 @@ class TelegramBridge(Bridge):
config: Config config: Config
session_container: AlchemySessionContainer session_container: AlchemySessionContainer
bot: Bot bot: Bot
manhole: Optional[ManholeState]
def prepare_db(self) -> None: def prepare_db(self) -> None:
super().prepare_db() super().prepare_db()
@@ -83,7 +82,6 @@ class TelegramBridge(Bridge):
context = Context(self.az, self.config, self.loop, self.session_container, self, self.bot) context = Context(self.az, self.config, self.loop, self.session_container, self, self.bot)
self._prepare_website(context) self._prepare_website(context)
self.matrix = context.mx = MatrixHandler(context) self.matrix = context.mx = MatrixHandler(context)
self.manhole = None
init_abstract_user(context) init_abstract_user(context)
init_formatter(context) init_formatter(context)
@@ -107,9 +105,6 @@ class TelegramBridge(Bridge):
for puppet in Puppet.by_custom_mxid.values(): for puppet in Puppet.by_custom_mxid.values():
puppet.stop() puppet.stop()
self.shutdown_actions = (user.stop() for user in User.by_tgid.values()) self.shutdown_actions = (user.stop() for user in User.by_tgid.values())
if self.manhole:
self.manhole.close()
self.manhole = None
async def get_user(self, user_id: UserID, create: bool = True) -> User: async def get_user(self, user_id: UserID, create: bool = True) -> User:
user = User.get_by_mxid(user_id, create=create) user = User.get_by_mxid(user_id, create=create)
@@ -129,5 +124,20 @@ class TelegramBridge(Bridge):
def is_bridge_ghost(self, user_id: UserID) -> bool: def is_bridge_ghost(self, user_id: UserID) -> bool:
return bool(Puppet.get_id_from_mxid(user_id)) return bool(Puppet.get_id_from_mxid(user_id))
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]:
return {
**await super().manhole_global_namespace(user_id),
"User": User,
"Portal": Portal,
"Puppet": Puppet,
}
@property
def manhole_banner_program_version(self) -> str:
return f"{super().manhole_banner_program_version} and Telethon {__telethon_version__}"
TelegramBridge().run() TelegramBridge().run()
+4 -4
View File
@@ -129,9 +129,9 @@ class AbstractUser(ABC):
def _init_client(self) -> None: def _init_client(self) -> None:
self.log.debug(f"Initializing client for {self.name}") self.log.debug(f"Initializing client for {self.name}")
self.session = self.session_container.new_session(self.name) session = self.session_container.new_session(self.name)
if config["telegram.server.enabled"]: if config["telegram.server.enabled"]:
self.session.set_dc(config["telegram.server.dc"], session.set_dc(config["telegram.server.dc"],
config["telegram.server.ip"], config["telegram.server.ip"],
config["telegram.server.port"]) config["telegram.server.port"])
@@ -145,10 +145,10 @@ class AbstractUser(ABC):
appversion = config["telegram.device_info.app_version"] appversion = config["telegram.device_info.app_version"]
connection, proxy = self._proxy_settings connection, proxy = self._proxy_settings
assert isinstance(self.session, Session) assert isinstance(session, Session)
self.client = MautrixTelegramClient( self.client = MautrixTelegramClient(
session=self.session, session=session,
api_id=config["telegram.api_id"], api_id=config["telegram.api_id"],
api_hash=config["telegram.api_hash"], api_hash=config["telegram.api_hash"],
+1 -1
View File
@@ -195,7 +195,7 @@ class Bot(AbstractUser):
return await reply("That user seems to be logged in. " return await reply("That user seems to be logged in. "
f"Just invite [{displayname}](tg://user?id={user.tgid})") f"Just invite [{displayname}](tg://user?id={user.tgid})")
else: else:
await portal.main_intent.invite_user(portal.mxid, user.mxid) await portal.invite_to_matrix(user.mxid)
return await reply(f"Invited `{user.mxid}` to the portal.") return await reply(f"Invited `{user.mxid}` to the portal.")
@staticmethod @staticmethod
+1 -1
View File
@@ -1,7 +1,7 @@
from .handler import (command_handler, CommandHandler, CommandProcessor, CommandEvent, from .handler import (command_handler, CommandHandler, CommandProcessor, CommandEvent,
SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT, SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT,
SECTION_MISC, SECTION_ADMIN) SECTION_MISC, SECTION_ADMIN)
from . import portal, telegram, matrix_auth, manhole from . import portal, telegram, matrix_auth
__all__ = ["command_handler", "CommandHandler", "CommandProcessor", "CommandEvent", __all__ = ["command_handler", "CommandHandler", "CommandProcessor", "CommandEvent",
"SECTION_AUTH", "SECTION_MISC", "SECTION_ADMIN", "SECTION_CREATING_PORTALS", "SECTION_AUTH", "SECTION_MISC", "SECTION_ADMIN", "SECTION_CREATING_PORTALS",
-128
View File
@@ -1,128 +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 Set, Callable
import asyncio
import sys
import os
from attr import dataclass
from telethon import __version__ as __telethon_version__
from mautrix import __version__ as __mautrix_version__
from mautrix.types import UserID
from mautrix.errors import MatrixConnectionError
from mautrix.util.manhole import start_manhole
from .. import __version__
from . import command_handler, CommandEvent, SECTION_ADMIN
@dataclass
class ManholeState:
server: asyncio.AbstractServer
opened_by: UserID
close: Callable[[], None]
whitelist: Set[int]
@command_handler(needs_auth=False, needs_admin=True, help_section=SECTION_ADMIN,
help_text="Open a manhole into the bridge.", help_args="<_uid..._>")
async def open_manhole(evt: CommandEvent) -> None:
if not evt.config["manhole.enabled"]:
await evt.reply("The manhole has been disabled in the config.")
return
elif len(evt.args) == 0:
await evt.reply("**Usage:** `$cmdprefix+sp open-manhole <uid...>`")
return
whitelist = set()
whitelist_whitelist = evt.config["manhole.whitelist"]
for arg in evt.args:
try:
uid = int(arg)
except ValueError:
await evt.reply(f"{arg} is not an integer.")
return
if whitelist_whitelist and uid not in whitelist_whitelist:
await evt.reply(f"{uid} is not in the list of allowed UIDs.")
return
whitelist.add(uid)
if evt.bridge.manhole:
added = [uid for uid in whitelist
if uid not in evt.bridge.manhole.whitelist]
evt.bridge.manhole.whitelist |= set(added)
if len(added) == 0:
await evt.reply(f"There's an existing manhole opened by {evt.bridge.manhole.opened_by}"
" and all the given UIDs are already whitelisted.")
else:
added_str = (f"{', '.join(str(uid) for uid in added[:-1])} and {added[-1]}"
if len(added) > 1 else added[0])
await evt.reply(f"There's an existing manhole opened by {evt.bridge.manhole.opened_by}"
f". Added {added_str} to the whitelist.")
evt.log.info(f"{evt.sender.mxid} added {added_str} to the manhole whitelist.")
return
from ..portal import Portal
from ..puppet import Puppet
from ..user import User
namespace = {
"bridge": evt.bridge,
"User": User,
"Portal": Portal,
"Puppet": Puppet,
}
banner = (f"Python {sys.version} on {sys.platform}\n"
f"mautrix-telegram {__version__} with mautrix-python {__mautrix_version__} "
f"and Telethon {__telethon_version__}\n\nManhole opened by {evt.sender.mxid}\n")
path = evt.config["manhole.path"]
wl_list = list(whitelist)
whitelist_str = (f"{', '.join(str(uid) for uid in wl_list[:-1])} and {wl_list[-1]}"
if len(wl_list) > 1 else wl_list[0])
evt.log.info(f"{evt.sender.mxid} opened a manhole with {whitelist_str} whitelisted.")
server, close = await start_manhole(path=path, banner=banner, namespace=namespace,
loop=evt.loop, whitelist=whitelist)
evt.bridge.manhole = ManholeState(server=server, opened_by=evt.sender.mxid, close=close,
whitelist=whitelist)
plrl = "s" if len(whitelist) != 1 else ""
await evt.reply(f"Opened manhole at unix://{path} with UID{plrl} {whitelist_str} whitelisted")
await server.wait_closed()
evt.bridge.manhole = None
try:
os.unlink(path)
except FileNotFoundError:
pass
evt.log.info(f"{evt.sender.mxid}'s manhole was closed.")
try:
await evt.reply("Your manhole was closed.")
except (AttributeError, MatrixConnectionError) as e:
evt.log.warning(f"Failed to send manhole close notification: {e}")
@command_handler(needs_auth=False, needs_admin=True, help_section=SECTION_ADMIN,
help_text="Close an open manhole.")
async def close_manhole(evt: CommandEvent) -> None:
if not evt.bridge.manhole:
await evt.reply("There is no open manhole.")
return
opened_by = evt.bridge.manhole.opened_by
evt.bridge.manhole.close()
evt.bridge.manhole = None
if opened_by != evt.sender.mxid:
await evt.reply(f"Closed manhole opened by {opened_by}")
+3 -1
View File
@@ -23,7 +23,7 @@ from mautrix.types import EventID, RoomID
from ...types import TelegramID from ...types import TelegramID
from ... import portal as po from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state from .util import user_has_power_level, get_initial_state, warn_missing_power
@command_handler(needs_auth=False, needs_puppeting=False, @command_handler(needs_auth=False, needs_puppeting=False,
@@ -191,4 +191,6 @@ async def _locked_confirm_bridge(evt: CommandEvent, portal: 'po.Portal', room_id
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct=False, levels=levels), asyncio.ensure_future(portal.update_matrix_room(user, entity, direct=False, levels=levels),
loop=evt.loop) loop=evt.loop)
await warn_missing_power(levels, evt)
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
@@ -18,7 +18,7 @@ from mautrix.types import EventID
from ... import portal as po from ... import portal as po
from ...types import TelegramID from ...types import TelegramID
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state from .util import user_has_power_level, get_initial_state, warn_missing_power
@command_handler(help_section=SECTION_CREATING_PORTALS, @command_handler(help_section=SECTION_CREATING_PORTALS,
@@ -58,6 +58,9 @@ async def create(evt: CommandEvent) -> EventID:
await evt.reply(f"Failed to add the following users to the chat:\n\n{error_list}\n\n" await evt.reply(f"Failed to add the following users to the chat:\n\n{error_list}\n\n"
"You can try `$cmdprefix+sp search -r <username>` to help the bridge find " "You can try `$cmdprefix+sp search -r <username>` to help the bridge find "
"those users.") "those users.")
await warn_missing_power(levels, evt)
try: try:
await portal.create_telegram_chat(evt.sender, invites=invites, supergroup=supergroup) await portal.create_telegram_chat(evt.sender, invites=invites, supergroup=supergroup)
except ValueError as e: except ValueError as e:
+12 -2
View File
@@ -18,14 +18,16 @@ from typing import Tuple, Optional
from mautrix.errors import MatrixRequestError from mautrix.errors import MatrixRequestError
from mautrix.appservice import IntentAPI from mautrix.appservice import IntentAPI
from mautrix.types import RoomID, EventType, PowerLevelStateEventContent from mautrix.types import RoomID, EventType, PowerLevelStateEventContent
from .. import CommandEvent
from ... import user as u from ... import user as u
OptStr = Optional[str] OptStr = Optional[str]
async def get_initial_state(intent: IntentAPI, room_id: RoomID async def get_initial_state(
) -> Tuple[OptStr, OptStr, Optional[PowerLevelStateEventContent], bool]: intent: IntentAPI, room_id: RoomID
) -> Tuple[OptStr, OptStr, Optional[PowerLevelStateEventContent], bool]:
state = await intent.get_state(room_id) state = await intent.get_state(room_id)
title: OptStr = None title: OptStr = None
about: OptStr = None about: OptStr = None
@@ -49,6 +51,14 @@ async def get_initial_state(intent: IntentAPI, room_id: RoomID
return title, about, levels, encrypted return title, about, levels, encrypted
async def warn_missing_power(levels: PowerLevelStateEventContent, evt: CommandEvent) -> None:
if levels.get_user_level(evt.az.bot_mxid) < levels.redact:
await evt.reply("Warning: The bot does not have privileges to redact messages on Matrix. "
"Message deletions from Telegram will not be bridged unless you give "
"redaction permissions to "
f"[{evt.az.bot_mxid}](https://matrix.to/#/{evt.az.bot_mxid})")
async def user_has_power_level(room_id: RoomID, intent: IntentAPI, sender: u.User, async def user_has_power_level(room_id: RoomID, intent: IntentAPI, sender: u.User,
event: str) -> bool: event: str) -> bool:
if sender.is_admin: if sender.is_admin:
-4
View File
@@ -75,10 +75,6 @@ class Config(BaseBridgeConfig):
copy("metrics.enabled") copy("metrics.enabled")
copy("metrics.listen_port") copy("metrics.listen_port")
copy("manhole.enabled")
copy("manhole.path")
copy("manhole.whitelist")
copy("bridge.username_template") copy("bridge.username_template")
copy("bridge.alias_template") copy("bridge.alias_template")
copy("bridge.displayname_template") copy("bridge.displayname_template")
+2 -2
View File
@@ -18,7 +18,7 @@ def run(cmd):
if os.path.exists(".git") and shutil.which("git"): if os.path.exists(".git") and shutil.which("git"):
try: try:
git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii") git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii")
git_revision_url = f"https://github.com/tulir/mautrix-telegram/commit/{git_revision}" git_revision_url = f"https://github.com/mautrix/telegram/commit/{git_revision}"
git_revision = git_revision[:8] git_revision = git_revision[:8]
except (subprocess.SubprocessError, OSError): except (subprocess.SubprocessError, OSError):
git_revision = "unknown" git_revision = "unknown"
@@ -33,7 +33,7 @@ else:
git_revision_url = None git_revision_url = None
git_tag = None git_tag = None
git_tag_url = (f"https://github.com/tulir/mautrix-telegram/releases/tag/{git_tag}" git_tag_url = (f"https://github.com/mautrix/telegram/releases/tag/{git_tag}"
if git_tag else None) if git_tag else None)
if git_tag and __version__ == git_tag[1:].replace("-", ""): if git_tag and __version__ == git_tag[1:].replace("-", ""):
+1 -1
View File
@@ -84,7 +84,7 @@ class MatrixHandler(BaseMatrixHandler):
portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user") portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
if portal.mxid: if portal.mxid:
try: try:
await intent.invite_user(portal.mxid, inviter.mxid) await portal.invite_to_matrix(inviter.mxid)
await intent.send_notice( await intent.send_notice(
room_id, text=f"You already have a private chat with me: {portal.mxid}", room_id, text=f"You already have a private chat with me: {portal.mxid}",
html=("You already have a private chat with me: " html=("You already have a private chat with me: "
+1 -1
View File
@@ -332,7 +332,7 @@ class PortalMatrix(BasePortal, ABC):
content: LocationMessageEventContent, reply_to: TelegramID content: LocationMessageEventContent, reply_to: TelegramID
) -> None: ) -> None:
try: try:
lat, long = content.geo_uri[len("geo:"):].split(",") lat, long = content.geo_uri[len("geo:"):].split(";")[0].split(",")
lat, long = float(lat), float(long) lat, long = float(lat), float(long)
except (KeyError, ValueError): except (KeyError, ValueError):
self.log.exception("Failed to parse location") self.log.exception("Failed to parse location")
+29 -19
View File
@@ -186,12 +186,27 @@ class PortalMetadata(BasePortal, ABC):
# endregion # endregion
# region Telegram -> Matrix # 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: async def invite_to_matrix(self, users: InviteList) -> None:
if isinstance(users, list): if isinstance(users, list):
for user in users: for user in users:
await self.main_intent.invite_user(self.mxid, user, check_cache=True) await self.invite_to_matrix(user)
else: else:
await self.main_intent.invite_user(self.mxid, users, check_cache=True) 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], async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
direct: bool = None, puppet: p.Puppet = None, direct: bool = None, puppet: p.Puppet = None,
@@ -337,12 +352,13 @@ class PortalMetadata(BasePortal, ABC):
if self.peer_type == "channel": if self.peer_type == "channel":
self.megagroup = entity.megagroup self.megagroup = entity.megagroup
preset = RoomCreatePreset.PRIVATE
if self.peer_type == "channel" and entity.username: if self.peer_type == "channel" and entity.username:
preset = RoomCreatePreset.PUBLIC if self.public_portals:
preset = RoomCreatePreset.PUBLIC
self.username = entity.username self.username = entity.username
alias = self.alias_localpart alias = self.alias_localpart
else: else:
preset = RoomCreatePreset.PRIVATE
# TODO invite link alias? # TODO invite link alias?
alias = None alias = None
@@ -379,6 +395,7 @@ class PortalMetadata(BasePortal, ABC):
"state_key": self.bridge_info_state_key, "state_key": self.bridge_info_state_key,
"content": self.bridge_info, "content": self.bridge_info,
}] }]
create_invites = []
if config["bridge.encryption.default"] and self.matrix.e2ee: if config["bridge.encryption.default"] and self.matrix.e2ee:
self.encrypted = True self.encrypted = True
initial_state.append({ initial_state.append({
@@ -386,7 +403,7 @@ class PortalMetadata(BasePortal, ABC):
"content": {"algorithm": "m.megolm.v1.aes-sha2"}, "content": {"algorithm": "m.megolm.v1.aes-sha2"},
}) })
if direct: if direct:
invites.append(self.az.bot_mxid) create_invites.append(self.az.bot_mxid)
if direct and (self.encrypted or self.private_chat_portal_meta): if direct and (self.encrypted or self.private_chat_portal_meta):
self.title = puppet.displayname self.title = puppet.displayname
if config["appservice.community_id"]: if config["appservice.community_id"]:
@@ -400,7 +417,7 @@ class PortalMetadata(BasePortal, ABC):
with self.backfill_lock: with self.backfill_lock:
room_id = await self.main_intent.create_room(alias_localpart=alias, preset=preset, room_id = await self.main_intent.create_room(alias_localpart=alias, preset=preset,
is_direct=direct, invitees=invites or [], is_direct=direct, invitees=create_invites,
name=self.title, topic=self.about, name=self.title, topic=self.about,
initial_state=initial_state, initial_state=initial_state,
creation_content=creation_content) creation_content=creation_content)
@@ -419,6 +436,8 @@ class PortalMetadata(BasePortal, ABC):
await self.az.state_store.set_power_levels(self.mxid, power_levels) await self.az.state_store.set_power_levels(self.mxid, power_levels)
await user.register_portal(self) await user.register_portal(self)
await self.invite_to_matrix(invites)
update_room = self.loop.create_task(self.update_matrix_room( update_room = self.loop.create_task(self.update_matrix_room(
user, entity, direct, puppet, user, entity, direct, puppet,
levels=power_levels, users=users)) levels=power_levels, users=users))
@@ -569,13 +588,6 @@ class PortalMetadata(BasePortal, ABC):
if user: if user:
await self.invite_to_matrix(user.mxid) await self.invite_to_matrix(user.mxid)
puppet = await p.Puppet.get_by_custom_mxid(user.mxid)
if puppet:
try:
await puppet.intent.ensure_joined(self.mxid)
except Exception:
self.log.exception("Failed to ensure %s is joined to portal", user.mxid)
# We can't trust the member list if any of the following cases is true: # 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. # * 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. # * The member sync count is limited, because then we might ignore some members.
@@ -747,15 +759,13 @@ class PortalMetadata(BasePortal, ABC):
if isinstance(photo, (ChatPhoto, UserProfilePhoto)): if isinstance(photo, (ChatPhoto, UserProfilePhoto)):
loc = InputPeerPhotoFileLocation( loc = InputPeerPhotoFileLocation(
peer=await self.get_input_entity(user), peer=await self.get_input_entity(user),
local_id=photo.photo_big.local_id, photo_id=photo.photo_id,
volume_id=photo.photo_big.volume_id,
big=True big=True
) )
photo_id = (f"{loc.volume_id}-{loc.local_id}" if isinstance(photo, ChatPhoto) photo_id = str(photo.photo_id)
else photo.photo_id)
elif isinstance(photo, Photo): elif isinstance(photo, Photo):
loc, largest = self._get_largest_photo_size(photo) loc, _ = self._get_largest_photo_size(photo)
photo_id = f"{largest.location.volume_id}-{largest.location.local_id}" photo_id = str(loc.id)
elif isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty, PhotoEmpty, type(None))): elif isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty, PhotoEmpty, type(None))):
photo_id = "" photo_id = ""
loc = None loc = None
+11 -5
View File
@@ -66,8 +66,10 @@ config: Optional['Config'] = None
class PortalTelegram(BasePortal, ABC): class PortalTelegram(BasePortal, ABC):
async def handle_telegram_typing(self, user: p.Puppet, update: UpdateTyping) -> None: 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) is_typing = isinstance(update.action, SendMessageTypingAction)
# Always use the default puppet here to avoid any problems with echoing
await user.default_mxid_intent.set_typing(self.mxid, is_typing=is_typing) await user.default_mxid_intent.set_typing(self.mxid, is_typing=is_typing)
def _get_external_url(self, evt: Message) -> Optional[str]: def _get_external_url(self, evt: Message) -> Optional[str]:
@@ -247,10 +249,14 @@ class PortalTelegram(BasePortal, ABC):
if info.thumbnail_info: if info.thumbnail_info:
info.thumbnail_info.width = info.width info.thumbnail_info.width = info.width
info.thumbnail_info.height = info.height info.thumbnail_info.height = info.height
if attrs.is_gif: if attrs.is_gif or (attrs.is_sticker and info.mimetype == "video/webm"):
info["fi.mau.telegram.gif"] = True 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.loop"] = True
info["fi.mau.autoplay"] = True info["fi.mau.autoplay"] = True
info["fi.mau.hide_controls"] = True
info["fi.mau.no_audio"] = True info["fi.mau.no_audio"] = True
content = MediaMessageEventContent( content = MediaMessageEventContent(
@@ -306,7 +312,7 @@ class PortalTelegram(BasePortal, ABC):
async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI, async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI,
evt: Message, relates_to: RelatesTo = None) -> EventID: evt: Message, relates_to: RelatesTo = None) -> EventID:
override_text = ("This message is not supported on your version of Mautrix-Telegram. " override_text = ("This message is not supported on your version of Mautrix-Telegram. "
"Please check https://github.com/tulir/mautrix-telegram or ask your " "Please check https://github.com/mautrix/telegram or ask your "
"bridge administrator about possible updates.") "bridge administrator about possible updates.")
content = await formatter.telegram_to_matrix( content = await formatter.telegram_to_matrix(
evt, source, self.main_intent, override_text=override_text) evt, source, self.main_intent, override_text=override_text)
@@ -408,7 +414,7 @@ class PortalTelegram(BasePortal, ABC):
@staticmethod @staticmethod
def _int_to_bytes(i: int) -> bytes: def _int_to_bytes(i: int) -> bytes:
hex_value = "{0:010x}".format(i).encode("utf-8") hex_value = f"{i:010x}".encode("utf-8")
return codecs.decode(hex_value, "hex_codec") return codecs.decode(hex_value, "hex_codec")
def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str: def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str:
+1 -2
View File
@@ -344,8 +344,7 @@ class Puppet(BasePuppet):
loc = InputPeerPhotoFileLocation( loc = InputPeerPhotoFileLocation(
peer=await self.get_input_entity(source), peer=await self.get_input_entity(source),
local_id=photo.photo_big.local_id, photo_id=photo.photo_id,
volume_id=photo.photo_big.volume_id,
big=True big=True
) )
file = await util.transfer_file_to_matrix(source.client, self.default_mxid_intent, loc) file = await util.transfer_file_to_matrix(source.client, self.default_mxid_intent, loc)
+42 -22
View File
@@ -27,11 +27,13 @@ from telethon.tl.custom import Dialog
from telethon.tl.types.contacts import ContactsNotModified from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
from telethon.tl.functions.account import UpdateStatusRequest from telethon.tl.functions.account import UpdateStatusRequest
from telethon.errors import AuthKeyDuplicatedError
from mautrix.client import Client from mautrix.client import Client
from mautrix.errors import MatrixRequestError, MNotFound from mautrix.errors import MatrixRequestError, MNotFound
from mautrix.types import UserID, RoomID, PushRuleScope, PushRuleKind, PushActionType, RoomTagInfo from mautrix.types import UserID, RoomID, PushRuleScope, PushRuleKind, PushActionType, RoomTagInfo
from mautrix.bridge import BaseUser, BridgeState from mautrix.bridge import BaseUser, BridgeState
from mautrix.util.bridge_state import BridgeStateEvent
from mautrix.util.logging import TraceLogger from mautrix.util.logging import TraceLogger
from mautrix.util.opt_prometheus import Gauge from mautrix.util.opt_prometheus import Gauge
@@ -53,7 +55,7 @@ METRIC_CONNECTED = Gauge('bridge_connected', 'Users connected to Telegram')
BridgeState.human_readable_errors.update({ BridgeState.human_readable_errors.update({
"tg-not-connected": "Your Telegram connection failed", "tg-not-connected": "Your Telegram connection failed",
"logged-out": "You're not logged into Telegram", "tg-auth-key-duplicated": "The bridge accidentally logged you out",
}) })
@@ -93,6 +95,7 @@ class User(AbstractUser, BaseUser):
self._db_instance = db_instance self._db_instance = db_instance
self._ensure_started_lock = asyncio.Lock() self._ensure_started_lock = asyncio.Lock()
self._track_connection_task = None self._track_connection_task = None
self._is_backfilling = False
(self.relaybot_whitelisted, (self.relaybot_whitelisted,
self.whitelisted, self.whitelisted,
@@ -201,7 +204,22 @@ class User(AbstractUser, BaseUser):
return cast(User, await super().ensure_started(even_if_no_session)) return cast(User, await super().ensure_started(even_if_no_session))
async def start(self, delete_unless_authenticated: bool = False) -> 'User': async def start(self, delete_unless_authenticated: bool = False) -> 'User':
await super().start() try:
await super().start()
except AuthKeyDuplicatedError:
self.log.warning("Got AuthKeyDuplicatedError in start()")
await self.push_bridge_state(BridgeStateEvent.BAD_CREDENTIALS,
error="tg-auth-key-duplicated")
await self.client.disconnect()
self.client.session.delete()
self.client = None
if not delete_unless_authenticated:
# The caller wants the client to be connected, so restart the connection.
await super().start()
return self
except Exception:
await self.push_bridge_state(BridgeStateEvent.UNKNOWN_ERROR)
raise
if await self.is_logged_in(): if await self.is_logged_in():
self.log.debug(f"Ensuring post_login() for {self.name}") self.log.debug(f"Ensuring post_login() for {self.name}")
self.loop.create_task(self.post_login()) self.loop.create_task(self.post_login())
@@ -211,36 +229,40 @@ class User(AbstractUser, BaseUser):
self.client.session.delete() self.client.session.delete()
return self return self
@property
def _is_connected(self) -> bool:
return bool(self.client and self.client._sender
and self.client._sender._transport_connected)
async def _track_connection(self) -> None: async def _track_connection(self) -> None:
self.log.debug("Starting loop to track connection state") self.log.debug("Starting loop to track connection state")
while True: while True:
await asyncio.sleep(3) await asyncio.sleep(3)
connected = bool(self.client._sender._transport_connected connected = self._is_connected
if self.client and self.client._sender else False)
self._track_metric(METRIC_CONNECTED, connected) self._track_metric(METRIC_CONNECTED, connected)
await self.push_bridge_state(ok=connected, ttl=3600 if connected else 240, if connected:
error="tg-not-connected" if not connected else None) await self.push_bridge_state(BridgeStateEvent.BACKFILLING if self._is_backfilling
else BridgeStateEvent.CONNECTED, ttl=3600)
else:
await self.push_bridge_state(BridgeStateEvent.UNKNOWN_ERROR, ttl=240,
error="tg-not-connected")
async def fill_bridge_state(self, state: BridgeState) -> None: async def fill_bridge_state(self, state: BridgeState) -> None:
await super().fill_bridge_state(state) await super().fill_bridge_state(state)
state.remote_id = str(self.tgid) state.remote_id = str(self.tgid)
state.remote_name = self.human_tg_id state.remote_name = self.human_tg_id
async def get_bridge_state(self) -> BridgeState: async def get_puppet(self) -> Optional['pu.Puppet']:
if not self.client: if not self.tgid:
return BridgeState(ok=False, error="logged-out") return None
elif not self.client._sender or not self.client._sender._transport_connected: return pu.Puppet.get(self.tgid)
return BridgeState(ok=False, error="tg-not-connected")
else:
return BridgeState(ok=True)
async def stop(self) -> None: async def stop(self) -> None:
await super().stop()
if self._track_connection_task: if self._track_connection_task:
self._track_connection_task.cancel() self._track_connection_task.cancel()
self._track_connection_task = None self._track_connection_task = None
await super().stop()
self._track_metric(METRIC_CONNECTED, False) self._track_metric(METRIC_CONNECTED, False)
await self.push_bridge_state(ok=False, error="tg-not-connected")
async def post_login(self, info: TLUser = None, first_login: bool = False) -> None: async def post_login(self, info: TLUser = None, first_login: bool = False) -> None:
if config["metrics.enabled"] and not self._track_connection_task: if config["metrics.enabled"] and not self._track_connection_task:
@@ -264,10 +286,13 @@ class User(AbstractUser, BaseUser):
if not self.is_bot and config["bridge.startup_sync"]: if not self.is_bot and config["bridge.startup_sync"]:
try: try:
self._is_backfilling = True
await self.sync_dialogs() await self.sync_dialogs()
await self.sync_contacts() await self.sync_contacts()
except Exception: except Exception:
self.log.exception("Failed to run post-login sync") self.log.exception("Failed to run post-login sync")
finally:
self._is_backfilling = False
async def update(self, update: TypeUpdate) -> bool: async def update(self, update: TypeUpdate) -> bool:
if not self.is_bot: if not self.is_bot:
@@ -332,6 +357,7 @@ class User(AbstractUser, BaseUser):
self.portals = {} self.portals = {}
self.contacts = [] self.contacts = []
await self.save(portals=True, contacts=True) await self.save(portals=True, contacts=True)
await self.push_bridge_state(BridgeStateEvent.LOGGED_OUT)
if self.tgid: if self.tgid:
try: try:
del self.by_tgid[self.tgid] del self.by_tgid[self.tgid]
@@ -345,7 +371,6 @@ class User(AbstractUser, BaseUser):
self.delete() self.delete()
await self.stop() await self.stop()
self._track_metric(METRIC_LOGGED_IN, False) self._track_metric(METRIC_LOGGED_IN, False)
await self.push_bridge_state(ok=False, error="logged-out")
return True return True
def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45 def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
@@ -381,12 +406,6 @@ class User(AbstractUser, BaseUser):
return await self._search_remote(query), True return await self._search_remote(query), True
async def _catch(self, action: str, task: asyncio.Task) -> None:
try:
await task
except Exception:
self.log.exception(f"Error while {action}")
async def get_direct_chats(self) -> Dict[UserID, List[RoomID]]: async def get_direct_chats(self) -> Dict[UserID, List[RoomID]]:
return { return {
pu.Puppet.get_mxid_from_id(portal.tgid): [portal.mxid] pu.Puppet.get_mxid_from_id(portal.tgid): [portal.mxid]
@@ -498,6 +517,7 @@ class User(AbstractUser, BaseUser):
index = 0 index = 0
self.log.debug(f"Syncing dialogs (update_limit={update_limit}, " self.log.debug(f"Syncing dialogs (update_limit={update_limit}, "
f"create_limit={create_limit})") f"create_limit={create_limit})")
await self.push_bridge_state(BridgeStateEvent.BACKFILLING)
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid) puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
dialog: Dialog dialog: Dialog
async for dialog in self.client.iter_dialogs(limit=update_limit, ignore_migrated=True, async for dialog in self.client.iter_dialogs(limit=update_limit, ignore_migrated=True,
+3 -1
View File
@@ -102,8 +102,10 @@ def _location_to_id(location: TypeLocation) -> str:
return f"{location.id}-{location.access_hash}" return f"{location.id}-{location.access_hash}"
elif isinstance(location, (InputDocumentFileLocation, InputPhotoFileLocation)): elif isinstance(location, (InputDocumentFileLocation, InputPhotoFileLocation)):
return f"{location.id}-{location.access_hash}-{location.thumb_size}" return f"{location.id}-{location.access_hash}-{location.thumb_size}"
elif isinstance(location, (InputFileLocation, InputPeerPhotoFileLocation)): elif isinstance(location, InputFileLocation):
return f"{location.volume_id}-{location.local_id}" return f"{location.volume_id}-{location.local_id}"
elif isinstance(location, InputPeerPhotoFileLocation):
return str(location.photo_id)
async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: IntentAPI, async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
@@ -134,7 +134,7 @@ class ParallelTransferrer:
self.upload_ticker = 0 self.upload_ticker = 0
async def _cleanup(self) -> None: async def _cleanup(self) -> None:
await asyncio.gather(*[sender.disconnect() for sender in self.senders]) await asyncio.gather(*(sender.disconnect() for sender in self.senders))
self.senders = None self.senders = None
@staticmethod @staticmethod
@@ -161,9 +161,9 @@ class ParallelTransferrer:
await self._create_download_sender(file, 0, part_size, connections * part_size, await self._create_download_sender(file, 0, part_size, connections * part_size,
get_part_count()), get_part_count()),
*await asyncio.gather( *await asyncio.gather(
*[self._create_download_sender(file, i, part_size, connections * part_size, *(self._create_download_sender(file, i, part_size, connections * part_size,
get_part_count()) get_part_count())
for i in range(1, connections)]) for i in range(1, connections)))
] ]
async def _create_download_sender(self, file: TypeLocation, index: int, part_size: int, async def _create_download_sender(self, file: TypeLocation, index: int, part_size: int,
@@ -177,8 +177,8 @@ class ParallelTransferrer:
self.senders = [ self.senders = [
await self._create_upload_sender(file_id, part_count, big, 0, connections), await self._create_upload_sender(file_id, part_count, big, 0, connections),
*await asyncio.gather( *await asyncio.gather(
*[self._create_upload_sender(file_id, part_count, big, i, connections) *(self._create_upload_sender(file_id, part_count, big, i, connections)
for i in range(1, connections)]) for i in range(1, connections)))
] ]
async def _create_upload_sender(self, file_id: int, part_count: int, big: bool, index: int, async def _create_upload_sender(self, file_id: int, part_count: int, big: bool, index: int,
+3 -3
View File
@@ -6,11 +6,11 @@ info:
description: The provisioning API for Mautrix-Telegram, the Matrix-Telegram puppeting/relaybot bridge. description: The provisioning API for Mautrix-Telegram, the Matrix-Telegram puppeting/relaybot bridge.
license: license:
name: AGPLv3 name: AGPLv3
url: https://github.com/tulir/mautrix-telegram/blob/master/LICENSE url: https://github.com/mautrix/telegram/blob/master/LICENSE
externalDocs: externalDocs:
description: Provisioning API wiki page on GitHub description: Provisioning API docs on docs.mau.fi
url: https://github.com/tulir/mautrix-telegram/wiki/Provisioning-API url: https://docs.mau.fi/bridges/python/telegram/provisioning-api.html
basePath: /_matrix/provision/v1 basePath: /_matrix/provision/v1
+1 -1
View File
@@ -21,7 +21,7 @@ prometheus_client>=0.6,<0.12
psycopg2-binary>=2,<3 psycopg2-binary>=2,<3
#/e2be #/e2be
asyncpg>=0.20,<0.24 asyncpg>=0.20,<0.25
python-olm>=3,<4 python-olm>=3,<4
pycryptodome>=3,<4 pycryptodome>=3,<4
unpaddedbase64>=1,<2 unpaddedbase64>=1,<2
+2 -2
View File
@@ -5,6 +5,6 @@ python-magic>=0.4,<0.5
commonmark>=0.8,<0.10 commonmark>=0.8,<0.10
aiohttp>=3,<4 aiohttp>=3,<4
yarl>=1,<2 yarl>=1,<2
mautrix>=0.9.3,<0.10 mautrix>=0.10.4,<0.11
telethon>=1.20,<1.22 telethon>=1.22,<1.23
telethon-session-sqlalchemy>=0.2.14,<0.3 telethon-session-sqlalchemy>=0.2.14,<0.3
+1 -1
View File
@@ -36,7 +36,7 @@ linkified_version = {linkified_version!r}
setuptools.setup( setuptools.setup(
name="mautrix-telegram", name="mautrix-telegram",
version=version, version=version,
url="https://github.com/tulir/mautrix-telegram", url="https://github.com/mautrix/telegram",
author="Tulir Asokan", author="Tulir Asokan",
author_email="tulir@maunium.net", author_email="tulir@maunium.net",