Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cc439853f | |||
| 76b2937c18 | |||
| f2a9f4ab33 | |||
| ec375e79d7 | |||
| 338a4d9761 | |||
| 83d457f2b3 | |||
| 3507095572 | |||
| 4e7cf481fd | |||
| 0915bb9402 | |||
| 7c5d1c2959 | |||
| 8aecf1f84b | |||
| 2c45d8dd5b | |||
| fac337eaf1 | |||
| e7d8948334 | |||
| 6b8831872c | |||
| 4e8c373d1b | |||
| 8865dab6b0 | |||
| e4a2bd2f69 | |||
| a132916525 | |||
| a9dcb34b2d | |||
| 74c43355e4 | |||
| 7255e86595 | |||
| e4098a226e | |||
| 5dea5977ad | |||
| 1c9a30773e | |||
| e276944b40 | |||
| 2e14991815 | |||
| 3083727aff | |||
| d778c639dc | |||
| 10de186598 | |||
| 64107fab17 | |||
| 52bfbddcca | |||
| 5d9cc490d7 | |||
| 13cac8db9a | |||
| 3ab5e4d8cc | |||
| 7e728dd5af | |||
| 597d2e3282 | |||
| 57611a3f30 | |||
| ec64c83cb0 | |||
| ecdaaea3b9 | |||
| bda41417aa | |||
| 5a76b5bcdc | |||
| 4edd8eaa7b | |||
| 742a925040 | |||
| bcede7710f | |||
| c02f67e0d1 | |||
| 31650aac96 | |||
| 730f6bab6f | |||
| f923552f86 | |||
| eca1032d16 |
+21
-3
@@ -19,10 +19,28 @@ build amd64:
|
|||||||
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
||||||
after_script:
|
after_script:
|
||||||
- |
|
- |
|
||||||
if [ "$CI_COMMIT_BRANCH" = "master" ]; then
|
if [[ "$CI_COMMIT_BRANCH" == "master" && "$CI_JOB_STATUS" == "success" ]]; then
|
||||||
apk add --update curl
|
apk add --update curl jq
|
||||||
rm -rf /var/cache/apk/*
|
rm -rf /var/cache/apk/*
|
||||||
curl "$NOVA_ADMIN_API_URL" -H "Content-Type: application/json" -d '{"password":"'"$NOVA_ADMIN_NIGHTLY_PASS"'","bridge":"'$NOVA_BRIDGE_TYPE'","image":"'$CI_REGISTRY_IMAGE':'$CI_COMMIT_SHA'-amd64"}'
|
|
||||||
|
jq -n '
|
||||||
|
{
|
||||||
|
password: env.BEEPER_DEV_ADMIN_NIGHTLY_PASS,
|
||||||
|
bridge: env.BEEPER_BRIDGE_TYPE,
|
||||||
|
image: "\(env.CI_REGISTRY_IMAGE):\(env.CI_COMMIT_SHA)-amd64",
|
||||||
|
channel: "STABLE"
|
||||||
|
}
|
||||||
|
' | curl "$BEEPER_DEV_ADMIN_API_URL" -H "Content-Type: application/json" -d @-
|
||||||
|
|
||||||
|
jq -n '
|
||||||
|
{
|
||||||
|
password: env.BEEPER_PROD_ADMIN_NIGHTLY_PASS,
|
||||||
|
bridge: env.BEEPER_BRIDGE_TYPE,
|
||||||
|
image: "\(env.CI_REGISTRY_IMAGE):\(env.CI_COMMIT_SHA)-amd64",
|
||||||
|
channel: "INTERNAL",
|
||||||
|
deployNext: true
|
||||||
|
}
|
||||||
|
' | curl "$BEEPER_PROD_ADMIN_API_URL" -H "Content-Type: application/json" -d @-
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build arm64:
|
build arm64:
|
||||||
|
|||||||
+6
-3
@@ -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 \
|
||||||
@@ -52,7 +54,8 @@ RUN apk add --virtual .build-deps \
|
|||||||
libffi-dev \
|
libffi-dev \
|
||||||
build-base \
|
build-base \
|
||||||
&& sed -Ei 's/psycopg2-binary.+//' optional-requirements.txt \
|
&& sed -Ei 's/psycopg2-binary.+//' optional-requirements.txt \
|
||||||
&& pip3 install -r requirements.txt -r optional-requirements.txt \
|
# TODO: unpin Pillow here after it's updated in Alpine
|
||||||
|
&& pip3 install -r requirements.txt -r optional-requirements.txt 'pillow==8.2' \
|
||||||
&& apk del .build-deps
|
&& apk del .build-deps
|
||||||
|
|
||||||
COPY . /opt/mautrix-telegram
|
COPY . /opt/mautrix-telegram
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# mautrix-telegram
|
# mautrix-telegram
|
||||||

|

|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/tulir/mautrix-telegram/releases)
|
[](https://github.com/mautrix/telegram/releases)
|
||||||
[](https://mau.dev/tulir/mautrix-telegram/container_registry)
|
[](https://mau.dev/mautrix/telegram/container_registry)
|
||||||
[](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
@@ -59,5 +59,5 @@
|
|||||||
* [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram)
|
* [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram)
|
||||||
* [x] End-to-bridge encryption in Matrix rooms (see [wiki](https://github.com/tulir/mautrix-telegram/wiki/End%E2%80%90to%E2%80%90bridge-encryption))
|
* [x] End-to-bridge encryption in Matrix rooms (see [wiki](https://github.com/tulir/mautrix-telegram/wiki/End%E2%80%90to%E2%80%90bridge-encryption))
|
||||||
|
|
||||||
† Information not automatically sent from source, i.e. implementation may not be possible
|
† Information not automatically sent from source, i.e. implementation may not be possible
|
||||||
‡ Maybe, i.e. this feature may or may not be implemented at some point
|
‡ Maybe, i.e. this feature may or may not be implemented at some point
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "0.10.0"
|
__version__ = "0.10.2"
|
||||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ if TYPE_CHECKING:
|
|||||||
from .context import Context
|
from .context import Context
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
|
from .__main__ import TelegramBridge
|
||||||
|
|
||||||
config: Optional['Config'] = None
|
config: Optional['Config'] = None
|
||||||
# Value updated from config in init()
|
# Value updated from config in init()
|
||||||
@@ -71,6 +72,7 @@ class AbstractUser(ABC):
|
|||||||
loop: asyncio.AbstractEventLoop = None
|
loop: asyncio.AbstractEventLoop = None
|
||||||
log: TraceLogger
|
log: TraceLogger
|
||||||
az: AppService
|
az: AppService
|
||||||
|
bridge: 'TelegramBridge'
|
||||||
relaybot: Optional['Bot']
|
relaybot: Optional['Bot']
|
||||||
ignore_incoming_bot_events: bool = True
|
ignore_incoming_bot_events: bool = True
|
||||||
|
|
||||||
@@ -129,9 +131,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 +147,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"],
|
||||||
@@ -196,7 +198,7 @@ class AbstractUser(ABC):
|
|||||||
if not await self.update(update):
|
if not await self.update(update):
|
||||||
await self._update(update)
|
await self._update(update)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception(f"Failed to handle Telegram update {update}")
|
self.log.exception("Failed to handle Telegram update")
|
||||||
UPDATE_ERRORS.labels(update_type=update_type).inc()
|
UPDATE_ERRORS.labels(update_type=update_type).inc()
|
||||||
UPDATE_TIME.labels(update_type=update_type).observe(time.time() - start_time)
|
UPDATE_TIME.labels(update_type=update_type).observe(time.time() - start_time)
|
||||||
|
|
||||||
@@ -513,6 +515,7 @@ class AbstractUser(ABC):
|
|||||||
def init(context: 'Context') -> None:
|
def init(context: 'Context') -> None:
|
||||||
global config, MAX_DELETIONS
|
global config, MAX_DELETIONS
|
||||||
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
|
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
|
||||||
|
AbstractUser.bridge = context.bridge
|
||||||
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
|
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
|
||||||
AbstractUser.session_container = context.session_container
|
AbstractUser.session_container = context.session_container
|
||||||
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
|
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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}")
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -46,10 +46,13 @@ except ImportError:
|
|||||||
help_section=SECTION_AUTH,
|
help_section=SECTION_AUTH,
|
||||||
help_text="Check if you're logged into Telegram.")
|
help_text="Check if you're logged into Telegram.")
|
||||||
async def ping(evt: CommandEvent) -> EventID:
|
async def ping(evt: CommandEvent) -> EventID:
|
||||||
me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None
|
if await evt.sender.is_logged_in():
|
||||||
if me:
|
me = await evt.sender.get_me()
|
||||||
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
|
if me:
|
||||||
return await evt.reply(f"You're logged in as {human_tg_id}")
|
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
|
||||||
|
return await evt.reply(f"You're logged in as {human_tg_id}")
|
||||||
|
else:
|
||||||
|
return await evt.reply("You were logged in, but there appears to have been an error.")
|
||||||
else:
|
else:
|
||||||
return await evt.reply("You're not logged in.")
|
return await evt.reply("You're not logged in.")
|
||||||
|
|
||||||
@@ -346,10 +349,12 @@ async def _finish_sign_in(evt: CommandEvent, user: User, login_as: 'u.User' = No
|
|||||||
return await evt.reply(msg)
|
return await evt.reply(msg)
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=True,
|
@command_handler(needs_auth=False,
|
||||||
help_section=SECTION_AUTH,
|
help_section=SECTION_AUTH,
|
||||||
help_text="Log out from Telegram.")
|
help_text="Log out from Telegram.")
|
||||||
async def logout(evt: CommandEvent) -> EventID:
|
async def logout(evt: CommandEvent) -> EventID:
|
||||||
|
if not evt.sender.tgid:
|
||||||
|
return await evt.reply("You're not logged in")
|
||||||
if await evt.sender.log_out():
|
if await evt.sender.log_out():
|
||||||
return await evt.reply("Logged out successfully.")
|
return await evt.reply("Logged out successfully.")
|
||||||
return await evt.reply("Failed to log out.")
|
return await evt.reply("Failed to log out.")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -244,14 +244,7 @@ bridge:
|
|||||||
# Default to encryption, force-enable encryption in all portals the bridge creates
|
# Default to encryption, force-enable encryption in all portals the bridge creates
|
||||||
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
||||||
default: false
|
default: false
|
||||||
# Database for the encryption data. Currently only supports Postgres and an in-memory
|
# Database for the encryption data. If set to `default`, will use the appservice database.
|
||||||
# store that's persisted as a pickle.
|
|
||||||
# If set to `default`, will use the appservice postgres database
|
|
||||||
# or a pickle file if the appservice database is sqlite.
|
|
||||||
#
|
|
||||||
# Format examples:
|
|
||||||
# Pickle: pickle:///filename.pickle
|
|
||||||
# Postgres: postgres://username:password@hostname/dbname
|
|
||||||
database: default
|
database: default
|
||||||
# Options for automatic key sharing.
|
# Options for automatic key sharing.
|
||||||
key_sharing:
|
key_sharing:
|
||||||
@@ -394,6 +387,21 @@ bridge:
|
|||||||
# The prefix for commands. Only required in non-management rooms.
|
# The prefix for commands. Only required in non-management rooms.
|
||||||
command_prefix: "!tg"
|
command_prefix: "!tg"
|
||||||
|
|
||||||
|
# Messages sent upon joining a management room.
|
||||||
|
# Markdown is supported. The defaults are listed below.
|
||||||
|
management_room_text:
|
||||||
|
# Sent when joining a room.
|
||||||
|
welcome: "Hello, I'm a Telegram bridge bot."
|
||||||
|
# Sent when joining a management room and the user is already logged in.
|
||||||
|
welcome_connected: "Use `help` for help."
|
||||||
|
# Sent when joining a management room and the user is not logged in.
|
||||||
|
welcome_unconnected: "Use `help` for help or `login` to log in."
|
||||||
|
# Optional extra text sent when joining a management room.
|
||||||
|
additional_help: ""
|
||||||
|
|
||||||
|
# Send each message separately (for readability in some clients)
|
||||||
|
management_room_multiple_messages: false
|
||||||
|
|
||||||
# Permissions for using the bridge.
|
# Permissions for using the bridge.
|
||||||
# Permitted values:
|
# Permitted values:
|
||||||
# relaybot - Only use the bridge via the relaybot, no access to commands.
|
# relaybot - Only use the bridge via the relaybot, no access to commands.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from telethon.helpers import add_surrogate, del_surrogate
|
|||||||
from mautrix.errors import MatrixRequestError
|
from mautrix.errors import MatrixRequestError
|
||||||
from mautrix.appservice import IntentAPI
|
from mautrix.appservice import IntentAPI
|
||||||
from mautrix.types import (TextMessageEventContent, RelatesTo, RelationType, Format, MessageType,
|
from mautrix.types import (TextMessageEventContent, RelatesTo, RelationType, Format, MessageType,
|
||||||
MessageEvent)
|
MessageEvent, EventType)
|
||||||
|
|
||||||
from .. import user as u, puppet as pu, portal as po
|
from .. import user as u, puppet as pu, portal as po
|
||||||
from ..types import TelegramID
|
from ..types import TelegramID
|
||||||
@@ -129,12 +129,14 @@ async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventCon
|
|||||||
content.relates_to = RelatesTo(rel_type=RelationType.REPLY, event_id=msg.mxid)
|
content.relates_to = RelatesTo(rel_type=RelationType.REPLY, event_id=msg.mxid)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event: MessageEvent = await main_intent.get_event(msg.mx_room, msg.mxid)
|
event = await main_intent.get_event(msg.mx_room, msg.mxid)
|
||||||
|
if event.type == EventType.ROOM_ENCRYPTED and source.bridge.matrix.e2ee:
|
||||||
|
event = await source.bridge.matrix.e2ee.decrypt(event)
|
||||||
if isinstance(event.content, TextMessageEventContent):
|
if isinstance(event.content, TextMessageEventContent):
|
||||||
event.content.trim_reply_fallback()
|
event.content.trim_reply_fallback()
|
||||||
puppet = await pu.Puppet.get_by_mxid(event.sender, create=False)
|
puppet = await pu.Puppet.get_by_mxid(event.sender, create=False)
|
||||||
content.set_reply(event, displayname=puppet.displayname if puppet else event.sender)
|
content.set_reply(event, displayname=puppet.displayname if puppet else event.sender)
|
||||||
except MatrixRequestError:
|
except Exception:
|
||||||
log.exception("Failed to get event to add reply fallback")
|
log.exception("Failed to get event to add reply fallback")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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("-", ""):
|
||||||
|
|||||||
@@ -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: "
|
||||||
@@ -115,23 +115,6 @@ class MatrixHandler(BaseMatrixHandler):
|
|||||||
await intent.send_notice(room_id, "This puppet will remain inactive until a "
|
await intent.send_notice(room_id, "This puppet will remain inactive until a "
|
||||||
"Telegram chat is created for this room.")
|
"Telegram chat is created for this room.")
|
||||||
|
|
||||||
async def send_welcome_message(self, room_id: RoomID, inviter: 'u.User') -> None:
|
|
||||||
try:
|
|
||||||
is_management = len(await self.az.intent.get_room_members(room_id)) == 2
|
|
||||||
except MatrixError:
|
|
||||||
# The AS bot is not in the room.
|
|
||||||
return
|
|
||||||
cmd_prefix = self.commands.command_prefix
|
|
||||||
text = html = "Hello, I'm a Telegram bridge bot. "
|
|
||||||
if is_management and inviter.puppet_whitelisted and not await inviter.is_logged_in():
|
|
||||||
text += f"Use `{cmd_prefix} help` for help or `{cmd_prefix} login` to log in."
|
|
||||||
html += (f"Use <code>{cmd_prefix} help</code> for help"
|
|
||||||
f" or <code>{cmd_prefix} login</code> to log in.")
|
|
||||||
else:
|
|
||||||
text += f"Use `{cmd_prefix} help` for help."
|
|
||||||
html += f"Use <code>{cmd_prefix} help</code> for help."
|
|
||||||
await self.az.intent.send_notice(room_id, text=text, html=html)
|
|
||||||
|
|
||||||
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:
|
event_id: EventID) -> None:
|
||||||
user = u.User.get_by_mxid(user_id, create=False)
|
user = u.User.get_by_mxid(user_id, create=False)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -421,7 +421,8 @@ class PortalMatrix(BasePortal, ABC):
|
|||||||
await self._handle_matrix_file(sender_id, event_id, space, client, content, reply_to,
|
await self._handle_matrix_file(sender_id, event_id, space, client, content, reply_to,
|
||||||
caption_content)
|
caption_content)
|
||||||
else:
|
else:
|
||||||
self.log.trace("Unhandled Matrix event: %s", content)
|
self.log.debug("Didn't handle Matrix event {event_id} due to unknown msgtype {content.msgtype}")
|
||||||
|
self.log.trace("Unhandled Matrix event content: %s", content)
|
||||||
|
|
||||||
async def handle_matrix_unpin_all(self, sender: 'u.User', pin_event_id: EventID) -> None:
|
async def handle_matrix_unpin_all(self, sender: 'u.User', pin_event_id: EventID) -> None:
|
||||||
await sender.client(UnpinAllMessagesRequest(peer=self.peer))
|
await sender.client(UnpinAllMessagesRequest(peer=self.peer))
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from telethon.tl.types import (
|
|||||||
PhotoEmpty, InputChannel, InputUser, ChatPhotoEmpty, PeerUser, Photo, TypeChat, TypeInputPeer,
|
PhotoEmpty, InputChannel, InputUser, ChatPhotoEmpty, PeerUser, Photo, TypeChat, TypeInputPeer,
|
||||||
TypeUser, User, InputPeerPhotoFileLocation, ChatParticipantAdmin, ChannelParticipantAdmin,
|
TypeUser, User, InputPeerPhotoFileLocation, ChatParticipantAdmin, ChannelParticipantAdmin,
|
||||||
ChatParticipantCreator, ChannelParticipantCreator, UserProfilePhoto, UserProfilePhotoEmpty,
|
ChatParticipantCreator, ChannelParticipantCreator, UserProfilePhoto, UserProfilePhotoEmpty,
|
||||||
InputPeerUser)
|
InputPeerUser, ChannelParticipantBanned)
|
||||||
|
|
||||||
from mautrix.errors import MForbidden
|
from mautrix.errors import MForbidden
|
||||||
from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership,
|
from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership,
|
||||||
@@ -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))
|
||||||
@@ -484,14 +503,15 @@ class PortalMetadata(BasePortal, ABC):
|
|||||||
levels.users[self.main_intent.mxid] = 100
|
levels.users[self.main_intent.mxid] = 100
|
||||||
return levels
|
return levels
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _get_level_from_participant(participant: TypeParticipant) -> int:
|
def _get_level_from_participant(cls, participant: TypeParticipant,
|
||||||
|
levels: PowerLevelStateEventContent) -> int:
|
||||||
# TODO use the power level requirements to get better precision in channels
|
# TODO use the power level requirements to get better precision in channels
|
||||||
if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
|
if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
|
||||||
return 50
|
return levels.state_default or 50
|
||||||
elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
|
elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
|
||||||
return 95
|
return levels.get_user_level(cls.az.bot_mxid) - 5
|
||||||
return 0
|
return levels.users_default or 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _participant_to_power_levels(levels: PowerLevelStateEventContent,
|
def _participant_to_power_levels(levels: PowerLevelStateEventContent,
|
||||||
@@ -522,7 +542,7 @@ class PortalMetadata(BasePortal, ABC):
|
|||||||
|
|
||||||
puppet = p.Puppet.get(TelegramID(participant.user_id))
|
puppet = p.Puppet.get(TelegramID(participant.user_id))
|
||||||
user = u.User.get_by_tgid(TelegramID(participant.user_id))
|
user = u.User.get_by_tgid(TelegramID(participant.user_id))
|
||||||
new_level = self._get_level_from_participant(participant)
|
new_level = self._get_level_from_participant(participant, levels)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
await user.register_portal(self)
|
await user.register_portal(self)
|
||||||
@@ -569,13 +589,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 +760,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
|
||||||
@@ -786,7 +797,8 @@ class PortalMetadata(BasePortal, ABC):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _filter_participants(users: List[TypeUser], participants: List[TypeParticipant]
|
def _filter_participants(users: List[TypeUser], participants: List[TypeParticipant]
|
||||||
) -> Iterable[TypeUser]:
|
) -> Iterable[TypeUser]:
|
||||||
participant_map = {part.user_id: part for part in participants}
|
participant_map = {part.user_id: part for part in participants
|
||||||
|
if not isinstance(part, ChannelParticipantBanned)}
|
||||||
for user in users:
|
for user in users:
|
||||||
try:
|
try:
|
||||||
user.participant = participant_map[user.id]
|
user.participant = participant_map[user.id]
|
||||||
@@ -822,15 +834,16 @@ class PortalMetadata(BasePortal, ABC):
|
|||||||
async def _get_users(self, user: 'AbstractUser',
|
async def _get_users(self, user: 'AbstractUser',
|
||||||
entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser, InputChannel]
|
entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser, InputChannel]
|
||||||
) -> List[TypeUser]:
|
) -> List[TypeUser]:
|
||||||
|
limit = self.max_initial_member_sync
|
||||||
if self.peer_type == "chat":
|
if self.peer_type == "chat":
|
||||||
chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
|
chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
|
||||||
return list(self._filter_participants(chat.users,
|
return list(
|
||||||
chat.full_chat.participants.participants))
|
self._filter_participants(chat.users, chat.full_chat.participants.participants)
|
||||||
|
)[:limit]
|
||||||
elif self.peer_type == "channel":
|
elif self.peer_type == "channel":
|
||||||
if not self.megagroup and not self.sync_channel_members:
|
if not self.megagroup and not self.sync_channel_members:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
limit = self.max_initial_member_sync
|
|
||||||
if limit == 0:
|
if limit == 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
@@ -191,13 +193,13 @@ class PortalTelegram(BasePortal, ABC):
|
|||||||
height=file.thumbnail.height or thumb_size.h,
|
height=file.thumbnail.height or thumb_size.h,
|
||||||
width=file.thumbnail.width or thumb_size.w,
|
width=file.thumbnail.width or thumb_size.w,
|
||||||
size=file.thumbnail.size)
|
size=file.thumbnail.size)
|
||||||
else:
|
elif attrs.is_sticker:
|
||||||
# This is a hack for bad clients like Element iOS that require a thumbnail
|
# 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:
|
if file.decryption_info:
|
||||||
info.thumbnail_file = file.decryption_info
|
info.thumbnail_file = file.decryption_info
|
||||||
else:
|
else:
|
||||||
info.thumbnail_url = file.mxc
|
info.thumbnail_url = file.mxc
|
||||||
info.thumbnail_info = ImageInfo.deserialize(info.serialize())
|
|
||||||
|
|
||||||
return info, name
|
return info, name
|
||||||
|
|
||||||
@@ -247,14 +249,21 @@ 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
|
||||||
|
if not name:
|
||||||
|
ext = sane_mimetypes.guess_extension(file.mime_type)
|
||||||
|
name = "unnamed_file" + ext
|
||||||
|
|
||||||
content = MediaMessageEventContent(
|
content = MediaMessageEventContent(
|
||||||
body=name or "unnamed file", info=info, relates_to=relates_to,
|
body=name, info=info, relates_to=relates_to,
|
||||||
external_url=self._get_external_url(evt),
|
external_url=self._get_external_url(evt),
|
||||||
msgtype={
|
msgtype={
|
||||||
"video/": MessageType.VIDEO,
|
"video/": MessageType.VIDEO,
|
||||||
@@ -306,7 +315,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 +417,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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+77
-26
@@ -22,16 +22,20 @@ import asyncio
|
|||||||
from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage,
|
from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage,
|
||||||
UpdateShortChatMessage, UpdateShortMessage, User as TLUser, Chat,
|
UpdateShortChatMessage, UpdateShortMessage, User as TLUser, Chat,
|
||||||
ChatForbidden, UpdateFolderPeers, UpdatePinnedDialogs,
|
ChatForbidden, UpdateFolderPeers, UpdatePinnedDialogs,
|
||||||
UpdateNotifySettings, NotifyPeer)
|
UpdateNotifySettings, NotifyPeer, InputUserSelf)
|
||||||
from telethon.tl.custom import Dialog
|
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.tl.functions.users import GetUsersRequest
|
||||||
|
from telethon.errors import (AuthKeyDuplicatedError, UserDeactivatedError, UserDeactivatedBanError,
|
||||||
|
SessionRevokedError, UnauthorizedError)
|
||||||
|
|
||||||
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 +57,8 @@ 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",
|
||||||
|
"tg-not-authenticated": "The stored auth token did not work",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +98,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,46 +207,80 @@ 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())
|
||||||
elif delete_unless_authenticated:
|
elif delete_unless_authenticated:
|
||||||
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting session...")
|
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting session...")
|
||||||
await self.client.disconnect()
|
await self.client.disconnect()
|
||||||
|
if self.tgid:
|
||||||
|
await self.push_bridge_state(BridgeStateEvent.BAD_CREDENTIALS,
|
||||||
|
error="tg-not-authenticated")
|
||||||
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_bridge_states(self) -> List[BridgeState]:
|
||||||
if not self.client:
|
if not self.tgid:
|
||||||
return BridgeState(ok=False, error="logged-out")
|
return []
|
||||||
elif not self.client._sender or not self.client._sender._transport_connected:
|
if self._is_connected and await self.is_logged_in():
|
||||||
return BridgeState(ok=False, error="tg-not-connected")
|
state_event = (BridgeStateEvent.BACKFILLING if self._is_backfilling
|
||||||
|
else BridgeStateEvent.CONNECTED)
|
||||||
|
ttl = 3600
|
||||||
else:
|
else:
|
||||||
return BridgeState(ok=True)
|
state_event = BridgeStateEvent.UNKNOWN_ERROR
|
||||||
|
ttl = 240
|
||||||
|
return [BridgeState(state_event=state_event, ttl=ttl)]
|
||||||
|
|
||||||
|
async def get_puppet(self) -> Optional['pu.Puppet']:
|
||||||
|
if not self.tgid:
|
||||||
|
return None
|
||||||
|
return pu.Puppet.get(self.tgid)
|
||||||
|
|
||||||
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 +304,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:
|
||||||
@@ -296,8 +339,22 @@ class User(AbstractUser, BaseUser):
|
|||||||
if not self.is_bot:
|
if not self.is_bot:
|
||||||
await self.client(UpdateStatusRequest(offline=not online))
|
await self.client(UpdateStatusRequest(offline=not online))
|
||||||
|
|
||||||
|
async def get_me(self) -> Optional[TLUser]:
|
||||||
|
try:
|
||||||
|
return (await self.client(GetUsersRequest([InputUserSelf()])))[0]
|
||||||
|
except UnauthorizedError as e:
|
||||||
|
self.log.error(f"Authorization error in get_me(): {e}")
|
||||||
|
await self.push_bridge_state(BridgeStateEvent.BAD_CREDENTIALS, error="tg-auth-error",
|
||||||
|
message=str(e), ttl=3600)
|
||||||
|
await self.stop()
|
||||||
|
return None
|
||||||
|
|
||||||
async def update_info(self, info: TLUser = None) -> None:
|
async def update_info(self, info: TLUser = None) -> None:
|
||||||
info = info or await self.client.get_me()
|
if not info:
|
||||||
|
info = await self.get_me()
|
||||||
|
if not info:
|
||||||
|
self.log.warning("get_me() returned None, aborting update_info()")
|
||||||
|
return
|
||||||
changed = False
|
changed = False
|
||||||
if self.is_bot != info.bot:
|
if self.is_bot != info.bot:
|
||||||
self.is_bot = info.bot
|
self.is_bot = info.bot
|
||||||
@@ -332,6 +389,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]
|
||||||
@@ -340,13 +398,11 @@ class User(AbstractUser, BaseUser):
|
|||||||
self.tgid = None
|
self.tgid = None
|
||||||
await self.save()
|
await self.save()
|
||||||
ok = await self.client.log_out()
|
ok = await self.client.log_out()
|
||||||
if not ok:
|
self.client.session.delete()
|
||||||
return False
|
|
||||||
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 ok
|
||||||
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
|
||||||
) -> List[SearchResult]:
|
) -> List[SearchResult]:
|
||||||
@@ -381,12 +437,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 +548,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import json
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from telethon.utils import get_peer_id, resolve_id
|
from telethon.utils import get_peer_id, resolve_id
|
||||||
from telethon.tl.types import ChatForbidden, ChannelForbidden, TypeChat
|
from telethon.tl.types import ChatForbidden, ChannelForbidden, TypeChat, InputUserSelf
|
||||||
|
from telethon.tl.functions.users import GetUsersRequest
|
||||||
|
from telethon.errors import (UserDeactivatedError, UserDeactivatedBanError, SessionRevokedError,
|
||||||
|
UnauthorizedError)
|
||||||
|
|
||||||
from mautrix.appservice import AppService
|
from mautrix.appservice import AppService
|
||||||
from mautrix.errors import MatrixRequestError, IntentError
|
from mautrix.errors import MatrixRequestError, IntentError
|
||||||
@@ -294,16 +297,17 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
|
|
||||||
user_data = None
|
user_data = None
|
||||||
if await user.is_logged_in():
|
if await user.is_logged_in():
|
||||||
me = await user.client.get_me()
|
me = await user.get_me()
|
||||||
await user.update_info(me)
|
if me:
|
||||||
user_data = {
|
await user.update_info(me)
|
||||||
"id": user.tgid,
|
user_data = {
|
||||||
"username": user.username,
|
"id": user.tgid,
|
||||||
"first_name": me.first_name,
|
"username": user.username,
|
||||||
"last_name": me.last_name,
|
"first_name": me.first_name,
|
||||||
"phone": me.phone,
|
"last_name": me.last_name,
|
||||||
"is_bot": user.is_bot,
|
"phone": me.phone,
|
||||||
}
|
"is_bot": user.is_bot,
|
||||||
|
}
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"telegram": user_data,
|
"telegram": user_data,
|
||||||
"mxid": user.mxid,
|
"mxid": user.mxid,
|
||||||
@@ -351,7 +355,7 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
return await self.post_login_password(user, data.get("password", ""))
|
return await self.post_login_password(user, data.get("password", ""))
|
||||||
|
|
||||||
async def logout(self, request: web.Request) -> web.Response:
|
async def logout(self, request: web.Request) -> web.Response:
|
||||||
_, user, err = await self.get_user_request_info(request, expect_logged_in=True,
|
_, user, err = await self.get_user_request_info(request, expect_logged_in=None,
|
||||||
require_puppeting=False,
|
require_puppeting=False,
|
||||||
want_data=False)
|
want_data=False)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
@@ -461,7 +465,7 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
Optional[web.Response]]):
|
Optional[web.Response]]):
|
||||||
err = self.check_authorization(request)
|
err = self.check_authorization(request)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
return err
|
return None, None, err
|
||||||
|
|
||||||
data = None
|
data = None
|
||||||
if want_data and (request.method == "POST" or request.method == "PUT"):
|
if want_data and (request.method == "POST" or request.method == "PUT"):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,16 @@ qrcode>=6,<7
|
|||||||
moviepy>=1,<2
|
moviepy>=1,<2
|
||||||
|
|
||||||
#/metrics
|
#/metrics
|
||||||
prometheus_client>=0.6,<0.12
|
prometheus_client>=0.6,<0.13
|
||||||
|
|
||||||
#/postgres
|
#/postgres
|
||||||
psycopg2-binary>=2,<3
|
psycopg2-binary>=2,<3
|
||||||
|
asyncpg>=0.20,<0.25
|
||||||
|
|
||||||
|
#/sqlite
|
||||||
|
aiosqlite>=0.17,<0.18
|
||||||
|
|
||||||
#/e2be
|
#/e2be
|
||||||
asyncpg>=0.20,<0.24
|
|
||||||
python-olm>=3,<4
|
python-olm>=3,<4
|
||||||
pycryptodome>=3,<4
|
pycryptodome>=3,<4
|
||||||
unpaddedbase64>=1,<2
|
unpaddedbase64>=1,<2
|
||||||
|
|||||||
+7
-2
@@ -5,6 +5,11 @@ 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.11.3,<0.12
|
||||||
telethon>=1.20,<1.22
|
#telethon>=1.22,<1.24
|
||||||
|
# Temporary patch for 64-bit IDs until upstream telethon 2.0 is ready
|
||||||
|
tulir-telethon==1.24.0a2
|
||||||
telethon-session-sqlalchemy>=0.2.14,<0.3
|
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)
|
||||||
|
aiosqlite>=0.17,<0.18
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user